diff --git a/.copilotignore b/.copilotignore new file mode 100644 index 000000000..02ec3ad1d --- /dev/null +++ b/.copilotignore @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 000000000..5e535b215 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "fileName": ["AGENTS.md", "GEMINI.md"] + } +} diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml index 8caf40c78..a42959190 100644 --- a/.github/actions/gradle-setup/action.yml +++ b/.github/actions/gradle-setup/action.yml @@ -33,4 +33,8 @@ runs: cache-read-only: ${{ inputs.cache_read_only }} cache-encryption-key: ${{ inputs.gradle_encryption_key }} cache-cleanup: on-success - add-job-summary: always \ No newline at end of file + add-job-summary: always + gradle-home-cache-includes: | + caches + notifications + ~/.m2/repository/org/robolectric \ No newline at end of file diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md new file mode 100644 index 000000000..93c242d16 --- /dev/null +++ b/.github/copilot-commit-message-instructions.md @@ -0,0 +1,27 @@ +# 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 2e60f3dff..e856cbe8f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - GitHub Copilot Guide -**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically. +> **Note:** The canonical instructions for all AI Agents have been deduplicated. -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. +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. diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md new file mode 100644 index 000000000..8e79d63d2 --- /dev/null +++ b/.github/copilot-pull-request-instructions.md @@ -0,0 +1,18 @@ +# 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 new file mode 100644 index 000000000..6179bc61a --- /dev/null +++ b/.github/instructions/android-source-set.instructions.md @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 000000000..d61fa34b8 --- /dev/null +++ b/.github/instructions/build-logic.instructions.md @@ -0,0 +1,10 @@ +--- +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 new file mode 100644 index 000000000..55a72b328 --- /dev/null +++ b/.github/instructions/ci-workflows.instructions.md @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 000000000..7dac915bc --- /dev/null +++ b/.github/instructions/kmp-common.instructions.md @@ -0,0 +1,20 @@ +--- +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/labeler.yml b/.github/labeler.yml deleted file mode 100644 index c3c2fa6cf..000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Auto Labeler rulse using https://github.com/actions/labeler -# - -# 'fix' in title/branch -> bug -# 'feat' in title/branch -> enhancement -# 'repo' in title/branch OR changes to ~/.github/ -> repo -# 'bug_fallthrough' for everything else except auto -# -# - [ ] need to look at title. waiting on https://github.com/actions/labeler/pull/866 - -# Add 'enhancement' label to any PR where the head branch name contains `feat` -enhancement: - - head-branch: [feat, Feat, FEAT] - - # Add 'repo' label to any PR where the head branch name contains `repo` - # or files in the .github dir -repo: -- any: - - head-branch: [repo, Repo, REPO, ci, CI] - - changed-files: - - any-glob-to-any-file: .github - - # Add 'bug' label to any PR where the head branch name contains `fix` or `bug` as the prefix. -bugfix: - - head-branch: [^fix, ^bug, ^Fix, ^FIX, ^Bug, ^BUG] - -# Add `refactor` label to any PR where the head branch name contains `refactor` or `Refactor` as the prefix. -refactor: - - head-branch: [^refactor, ^Refactor] - -# our fallback - bug except repo, feat, or automated pipelines -# bug_fallthrough: -# - all: -# - head-branch: ['^((?!feat).)*$', '^((?!repo).)*$', '^((?!renovate).)*$', '^((?!scheduled).)*$'] - diff --git a/.github/lsp.json b/.github/lsp.json new file mode 100644 index 000000000..983ecf785 --- /dev/null +++ b/.github/lsp.json @@ -0,0 +1,12 @@ +{ + "lspServers": { + "kotlin": { + "command": "kotlin-language-server", + "args": [], + "fileExtensions": { + ".kt": "kotlin", + ".kts": "kotlin" + } + } + } +} diff --git a/.github/renovate.json b/.github/renovate.json index c9993abac..1faa1a4ad 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -49,236 +49,31 @@ "automerge": true }, { + "description": "Meshtastic Protobufs changelog link", "matchPackageNames": [ "https://github.com/meshtastic/protobufs.git" ], "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", - "groupName": "Meshtastic Protobufs", - "groupSlug": "meshtastic-protobufs", "automerge": true }, { - "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)", - "groupName": "AndroidX (General)", - "groupSlug": "androidx-general", + "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", + "groupName": "compose-multiplatform", "matchPackageNames": [ - "/^androidx\\./", - "!/^androidx\\.room/", - "!/^androidx\\.lifecycle/", - "!/^androidx\\.navigation/", - "!/^androidx\\.datastore/", - "!/^androidx\\.compose\\.material3\\.adaptive/", - "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/", - "!/^androidx\\.test\\.espresso/", - "!/^androidx\\.test\\.ext/", - "!/^androidx\\.compose\\.ui:ui-test-junit4$/", - "!/^androidx\\.hilt/" + "/^org\\.jetbrains\\.compose/", + "androidx.compose.runtime:runtime-tracing", + "androidx.compose.ui:ui-test-manifest" ] }, { - "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)", + "description": "Restrict sensitive infrastructure to manual minor updates", "matchUpdateTypes": [ "minor" ], "matchPackageNames": [ "/^org\\.jetbrains\\.kotlin/", "/^org\\.jetbrains\\.kotlinx/", + "/^org\\.jetbrains\\.compose/", "/^com\\.google\\.dagger/", "/^androidx\\.hilt/", "/^com\\.google\\.protobuf/", @@ -298,4 +93,4 @@ "automerge": false } ] -} \ No newline at end of file +} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index e67a217c7..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,108 +0,0 @@ -# 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 568da41f4..f7c8151c7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,16 @@ 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: @@ -29,11 +39,11 @@ permissions: pages: write id-token: write -# 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. +# Allow only one concurrent deployment; cancel queued runs since only the latest +# main state matters for documentation. concurrency: group: "pages" - cancel-in-progress: false + cancel-in-progress: true jobs: build-docs: @@ -56,7 +66,7 @@ jobs: run: ./gradlew dokkaGeneratePublicationHtml - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: build/dokka/html diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml index 4c29847a3..eaf3f54d3 100644 --- a/.github/workflows/main-check.yml +++ b/.github/workflows/main-check.yml @@ -20,8 +20,7 @@ jobs: uses: ./.github/workflows/reusable-check.yml with: run_lint: true - run_unit_tests: true - run_instrumented_tests: true - api_levels: '[35]' # One API level is enough for post-merge sanity check + run_unit_tests: false + run_desktop_builds: false upload_artifacts: true secrets: inherit diff --git a/.github/workflows/main-push-changelog.yml b/.github/workflows/main-push-changelog.yml index 09446c50b..da161e44e 100644 --- a/.github/workflows/main-push-changelog.yml +++ b/.github/workflows/main-push-changelog.yml @@ -39,6 +39,10 @@ jobs: fromTag: ${{ steps.last_prod_tag.outputs.tag }} toTag: ${{ github.sha }} outputFile: main-push-changelog.md + fetchViaCommits: true + fetchReviewers: false + fetchReleaseInformation: false + fetchReviews: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 2818ca939..44d31183d 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -18,8 +18,6 @@ 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_issue_triage.yml b/.github/workflows/models_issue_triage.yml index 87756b616..a02fb8ed8 100644 --- a/.github/workflows/models_issue_triage.yml +++ b/.github/workflows/models_issue_triage.yml @@ -14,7 +14,7 @@ concurrency: jobs: triage: - if: ${{ github.repository == 'meshtastic/firmware' && github.event.issue.user.type != 'Bot' }} + if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }} runs-on: ubuntu-24.04-arm steps: # ───────────────────────────────────────────────────────────────────────── @@ -38,7 +38,7 @@ jobs: - name: Apply quality label if needed if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: QUALITY_LABEL: ${{ steps.quality.outputs.response }} with: @@ -80,7 +80,7 @@ jobs: # ───────────────────────────────────────────────────────────────────────── - name: Determine if completeness check should be skipped if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '' - uses: actions/github-script@v8 + uses: actions/github-script@v9 id: check-skip with: script: | @@ -98,20 +98,20 @@ jobs: continue-on-error: true with: prompt: | - Analyze this GitHub issue for completeness and determine if it needs labels. + Analyze this GitHub issue for the Meshtastic Android app and determine if it needs labels. - If this looks like a bug on the device/firmware (crash, reboot, lockup, radio issues, GPS issues, display issues, power/sleep issues), request device logs and explain how to get them: + If this looks like a bug in the Android app (crash, ANR, UI glitch, connection failure, Bluetooth issues, notification problems, map issues), request app logs and explain how to get them: - Web Flasher logs: - - Go to https://flasher.meshtastic.org - - Connect the device via USB and click Connect - - Open the device console/log output, reproduce the problem, then copy/download and attach/paste the logs + Android app debug logs: + - Open the Meshtastic app, go to Settings > Debug > Save Logs + - Reproduce the problem, then share/attach the exported log file - Meshtastic CLI logs: - - Run: meshtastic --port --noproto - - Reproduce the problem, then copy/paste the terminal output + Android logcat (if app logs are insufficient): + - Connect phone via USB with USB debugging enabled + - Run: adb logcat -s Meshtastic:* *:E + - Reproduce the problem, then copy/paste the relevant output - Also request key context if missing: device model/variant, firmware version, region, steps to reproduce, expected vs actual. + Also request key context if missing: Android version, phone model, app version, Meshtastic device model, firmware version, connection type (BLE/USB/TCP), steps to reproduce, expected vs actual. Respond ONLY with JSON: { @@ -120,7 +120,7 @@ jobs: "label": "needs-logs" | "needs-info" | "none" } - Use "needs-logs" if this is a device bug AND no logs are attached. + Use "needs-logs" if this is an app bug AND no logs are attached. Use "needs-info" if basic info like firmware version or steps to reproduce are missing. Use "none" if the issue is complete or is a feature request. @@ -131,7 +131,7 @@ jobs: - name: Process analysis result if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != '' - uses: actions/github-script@v8 + uses: actions/github-script@v9 id: process env: AI_RESPONSE: ${{ steps.analysis.outputs.response }} @@ -165,7 +165,7 @@ jobs: - name: Apply triage label if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none' - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: LABEL_NAME: ${{ steps.process.outputs.label }} with: @@ -191,7 +191,7 @@ jobs: - name: Comment on issue if: steps.process.outputs.should_comment == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: COMMENT_BODY: ${{ steps.process.outputs.comment_body }} with: diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml index af1b04037..c2a1aaf25 100644 --- a/.github/workflows/models_pr_triage.yml +++ b/.github/workflows/models_pr_triage.yml @@ -15,19 +15,19 @@ concurrency: jobs: triage: - if: ${{ github.repository == 'meshtastic/firmware' && github.event.pull_request.user.type != 'Bot' }} + if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }} runs-on: ubuntu-24.04-arm steps: # ───────────────────────────────────────────────────────────────────────── # Step 1: Check if PR already has automation/type labels (skip if so) # ───────────────────────────────────────────────────────────────────────── - name: Check existing labels - uses: actions/github-script@v8 + uses: actions/github-script@v9 id: check-labels with: script: | - const skipLabels = new Set(['automation']); - const typeLabels = new Set(['bugfix', 'hardware-support', 'enhancement', 'dependencies', 'submodules', 'github_actions', 'trunk', 'cleanup']); + const skipLabels = new Set(['automation', 'release']); + const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']); const prLabels = context.payload.pull_request.labels.map(l => l.name); const shouldSkipAll = prLabels.some(l => skipLabels.has(l)); @@ -44,13 +44,16 @@ jobs: uses: actions/ai-inference@v2 id: quality continue-on-error: true + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} with: max-tokens: 20 prompt: | Is this GitHub pull request spam, AI-generated slop, or low quality? - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} Respond with exactly one of: spam, ai-generated, needs-review, ok system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop. @@ -58,7 +61,7 @@ jobs: - name: Apply quality label if needed if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' - uses: actions/github-script@v8 + uses: actions/github-script@v9 id: quality-label env: QUALITY_LABEL: ${{ steps.quality.outputs.response }} @@ -87,32 +90,35 @@ jobs: core.setOutput('is_spam', 'true'); # ───────────────────────────────────────────────────────────────────────── - # Step 3: Auto-label PR type (bugfix/hardware-support/enhancement) + # Step 3: Auto-label PR type (bugfix/enhancement/refactor) # ───────────────────────────────────────────────────────────────────────── - name: Classify PR for labeling if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') 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: | - Classify this pull request into exactly one category. + Classify this pull request for the Meshtastic Android app into exactly one category. - Return exactly one of: bugfix, hardware-support, enhancement + Return exactly one of: bugfix, enhancement, refactor Use bugfix if it fixes a bug, crash, or incorrect behavior. - Use hardware-support if it adds or improves support for a specific hardware device/variant. - Use enhancement if it adds a new feature, improves performance, or refactors code. + Use enhancement if it adds a new feature, improves performance, or adds new functionality. + Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture. - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. model: openai/gpt-4o-mini - name: Apply type label if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != '' - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: TYPE_LABEL: ${{ steps.classify.outputs.response }} with: @@ -120,8 +126,8 @@ jobs: const label = (process.env.TYPE_LABEL || '').trim().toLowerCase(); const labelMeta = { 'bugfix': { color: 'd73a4a', description: 'Bug fix' }, - 'hardware-support': { color: '0e8a16', description: 'Hardware support addition or improvement' }, 'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' }, + 'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' }, }; const meta = labelMeta[label]; if (!meta) return; diff --git a/.github/workflows/moderate.yml b/.github/workflows/moderate.yml index 81eff6b59..4b8f94bfa 100644 --- a/.github/workflows/moderate.yml +++ b/.github/workflows/moderate.yml @@ -9,6 +9,7 @@ on: jobs: spam-detection: + if: github.repository == 'meshtastic/Meshtastic-Android' runs-on: ubuntu-24.04-arm permissions: issues: write diff --git a/.github/workflows/pr_enforce_labels.yml b/.github/workflows/pr_enforce_labels.yml index 05603796f..fa68a597b 100644 --- a/.github/workflows/pr_enforce_labels.yml +++ b/.github/workflows/pr_enforce_labels.yml @@ -4,29 +4,34 @@ on: pull_request: types: [edited, labeled] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: pull-requests: read contents: read jobs: - check-label: + check-label: + # Skip bot PRs — they already have labels from the workflows/bots that create them + if: >- + github.event.pull_request.user.login != 'renovate[bot]' && + github.event.pull_request.user.login != 'github-actions[bot]' && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.event.pull_request.head.ref != 'scheduled-updates' && + github.event.pull_request.head.ref != 'l10n_main' runs-on: ubuntu-24.04-arm steps: - name: Check for PR labels - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | - // Always fetch the latest labels from the GitHub API to avoid stale context - const prNumber = context.payload.pull_request.number; - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - const latestLabels = pr.labels.map(label => label.name); + // Extract labels from the payload directly to avoid extra API calls + const latestLabels = context.payload.pull_request.labels.map(label => label.name); const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor']; + console.log('Labels from payload:', latestLabels); const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label)); - console.log('Latest labels:', latestLabels); if (!hasRequiredLabel) { core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`); } diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 2338a6aeb..df16866f3 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -139,6 +139,7 @@ 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-target.yml b/.github/workflows/pull-request-target.yml index 7dfe1674b..d37cecf43 100644 --- a/.github/workflows/pull-request-target.yml +++ b/.github/workflows/pull-request-target.yml @@ -1,9 +1,14 @@ name: "Pull Request Labeler" on: -- pull_request_target -# Do not execute arbitary code on this workflow. + pull_request_target: + types: [opened, synchronize] +# Do not execute arbitrary code on this workflow. # See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: labeler: permissions: @@ -11,5 +16,52 @@ jobs: pull-requests: write runs-on: ubuntu-24.04-arm steps: - - id: label-the-PR - uses: actions/labeler@v6 \ No newline at end of file + - name: Auto-label PR + uses: actions/github-script@v9 + with: + script: | + const branch = context.payload.pull_request.head.ref; + const labels = new Set(); + + // enhancement: branch contains feat + if (/feat/i.test(branch)) labels.add('enhancement'); + + // bugfix: branch starts with fix or bug + if (/^(fix|bug)/i.test(branch)) labels.add('bugfix'); + + // refactor: branch starts with refactor + if (/^refactor/i.test(branch)) labels.add('refactor'); + + // repo: branch contains repo or ci + if (/repo|ci/i.test(branch)) { + labels.add('repo'); + } else { + // Also label 'repo' if .github files were changed (needs one API call) + try { + const files = await github.paginate( + github.rest.pulls.listFiles, + { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 }, + (res) => res.data.map(f => f.filename) + ); + if (files.some(f => f.startsWith('.github/'))) labels.add('repo'); + } catch (e) { + core.warning(`Could not list PR files (rate limited?): ${e.message}`); + } + } + + if (labels.size > 0) { + const labelArray = [...labels]; + core.info(`Applying labels: ${labelArray.join(', ')}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: labelArray, + }); + } catch (e) { + core.warning(`Could not apply labels (rate limited?): ${e.message}`); + } + } else { + core.info('No labels matched for this PR.'); + } diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6649dbc84..d450711ce 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -3,10 +3,6 @@ name: Pull Request CI on: pull_request: branches: [ main ] - paths-ignore: - - '**/*.md' - - 'docs/**' - - '.gitignore' permissions: contents: read @@ -39,7 +35,6 @@ jobs: - 'desktop/**' - 'core/**' - 'feature/**' - - 'mesh_service_example/**' # Shared build infrastructure - 'build-logic/**' - 'config/**' @@ -99,7 +94,9 @@ jobs: PY # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable instrumented tests and coverage for PRs to keep feedback fast (< 10 mins). + # 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. validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' @@ -107,9 +104,8 @@ jobs: with: run_lint: true run_unit_tests: true - run_instrumented_tests: false run_coverage: false - api_levels: '[35]' + run_desktop_builds: false upload_artifacts: true secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 905fe78c1..40d8e40f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,7 +285,7 @@ jobs: env: ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} APPIMAGE_EXTRACT_AND_RUN: 1 - run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS --no-daemon + run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon - name: List Desktop Binaries if: runner.os == 'Linux' @@ -328,7 +328,7 @@ jobs: path: ./artifacts - name: Create or Update GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 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@v2 + uses: softprops/action-gh-release@v3 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 ce24c1b66..632bf1ea4 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -9,15 +9,12 @@ on: run_unit_tests: type: boolean default: true - run_instrumented_tests: - type: boolean - default: true run_coverage: type: boolean default: true - api_levels: - type: string - default: '[35]' + run_desktop_builds: + type: boolean + default: true upload_artifacts: type: boolean default: true @@ -97,7 +94,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 mesh_service_example:lintDebug kmpSmokeCompile -Pci=true --continue --scan + run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan - name: KMP Smoke Compile (lint skipped) if: inputs.run_lint == false @@ -176,14 +173,12 @@ 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 @@ -218,7 +213,7 @@ jobs: files: "**/build/test-results/**/*.xml" - name: Upload coverage to Codecov - if: ${{ !cancelled() }} + if: ${{ !cancelled() && inputs.run_coverage }} uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -237,7 +232,7 @@ jobs: **/build/test-results retention-days: 7 - # ── Android Build & Instrumented Tests ────────────────────────────── + # ── Android Build ──────────────────────────────────────────────────── android-check: runs-on: ubuntu-24.04 permissions: @@ -246,10 +241,6 @@ jobs: 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 @@ -264,110 +255,38 @@ jobs: 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" - "core:barcode:connectedFdroidDebugAndroidTest" - "core:barcode: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: Build Android APKs + run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan - name: Upload debug artifact - if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }} + if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: name: app-debug-apks path: app/build/outputs/apk/*/debug/*.apk - retention-days: 14 + retention-days: 7 - name: Report App Size - if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }} + 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 - - 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 + 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 }} @@ -385,12 +304,12 @@ jobs: cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - name: Build Desktop - run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan + run: ./gradlew :desktop:createDistributable -Pci=true --scan - name: Upload Desktop artifact if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: - name: desktop-app - path: desktop/build/compose/binaries/main/app/Meshtastic/bin/* + name: desktop-app-${{ runner.os }}-${{ runner.arch }} + path: desktop/build/compose/binaries/main/app/ retention-days: 7 diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index d516537e0..2399d1f88 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 * * * *' # Run every hour - workflow_dispatch: # Allow manual triggering + - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) + workflow_dispatch: # Allow manual triggering jobs: update_assets: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1b9ee1fd6..f1ae45660 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -20,7 +20,7 @@ jobs: uses: actions/stale@v10.2.0 with: days-before-stale: 30 - stale-issue-message: This issue hasn not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days. + stale-issue-message: This issue has not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days. exempt-issue-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies' exempt-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies' operations-per-run: 100 diff --git a/.gitignore b/.gitignore index 97dbb7b24..447d8a28e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ 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 new file mode 100644 index 000000000..d0a809449 --- /dev/null +++ b/.pr5167.diff @@ -0,0 +1,295 @@ +diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..2a27b96906 +--- /dev/null ++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +@@ -0,0 +1,39 @@ ++/* ++ * Copyright (c) 2026 Meshtastic LLC ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package org.meshtastic.core.common.di ++ ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.koin.core.annotation.Single ++import org.meshtastic.core.common.util.ioDispatcher ++ ++/** ++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. ++ * ++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled ++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not ++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. ++ * ++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch ++ * and should be used sparingly. ++ */ ++interface ApplicationCoroutineScope : CoroutineScope ++ ++@Single(binds = [ApplicationCoroutineScope::class]) ++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { ++ override val coroutineContext = SupervisorJob() + ioDispatcher ++} +diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 231c84d401..5365ab95e2 100644 +--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect + import co.touchlab.kermit.Logger + import com.eygraber.uri.toAndroidUri + import com.eygraber.uri.toKmpUri +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.jetbrains.compose.resources.getString + import org.meshtastic.core.common.gpsDisabled + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.net.URLEncoder + + @Composable +@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> + val context = LocalContext.current + return remember(context) { + { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val androidUri = uri.toAndroidUri() +diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 031e1fe35d..a938f92ea6 100644 +--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util + + import androidx.compose.runtime.Composable + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.Desktop + import java.awt.FileDialog + import java.awt.Frame +@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT + /** JVM — Reads text from a file URI. */ + @Composable + actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val file = File(URI(uri.toString())) +diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +index dc1c459716..f8ff9fcac8 100644 +--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt ++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch + import kotlinx.coroutines.withTimeoutOrNull + import org.jetbrains.compose.resources.StringResource + import org.koin.core.annotation.KoinViewModel ++import org.meshtastic.core.common.di.ApplicationCoroutineScope + import org.meshtastic.core.common.util.CommonUri + import org.meshtastic.core.common.util.safeCatching + import org.meshtastic.core.database.entity.FirmwareRelease +@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( + private val firmwareUpdateManager: FirmwareUpdateManager, + private val usbManager: FirmwareUsbManager, + private val fileHandler: FirmwareFileHandler, ++ private val applicationScope: ApplicationCoroutineScope, + ) : ViewModel() { + + private val _state = MutableStateFlow(FirmwareUpdateState.Idle) +@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( + + override fun onCleared() { + super.onCleared() +- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a +- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a +- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope +- // is cancelled concurrently. +- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) +- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { ++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the ++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup ++ // running even if something tries to cancel it mid-flight. ++ applicationScope.launch(NonCancellable) { + tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + } + } +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +index 4c48a1ced5..030d84effd 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +index 7032ed4088..a8eddff838 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..3ef5c44ef4 +--- /dev/null ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +@@ -0,0 +1,26 @@ ++/* ++ * Copyright (c) 2026 Meshtastic LLC ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package org.meshtastic.feature.firmware ++ ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.meshtastic.core.common.di.ApplicationCoroutineScope ++ ++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : ++ ApplicationCoroutineScope, ++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) +diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +index acb1545bdd..23a0d03ab2 100644 +--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt ++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + // ----------------------------------------------------------------------- +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index c251b4d5ef..315ad1da85 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import org.meshtastic.core.resources.Res + import org.meshtastic.core.resources.debug_export_failed + import org.meshtastic.core.resources.debug_export_success +@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + try { + if (logs.isEmpty()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +index 9afde85e5f..a28a576788 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import androidx.compose.ui.platform.LocalContext + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + + @Composable + actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { +@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr + return { fileName -> exportLauncher.launch(fileName) } + } + +-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { ++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { + try { + context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } + Logger.i { "TAK data package exported successfully to $targetUri" } +diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index 5b63cc90a3..a9a7285593 100644 +--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging + import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.FileDialog + import java.awt.Frame + import java.io.File +@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr + if (directory != null && file != null) { + val targetFile = File(directory, file) + val data = dataPackageProvider() +- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } ++ withContext(ioDispatcher) { targetFile.writeBytes(data) } + Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } + } + } diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md new file mode 100644 index 000000000..acab253d5 --- /dev/null +++ b/.skills/code-review/SKILL.md @@ -0,0 +1,66 @@ +# 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 new file mode 100644 index 000000000..22fe1b489 --- /dev/null +++ b/.skills/compose-ui/SKILL.md @@ -0,0 +1,61 @@ +# 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 new file mode 100644 index 000000000..0277bee10 --- /dev/null +++ b/.skills/implement-feature/SKILL.md @@ -0,0 +1,41 @@ +# 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 new file mode 100644 index 000000000..46602c430 --- /dev/null +++ b/.skills/kmp-architecture/SKILL.md @@ -0,0 +1,61 @@ +# 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 new file mode 100644 index 000000000..c9d7336a6 --- /dev/null +++ b/.skills/navigation-and-di/SKILL.md @@ -0,0 +1,56 @@ +# 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 new file mode 100644 index 000000000..d63f3f4c2 --- /dev/null +++ b/.skills/new-branch/SKILL.md @@ -0,0 +1,79 @@ +# Skill: New Branch Bootstrap + +## Description +Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill +whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh +branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work. + +This replaces the ad-hoc prose that used to be retyped at the start of every session. + +## When to Use +- Starting any new feature, fix, chore, or refactor. +- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)). +- Reproducing a CI failure from a clean baseline. + +## Preconditions (verify before branching) +1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding. +2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at + `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream. +3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md + workspace bootstrap rules. +4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties` + (required for `google` flavor builds). + +## Standard Recipe + +```bash +# 1. Fetch latest upstream +git fetch upstream --prune --tags + +# 2. Create the branch from upstream/main (never from a local stale main) +git switch -c upstream/main + +# 3. Ensure submodules track the new base +git submodule update --init --recursive + +# 4. Sanity check +git --no-pager log -1 --oneline +``` + +## Branch Naming +Use conventional-commit style prefixes that match the PR title convention in AGENTS.md +``: + +| Prefix | Use for | +| :--- | :--- | +| `feat/` | New user-visible behavior | +| `fix/` | Bug fixes | +| `refactor/` | Code structure changes, no behavior change | +| `chore/` | Tooling, deps, CI, cleanup | +| `docs/` | Documentation only | + +Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`. + +## Rebase Variant +When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*: + +```bash +git fetch upstream --prune +gh pr checkout # checks out the PR head locally +git rebase upstream/main +git submodule update --init --recursive +# Resolve conflicts, then: +git push --force-with-lease +``` + +Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes. + +## Post-Branch Checklist +- [ ] Branch name follows conventional prefix. +- [ ] Submodules up to date. +- [ ] `local.properties` exists. +- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap). +- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing. + +## Tip: Prefer `/delegate` for Long Audits +If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since +v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider +suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR +end-to-end while the user keeps working locally. See AGENTS.md ``. diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md new file mode 100644 index 000000000..2224fa7ad --- /dev/null +++ b/.skills/project-overview/SKILL.md @@ -0,0 +1,83 @@ +# 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 new file mode 100644 index 000000000..1c8b7b901 --- /dev/null +++ b/.skills/testing-ci/SKILL.md @@ -0,0 +1,85 @@ +# 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 40adbfd06..c1bafdd96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,206 +1,108 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - Unified Agent & Developer Guide -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. + +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. + -For execution-focused recipes, see `docs/agent-playbooks/README.md`. + +- **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. + -## 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. + +- **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). + -- **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. + +- **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. + -## 2. Codebase Map + +`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. -| 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, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for 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 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. | +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. + -## 3. Development Guidelines & Coding Standards + +- **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. + -### 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`. + +These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this +section. -### 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. -- **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. +- **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. + -### 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. -- **`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 + +- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. +- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. +- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..eb5cd5e5c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# 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 9076b718e..72a350afb 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - Google Gemini Guide -**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically. +> **Note:** The canonical instructions for all AI Agents have been deduplicated. -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. +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. diff --git a/Gemfile.lock b/Gemfile.lock index de497cc4a..cf6a1b9c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1213.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1240.0) + aws-sdk-core (3.245.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.213.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -29,7 +29,7 @@ GEM babosa (1.0.4) base64 (0.2.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -68,11 +68,11 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.232.2) + fastimage (2.4.1) + fastlane (2.233.0) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) @@ -92,7 +92,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -122,10 +122,9 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.95.0) + google-apis-androidpublisher_v3 (0.99.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -139,15 +138,15 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.59.0) + google-apis-storage_v1 (0.61.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.1.1) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.5.0) - google-cloud-storage (1.58.0) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -169,13 +168,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.1) + json (2.19.4) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.20.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -185,13 +184,13 @@ GEM os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.2) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.4.1) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -205,7 +204,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/SOUL.md b/SOUL.md deleted file mode 100644 index 793387334..000000000 --- a/SOUL.md +++ /dev/null @@ -1,31 +0,0 @@ -# Meshtastic-Android: AI Agent Soul (SOUL.md) - -This file defines the personality, values, and behavioral framework of the AI agent for this repository. - -## 1. Core Identity -I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack. - -## 2. Core Truths & Values -- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets. -- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible. -- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic. -- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility. - -## 3. Communication Style (The "Vibe") -- **Direct & Concise:** I skip the fluff. I provide technical rationale first. -- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions. -- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it. - -## 4. Operational Boundaries -- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules. -- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic. -- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity. -- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system. - -## 5. Evolution -I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers. - -For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth. -For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`. - - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 144700a32..d239d0530 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,8 +171,6 @@ configure { } else { signingConfig = signingConfigs.getByName("debug") } - isMinifyEnabled = true - isShrinkResources = true isDebuggable = false } } @@ -243,10 +241,10 @@ dependencies { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.material) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.iconsExtended) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.ui.text) + 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.glance.appwidget) implementation(libs.androidx.glance.appwidget.preview) implementation(libs.androidx.glance.material3) @@ -254,7 +252,6 @@ dependencies { implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.androidx.paging.compose) implementation(libs.ktor.client.android) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) @@ -267,7 +264,6 @@ dependencies { implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.koin.android) - implementation(libs.koin.androidx.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androidx.workmanager) implementation(libs.koin.annotations) @@ -283,7 +279,6 @@ dependencies { googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose.utils) googleImplementation(libs.maps.compose.widgets) - googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) googleImplementation(libs.dd.sdk.android.session.replay) @@ -299,12 +294,6 @@ 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) @@ -312,22 +301,22 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) - testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.compose.multiplatform.ui.test) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.glance.appwidget) } aboutLibraries { - // Fetch full license text + funding info from GitHub API when on CI with a token - val isCi = - providers - .gradleProperty("ci") - .map { it.toBoolean() } - .getOrElse(providers.environmentVariable("CI").map { it.toBoolean() }.getOrElse(false)) + // Run offline by default to avoid burning GitHub API calls on every build. + // Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info. + val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false) val ghToken = providers.environmentVariable("GITHUB_TOKEN") + + offlineMode = !isReleaseBuild + collect { - fetchRemoteLicense = isCi && ghToken.isPresent - fetchRemoteFunding = isCi && ghToken.isPresent + fetchRemoteLicense = isReleaseBuild && ghToken.isPresent + fetchRemoteFunding = isReleaseBuild && ghToken.isPresent if (ghToken.isPresent) { gitHubApiToken = ghToken.get() } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4d6c3924e..de2b3144c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,49 +1,45 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. +# ============================================================================ +# Meshtastic Android — ProGuard / R8 rules for release minification +# ============================================================================ +# Open-source project: obfuscation and optimization are disabled. We rely on +# tree-shaking (unused code removal) for APK size reduction. # -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by the +# AndroidApplicationConventionPlugin. This file holds only Android-specific +# rules and R8-only directives. +# ============================================================================ -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# ---- General ---------------------------------------------------------------- -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable +# Open-source — no need to obfuscate +-dontobfuscate -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# Disable R8 optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the +# target classes are preserved by -keep rules. The result is that the Compose +# recomposer/frame-clock/animation state machines silently freeze on their +# first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. See #5146. +-dontoptimize -# Room KMP: preserve generated database constructor (required for R8/ProGuard) --keep class * extends androidx.room.RoomDatabase { (); } +# Dump the full merged R8 configuration (app rules + all library consumer rules) +# for auditing. Inspect this file after a release build to see what libraries inject. +-printconfiguration build/outputs/mapping/r8-merged-config.txt -# Needed for protobufs --keep class com.google.protobuf.** { *; } --keep class org.meshtastic.proto.** { *; } +# ---- Networking (transitive references from Ktor on Android) ---------------- -# Networking -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# ? --dontwarn java.lang.reflect.** --dontwarn com.google.errorprone.annotations.** - -# Our app is opensource no need to obsfucate --dontobfuscate --optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable - -# R8 optimization for Kotlin null checks (AGP 9.0+) --processkotlinnullchecks remove - -# Nordic BLE --dontwarn no.nordicsemi.kotlin.ble.environment.android.mock.** --keep class no.nordicsemi.kotlin.ble.environment.android.mock.** { *; } --keep class no.nordicsemi.kotlin.ble.environment.android.compose.** { *; } +# Compose runtime/ui/animation/foundation/material3 keep rules now live in +# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard) +# get the same defence-in-depth coverage against CMP 1.11 optimizer folding. diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt deleted file mode 100644 index 4cbf88356..000000000 --- a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt +++ /dev/null @@ -1,48 +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.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/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt index a5069fb59..21c2d4fde 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt @@ -23,32 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.Single import org.meshtastic.core.ui.util.MapViewProvider +/** OSMDroid implementation of [MapViewProvider]. */ @Single class FdroidMapViewProvider : MapViewProvider { @Composable - override fun MapView( - modifier: Modifier, - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int?, - nodeTracks: List?, - tracerouteOverlay: Any?, - tracerouteNodePositions: Map, - onTracerouteMappableCountChanged: (Int, Int) -> Unit, - waypointId: Int?, - ) { + override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - @Suppress("UNCHECKED_CAST") org.meshtastic.app.map.MapView( modifier = modifier, mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails, - focusedNodeNum = focusedNodeNum, - nodeTracks = nodeTracks as? List, - tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(), - onTracerouteMappableCountChanged = onTracerouteMappableCountChanged, ) } } 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 a6c575af7..b4d0e1bbd 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -17,10 +17,8 @@ package org.meshtastic.app.map import android.Manifest -import android.graphics.Paint import androidx.appcompat.content.res.AppCompatResources 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.ColumnScope @@ -32,24 +30,17 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Lens -import androidx.compose.material.icons.rounded.LocationDisabled -import androidx.compose.material.icons.rounded.PinDrop -import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -58,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope 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.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -87,7 +77,6 @@ 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.model.CustomTileSource import org.meshtastic.app.map.model.MarkerWithLabel import org.meshtastic.core.common.gpsDisabled @@ -105,6 +94,7 @@ import org.meshtastic.core.resources.delete_for_everyone import org.meshtastic.core.resources.delete_for_me import org.meshtastic.core.resources.expires import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.last_heard_filter_label import org.meshtastic.core.resources.location_disabled import org.meshtastic.core.resources.map_cache_info import org.meshtastic.core.resources.map_cache_manager @@ -114,7 +104,6 @@ import org.meshtastic.core.resources.map_clear_tiles import org.meshtastic.core.resources.map_download_complete import org.meshtastic.core.resources.map_download_errors import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.resources.map_filter import org.meshtastic.core.resources.map_node_popup_details import org.meshtastic.core.resources.map_offline_manager import org.meshtastic.core.resources.map_purge_fail @@ -123,21 +112,25 @@ import org.meshtastic.core.resources.map_style_selection import org.meshtastic.core.resources.map_subDescription import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.position import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.toggle_my_position import org.meshtastic.core.resources.waypoint_delete import org.meshtastic.core.resources.you import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.core.ui.icon.Check +import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.Layers +import org.meshtastic.core.ui.icon.Lens +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PinDrop 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.model.TracerouteOverlay -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Position +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 import org.osmdroid.config.Configuration @@ -156,38 +149,23 @@ import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Polygon -import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.infowindow.InfoWindow import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import java.io.File -import kotlin.math.abs -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin +import kotlin.math.roundToInt private fun MapView.updateMarkers( nodeMarkers: List, waypointMarkers: List, - trackMarkers: List, - trackPolylines: List, nodeClusterer: RadiusMarkerClusterer, ) { - Logger.d { - "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks" - } - - val trackOverlayIds = (trackMarkers + trackPolylines).toSet() + Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } overlays.removeAll { overlay -> - overlay is MarkerWithLabel || - (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) || - (overlay is Polyline && overlay !in trackOverlayIds) + overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) } overlays.addAll(waypointMarkers) - overlays.addAll(trackPolylines) - overlays.addAll(trackMarkers) nodeClusterer.items.clear() nodeClusterer.items.addAll(nodeMarkers) @@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. */ @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod") @Composable fun MapView( modifier: Modifier = Modifier, mapViewModel: MapViewModel = koinViewModel(), navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - nodeTracks: List? = null, - tracerouteOverlay: TracerouteOverlay? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { var mapFilterExpanded by remember { mutableStateOf(false) } @@ -334,6 +307,16 @@ fun MapView( } } + // Keep screen on while location tracking is active + LaunchedEffect(myLocationOverlay) { + val activity = context as? android.app.Activity ?: return@LaunchedEffect + if (myLocationOverlay != null) { + activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() @@ -349,77 +332,21 @@ fun MapView( } } - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, nodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = nodes, - ) - } - val overlayNodeNums = tracerouteSelection.overlayNodeNums - val nodeLookup = tracerouteSelection.nodeLookup - val nodesForMarkers = tracerouteSelection.nodesForMarkers - val tracerouteForwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - val tracerouteReturnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - LaunchedEffect(tracerouteOverlay, nodesForMarkers) { - if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size) - } - } - val tracerouteHeadingReferencePoints = - remember(tracerouteForwardPoints, tracerouteReturnPoints) { - when { - tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints - tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints - else -> emptyList() - } - } - val tracerouteForwardOffsetPoints = - remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteForwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = 1.0, - ) - } - val tracerouteReturnOffsetPoints = - remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteReturnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = -1.0, - ) - } - val traceroutePolylines = remember { mutableStateListOf() } - var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = - mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC + val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> + if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { + return@mapNotNull null + } if ( - mapFilterStateValue.onlyFavorites && - !node.isFavorite && - !overlayNodeNums.contains(node.num) && - !node.equals(ourNode) + mapFilterStateValue.lastHeardFilter.seconds != 0L && + (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && + node.num != ourNode?.num ) { return@mapNotNull null } @@ -580,53 +507,6 @@ fun MapView( invalidate() } - fun MapView.updateTracerouteOverlay(forwardPoints: List, returnPoints: List) { - overlays.removeAll(traceroutePolylines) - traceroutePolylines.clear() - - fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { - setPoints(points) - outlinePaint.apply { - this.color = color - this.strokeWidth = strokeWidth - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - style = Paint.Style.STROKE - } - } - - forwardPoints - .takeIf { it.size >= 2 } - ?.let { points -> - traceroutePolylines.add( - buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }), - ) - } - returnPoints - .takeIf { it.size >= 2 } - ?.let { points -> - traceroutePolylines.add( - buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }), - ) - } - overlays.addAll(traceroutePolylines) - invalidate() - } - - LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - if (allPoints.size == 1) { - map.controller.setCenter(allPoints.first()) - map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) - } else { - map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true) - } - hasCenteredTraceroute = true - } - } - fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } val zoomFactor = 1.3 @@ -689,49 +569,6 @@ fun MapView( } } - fun MapView.onTracksChanged(nodeTracks: List?, focusedNodeNum: Int?): Pair, List> { - if (nodeTracks == null || focusedNodeNum == null) return emptyList() to emptyList() - - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - val timeFilteredPositions = nodeTracks.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - val sortedPositions = timeFilteredPositions.sortedBy { it.time } - - val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList() to emptyList() - val color = focusedNode.colors.second - - val trackPolylines = mutableListOf() - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) - val polyline = - Polyline().apply { - setPoints( - segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) }, - ) - outlinePaint.color = Color(color).copy(alpha = alpha).toArgb() - outlinePaint.strokeWidth = 8f - } - trackPolylines.add(polyline) - } - } - - val trackMarkers = sortedPositions.mapIndexedNotNull { index, position -> - if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null - - Marker(this).apply { - this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7) - icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - title = getString(Res.string.position) - snippet = formatAgo(position.time) - } - } - return trackMarkers to trackPolylines - } - Scaffold( modifier = modifier, floatingActionButton = { @@ -748,14 +585,10 @@ fun MapView( }, modifier = Modifier.fillMaxSize(), update = { mapView -> - mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints) - val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum) with(mapView) { updateMarkers( - onNodesChanged(nodesForMarkers), + onNodesChanged(nodes), onWaypointChanged(waypoints.values, selectedWaypointId), - trackMarkers, - trackPolylines, nodeClusterer, ) } @@ -774,122 +607,34 @@ fun MapView( modifier = Modifier.align(Alignment.BottomCenter), ) } else { - @Suppress("MagicNumber") - Column( - modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - MapButton( - onClick = { showMapStyleDialog = true }, - icon = Icons.Outlined.Layers, - contentDescription = Res.string.map_style_selection, - ) - Box(modifier = Modifier) { - MapButton( - onClick = { mapFilterExpanded = true }, - icon = Icons.Outlined.Tune, - contentDescription = stringResource(Res.string.map_filter), - ) - DropdownMenu( + MapControlsOverlay( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), + onToggleFilterMenu = { mapFilterExpanded = true }, + filterDropdownContent = { + FdroidMainMapFilterDropdown( expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.Star, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.only_favorites), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_waypoints), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_precision_circle), - modifier = Modifier.weight(1f), - ) - @Suppress("MagicNumber") - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - } - } - MapButton( - icon = - if (myLocationOverlay == null) { - Icons.Outlined.MyLocation - } else { - Icons.Rounded.LocationDisabled - }, - contentDescription = stringResource(Res.string.toggle_my_position), - ) { + mapFilterState = mapFilterState, + mapViewModel = mapViewModel, + ) + }, + mapTypeContent = { + MapButton( + icon = MeshtasticIcons.Layers, + contentDescription = stringResource(Res.string.map_style_selection), + onClick = { showMapStyleDialog = true }, + ) + }, + isLocationTrackingEnabled = myLocationOverlay != null, + onToggleLocationTracking = { if (locationPermissionsState.allPermissionsGranted) { map.toggleMyLocation() } else { triggerLocationToggleAfterPermission = true locationPermissionsState.launchMultiplePermissionRequest() } - } - } + }, + ) } } } @@ -968,6 +713,103 @@ fun MapView( } } +/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */ +@Composable +private fun FdroidMainMapFilterDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + mapFilterState: MapFilterState, + mapViewModel: MapViewModel, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.Favorite, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleOnlyFavorites() }, + ) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.PinDrop, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.showWaypoints, + onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowWaypointsOnMap() }, + ) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.Lens, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + HorizontalDivider() + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val filterOptions = LastHeardFilter.entries + val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + Text( + text = + stringResource( + Res.string.last_heard_filter_label, + stringResource(mapFilterState.lastHeardFilter.label), + ), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) + mapViewModel.setLastHeardFilter(filterOptions[newIndex]) + }, + valueRange = 0f..(filterOptions.size - 1).toFloat(), + steps = filterOptions.size - 2, + ) + } + } +} + @Composable private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { val selected = remember { mutableStateOf(selectedMapStyle) } @@ -976,7 +818,7 @@ private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelec CustomTileSource.mTileSources.values.forEachIndexed { index, style -> ListItem( text = style, - trailingIcon = if (index == selected.value) Icons.Rounded.Check else null, + trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, onClick = { selected.value = index onSelectMapStyle(index) @@ -1019,15 +861,9 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { onDismiss = onDismiss, negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) + val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() + val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() + Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) } } @@ -1123,56 +959,4 @@ private fun MapsDialog( } } -private const val EARTH_RADIUS_METERS = 6_371_000.0 -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 -private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 private const val WAYPOINT_ZOOM = 15.0 - -@Suppress("MagicNumber") -private fun Double.toRad(): Double = this * Math.PI / 180.0 - -private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { - val lat1 = from.latitude.toRad() - val lat2 = to.latitude.toRad() - val dLon = (to.longitude - from.longitude).toRad() - return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) -} - -private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { - val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS - val lat1 = latitude.toRad() - val lon1 = longitude.toRad() - val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) - val lon2 = - lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) - return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> bearingRad(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) - - else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - - @Suppress("MagicNumber") - val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier) - point.offsetPoint(perpendicularHeading, abs(offsetMeters)) - } -} 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 04f896d18..3cc0dbaf0 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt @@ -124,20 +124,21 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () return polyline } -fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { +fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - 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 + 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 + } } } - } overlays.addAll(markers) return markers diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt index d6e84d19b..c16d87163 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.app.map -import android.annotation.SuppressLint -import android.content.Context -import android.os.PowerManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import co.touchlab.kermit.Logger import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.ITileSource import org.osmdroid.tileprovider.tilesource.TileSourceFactory @@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView -@SuppressLint("WakelockTimeout") -private fun PowerManager.WakeLock.safeAcquire() { - if (!isHeld) { - try { - acquire() - } catch (e: SecurityException) { - Logger.e { "WakeLock permission exception: ${e.message}" } - } catch (e: IllegalStateException) { - Logger.e { "WakeLock acquire() exception: ${e.message}" } - } - } -} - -private fun PowerManager.WakeLock.safeRelease() { - if (isHeld) { - try { - release() - } catch (e: IllegalStateException) { - Logger.e { "WakeLock release() exception: ${e.message}" } - } - } -} - private const val MIN_ZOOM_LEVEL = 1.5 private const val MAX_ZOOM_LEVEL = 20.0 private const val DEFAULT_ZOOM_LEVEL = 15.0 @@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle( } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - - @Suppress("DEPRECATION") - val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock") - - wakeLock.safeAcquire() - val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE -> { - wakeLock.safeRelease() mapView.onPause() } Lifecycle.Event.ON_RESUME -> { - wakeLock.safeAcquire() mapView.onResume() } @@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle( lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - wakeLock.safeRelease() - } + onDispose { lifecycle.removeObserver(observer) } } return mapView } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt index 7b12f70b9..7568d695a 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt @@ -21,8 +21,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Download import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,6 +30,8 @@ import androidx.compose.ui.draw.scale import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map_download_region +import org.meshtastic.core.ui.icon.Download +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { @@ -50,7 +50,7 @@ fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { ) { FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { Icon( - imageVector = Icons.Rounded.Download, + imageVector = MeshtasticIcons.Download, contentDescription = stringResource(Res.string.map_download_region), modifier = Modifier.scale(1.25f), ) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt index fbdf28e40..c41798bf0 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt @@ -34,9 +34,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.IconButton @@ -81,6 +78,9 @@ import org.meshtastic.core.resources.waypoint_edit import org.meshtastic.core.resources.waypoint_new import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.emoji.EmojiPickerDialog +import org.meshtastic.core.ui.icon.CalendarMonth +import org.meshtastic.core.ui.icon.Lock +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.proto.Waypoint import kotlin.time.Duration.Companion.hours @@ -198,7 +198,10 @@ fun EditWaypointDialog( modifier = Modifier.fillMaxWidth().size(48.dp), verticalAlignment = Alignment.CenterVertically, ) { - Image(imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.locked)) + Image( + imageVector = MeshtasticIcons.Lock, + contentDescription = stringResource(Res.string.locked), + ) Text(stringResource(Res.string.locked)) Switch( modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), @@ -255,7 +258,7 @@ fun EditWaypointDialog( verticalAlignment = Alignment.CenterVertically, ) { Image( - imageVector = Icons.Rounded.CalendarMonth, + imageVector = MeshtasticIcons.CalendarMonth, contentDescription = stringResource(Res.string.expires), ) Text(stringResource(Res.string.expires)) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt deleted file mode 100644 index 5bffb830d..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt +++ /dev/null @@ -1,61 +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.app.map.component - -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_style_selection -import org.meshtastic.core.ui.theme.AppTheme - -@Composable -fun MapButton( - icon: ImageVector, - contentDescription: StringResource, - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, -) { - MapButton( - icon = icon, - contentDescription = stringResource(contentDescription), - modifier = modifier, - onClick = onClick, - ) -} - -@Composable -fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { - FloatingActionButton(onClick = onClick, modifier = modifier) { - Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp)) - } -} - -@PreviewLightDark -@Composable -private fun MapButtonPreview() { - AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection) } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt index 668f17413..b7795180f 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt @@ -17,48 +17,38 @@ package org.meshtastic.app.map.node import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.model.CustomTileSource -import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.feature.map.node.NodeMapViewModel -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint - -private const val DEG_D = 1e-7 @Composable fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val density = LocalDensity.current - val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } - val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = nodeMapViewModel.applicationId, - box = cameraView, - tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId), - ) + val node by nodeMapViewModel.node.collectAsStateWithLifecycle() + val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(positionLogs) {} + Scaffold( + topBar = { + MainAppBar( + title = node?.user?.long_name ?: "", + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) }, - ) + ) { paddingValues -> + NodeTrackOsmMap( + positions = positions, + applicationId = nodeMapViewModel.applicationId, + mapStyleId = nodeMapViewModel.mapStyleId, + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) + } } 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 new file mode 100644 index 000000000..77b595d88 --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.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.app.map.node + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.feature.map.node.NodeMapViewModel +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, +) { + val vm = koinViewModel() + vm.setDestNum(destNum) + NodeTrackOsmMap( + positions = positions, + 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 new file mode 100644 index 000000000..a6aec4c2d --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.node + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.map.MapViewModel +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.model.CustomTileSource +import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.core.common.util.nowSeconds +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 +import kotlin.math.roundToInt + +/** + * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional + * markers for each historical position. + * + * 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]. + * + * 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. + */ +@Composable +fun NodeTrackOsmMap( + positions: List, + applicationId: String, + mapStyleId: Int, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, + mapViewModel: MapViewModel = koinViewModel(), +) { + val density = LocalDensity.current + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + + val filteredPositions = + remember(positions, lastHeardTrackFilter) { + positions.filter { + lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds + } + } + + val geoPoints = + remember(filteredPositions) { + filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } + } + val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) } + val mapView = + rememberMapViewWithLifecycle( + applicationId = applicationId, + box = cameraView, + tileSource = CustomTileSource.getTileSource(mapStyleId), + ) + + var filterMenuExpanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.matchParentSize(), + factory = { mapView }, + update = { map -> + map.overlays.clear() + 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) + } + } + }, + ) + + // Track filter controls overlay + MapControlsOverlay( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), + onToggleFilterMenu = { filterMenuExpanded = true }, + filterDropdownContent = { + DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val filterOptions = LastHeardFilter.entries + val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter) + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + + Text( + text = + stringResource( + Res.string.last_heard_filter_label, + stringResource(lastHeardTrackFilter.label), + ), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) + mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) + }, + valueRange = 0f..(filterOptions.size - 1).toFloat(), + steps = filterOptions.size - 2, + ) + } + } + }, + ) + } +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt new file mode 100644 index 000000000..fcf1d47e9 --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt @@ -0,0 +1,41 @@ +/* + * 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.app.map.traceroute + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation + * ([TracerouteOsmMap]). + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + TracerouteOsmMap( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + onMappableCountChanged = onMappableCountChanged, + modifier = modifier, + ) +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt new file mode 100644 index 000000000..55b49154a --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt @@ -0,0 +1,288 @@ +/* + * 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("MagicNumber") + +package org.meshtastic.app.map.traceroute + +import android.graphics.Paint +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.R +import org.meshtastic.app.map.MapViewModel +import org.meshtastic.app.map.addCopyright +import org.meshtastic.app.map.addScaleBarOverlay +import org.meshtastic.app.map.model.CustomTileSource +import org.meshtastic.app.map.model.MarkerWithLabel +import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.app.map.zoomIn +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS +import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.core.ui.util.formatAgo +import org.meshtastic.feature.map.tracerouteNodeSelection +import org.meshtastic.proto.Position +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +private const val TRACEROUTE_OFFSET_METERS = 100.0 +private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 +private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 + +/** + * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and + * forward/return offset polylines with auto-centering camera. + * + * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any + * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold. + */ +@Composable +fun TracerouteOsmMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, + mapViewModel: MapViewModel = koinViewModel(), +) { + val context = LocalContext.current + val density = LocalDensity.current + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } + + // Resolve which nodes to display for the traceroute + val tracerouteSelection = + remember(tracerouteOverlay, tracerouteNodePositions, nodes) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = nodes, + ) + } + val displayNodes = tracerouteSelection.nodesForMarkers + val nodeLookup = tracerouteSelection.nodeLookup + + // Report mappable count + LaunchedEffect(tracerouteOverlay, displayNodes) { + if (tracerouteOverlay != null) { + onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) + } + } + + // Compute polyline GeoPoints from node positions + val forwardPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.forwardRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + val returnPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.returnRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + + // Compute offset polylines for visual separation + val headingReferencePoints = + remember(forwardPoints, returnPoints) { + when { + forwardPoints.size >= 2 -> forwardPoints + returnPoints.size >= 2 -> returnPoints + else -> emptyList() + } + } + val forwardOffsetPoints = + remember(forwardPoints, headingReferencePoints) { + offsetPolyline( + points = forwardPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = headingReferencePoints, + sideMultiplier = 1.0, + ) + } + val returnOffsetPoints = + remember(returnPoints, headingReferencePoints) { + offsetPolyline( + points = returnPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = headingReferencePoints, + sideMultiplier = -1.0, + ) + } + + // Camera auto-center + var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) } + + // Build initial camera from all traceroute points + val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() } + val initialCameraView = + remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) } + + val mapView = + rememberMapViewWithLifecycle( + applicationId = mapViewModel.applicationId, + box = initialCameraView ?: BoundingBox(), + tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId), + ) + + // Center camera on traceroute bounds + LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) { + if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect + if (allPoints.isNotEmpty()) { + if (allPoints.size == 1) { + mapView.controller.setCenter(allPoints.first()) + mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) + } else { + mapView.zoomToBoundingBox( + BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), + true, + ) + } + hasCentered = true + } + } + + AndroidView( + modifier = modifier, + factory = { mapView.apply { setDestroyMode(false) } }, + update = { map -> + map.overlays.clear() + map.addCopyright() + map.addScaleBarOverlay(density) + + // Render traceroute polylines + buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) } + + // Render simple node markers + displayNodes.forEach { node -> + val position = GeoPoint(node.latitude, node.longitude) + val marker = + MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}") + .apply { + id = node.user.id + title = node.user.long_name + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + this.position = position + icon = markerIcon + setNodeColors(node.colors) + } + map.overlays.add(marker) + } + + map.invalidate() + }, + ) +} + +private fun buildTraceroutePolylines( + forwardPoints: List, + returnPoints: List, + density: androidx.compose.ui.unit.Density, +): List { + val polylines = mutableListOf() + + fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { + setPoints(points) + outlinePaint.apply { + this.color = color + this.strokeWidth = strokeWidth + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + style = Paint.Style.STROKE + } + } + + forwardPoints + .takeIf { it.size >= 2 } + ?.let { points -> + polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() })) + } + returnPoints + .takeIf { it.size >= 2 } + ?.let { points -> + polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() })) + } + return polylines +} + +// --- Haversine offset math for OSMDroid (no SphericalUtil available) --- + +private fun Double.toRad(): Double = this * PI / 180.0 + +private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { + val lat1 = from.latitude.toRad() + val lat2 = to.latitude.toRad() + val dLon = (to.longitude - from.longitude).toRad() + return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) +} + +private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { + val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS + val lat1 = latitude.toRad() + val lon1 = longitude.toRad() + val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) + val lon2 = + lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) + return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI) +} + +private fun offsetPolyline( + points: List, + offsetMeters: Double, + headingReferencePoints: List = points, + sideMultiplier: Double = 1.0, +): List { + val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points + if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points + + val headings = + headingPoints.mapIndexed { index, _ -> + when (index) { + 0 -> bearingRad(headingPoints[0], headingPoints[1]) + headingPoints.lastIndex -> + bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) + + else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) + } + } + + return points.mapIndexed { index, point -> + val heading = headings[index.coerceIn(0, headings.lastIndex)] + val perpendicularHeading = heading + (PI / 2 * sideMultiplier) + point.offsetPoint(perpendicularHeading, abs(offsetMeters)) + } +} 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 691874782..0583dd78e 100644 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -26,7 +26,6 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Severity import com.datadog.android.Datadog import com.datadog.android.DatadogSite -import com.datadog.android.compose.enableComposeActionTracking import com.datadog.android.core.configuration.Configuration import com.datadog.android.log.Logger import com.datadog.android.log.Logs @@ -38,7 +37,7 @@ import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumConfiguration import com.datadog.android.sessionreplay.SessionReplay import com.datadog.android.sessionreplay.SessionReplayConfiguration -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.trace.Trace import com.datadog.android.trace.TraceConfiguration import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry @@ -160,7 +159,6 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic .trackFrustrations(false) // Disable click-tracking based frustration detection .trackLongTasks() .trackNonFatalAnrs(true) - .enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags .setSessionSampleRate(sampleRate) .build() Rum.enable(rumConfiguration) @@ -175,7 +173,9 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic // Masks all text inputs to protect message content. if (BuildConfig.DEBUG) { val sessionReplayConfig = - SessionReplayConfiguration.Builder(sampleRate).setPrivacy(SessionReplayPrivacy.MASK_USER_INPUT).build() + SessionReplayConfiguration.Builder(sampleRate) + .setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL_INPUTS) + .build() SessionReplay.enable(sessionReplayConfig) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt index c228297a3..940c4ab5a 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt @@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.Single import org.meshtastic.core.ui.util.MapViewProvider +/** Google Maps implementation of [MapViewProvider]. */ @Single class GoogleMapViewProvider : MapViewProvider { @Composable - override fun MapView( - modifier: Modifier, - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int?, - nodeTracks: List?, - tracerouteOverlay: Any?, - tracerouteNodePositions: Map, - onTracerouteMappableCountChanged: (Int, Int) -> Unit, - waypointId: Int?, - ) { + override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } org.meshtastic.app.map.MapView( modifier = modifier, mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails, - focusedNodeNum = focusedNodeNum, - nodeTracks = nodeTracks as? List, - tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(), - onTracerouteMappableCountChanged = onTracerouteMappableCountChanged, ) } } 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 ec87c68f8..c8f2f3fee 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -21,8 +21,6 @@ package org.meshtastic.app.map import android.Manifest import android.app.Activity import android.content.Intent -import android.graphics.Canvas -import android.graphics.Paint import android.net.Uri import android.view.WindowManager import androidx.activity.compose.rememberLauncherForActivityResult @@ -35,8 +33,8 @@ 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.material.icons.rounded.TripOrigin import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -57,7 +55,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.graphics.createBitmap import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -68,13 +65,12 @@ import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.model.BitmapDescriptor -import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.JointType import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.maps.android.SphericalUtil +import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.ComposeMapColorScheme import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapEffect @@ -86,10 +82,13 @@ import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.MarkerInfoWindowComposable import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.TileOverlay +import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.google.maps.android.compose.widgets.ScaleBar +import com.google.maps.android.data.Layer import com.google.maps.android.data.geojson.GeoJsonLayer import com.google.maps.android.data.kml.KmlLayer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.json.JSONObject @@ -98,13 +97,18 @@ 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.MapControlsOverlay +import org.meshtastic.app.map.component.MapFilterDropdown +import org.meshtastic.app.map.component.MapTypeDropdown import org.meshtastic.app.map.component.NodeClusterMarkers +import org.meshtastic.app.map.component.NodeMapFilterDropdown import org.meshtastic.app.map.component.WaypointMarkers import org.meshtastic.app.map.model.NodeClusterItem import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.util.GeoConstants.DEG_D +import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.mpsToKmph import org.meshtastic.core.model.util.mpsToMph @@ -114,17 +118,25 @@ import org.meshtastic.core.resources.alt import org.meshtastic.core.resources.heading import org.meshtastic.core.resources.latitude import org.meshtastic.core.resources.longitude +import org.meshtastic.core.resources.manage_map_layers +import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.position import org.meshtastic.core.resources.sats import org.meshtastic.core.resources.speed import org.meshtastic.core.resources.timestamp import org.meshtastic.core.resources.track_point import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.core.ui.icon.Layers +import org.meshtastic.core.ui.icon.Map +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.TripOrigin import org.meshtastic.core.ui.theme.TracerouteColors 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.model.TracerouteOverlay +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 @@ -132,9 +144,35 @@ import org.meshtastic.proto.Waypoint import kotlin.math.abs import kotlin.math.max -private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f -private const val DEG_D = 1e-7 -private const val HEADING_DEG = 1e-5 +// region --- Map Mode --- + +/** + * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed + * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers, + * controls overlay) is available in every mode. + */ +sealed interface GoogleMapMode { + /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */ + 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 + + /** Traceroute visualization: offset forward/return polylines + hop markers. */ + data class Traceroute( + val overlay: TracerouteOverlay?, + val nodePositions: Map, + val onMappableCountChanged: (shown: Int, total: Int) -> Unit, + ) : GoogleMapMode +} + +// endregion + private const val TRACEROUTE_OFFSET_METERS = 100.0 private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 @@ -144,28 +182,22 @@ private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 fun MapView( modifier: Modifier = Modifier, mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - nodeTracks: List? = null, - tracerouteOverlay: TracerouteOverlay? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, + navigateToNodeDetails: (Int) -> Unit = {}, + mode: GoogleMapMode = GoogleMapMode.Main, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() - // Location permissions state + // --- Location permissions --- val locationPermissionsState = rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - // Location tracking state + // --- Location tracking --- var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followPhoneBearing by remember { mutableStateOf(false) } - // Effect to toggle location tracking after permission is granted LaunchedEffect(locationPermissionsState.allPermissionsGranted) { if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { isLocationTrackingEnabled = true @@ -173,9 +205,10 @@ fun MapView( } } + // --- File picker for map layers (Main mode) --- val filePickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { + if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> val fileName = uri.getFileName(context) mapViewModel.addMapLayer(uri, fileName) @@ -183,6 +216,7 @@ fun MapView( } } + // --- UI state --- var mapFilterMenuExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() @@ -194,16 +228,20 @@ fun MapView( var mapTypeMenuExpanded by remember { mutableStateOf(false) } var showCustomTileManagerSheet by remember { mutableStateOf(false) } - val cameraPositionState = mapViewModel.cameraPositionState + // --- Camera --- + // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering. + val cameraPositionState = + if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState() - // Save camera position when it stops moving - LaunchedEffect(cameraPositionState.isMoving) { - if (!cameraPositionState.isMoving) { - mapViewModel.saveCameraPosition(cameraPositionState.position) + if (mode is GoogleMapMode.Main) { + LaunchedEffect(cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + mapViewModel.saveCameraPosition(cameraPositionState.position) + } } } - // Location tracking functionality + // --- FusedLocation --- val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val locationCallback = remember { object : LocationCallback() { @@ -242,14 +280,12 @@ fun MapView( } } - // Start/stop location tracking based on state LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) .setMinUpdateIntervalMillis(2000L) .build() - try { @Suppress("MissingPermission") fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null) @@ -266,20 +302,12 @@ fun MapView( DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } } + // --- Node & waypoint data --- val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, allNodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = allNodes, - ) - } - val filteredNodes = allNodes .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } @@ -289,29 +317,7 @@ fun MapView( node.num == ourNodeInfo?.num } - val displayNodes = - if (tracerouteOverlay != null) { - tracerouteSelection.nodesForMarkers - } else { - filteredNodes - } - LaunchedEffect(tracerouteOverlay, displayNodes) { - if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) - } - } - val myNodeNum = mapViewModel.myNodeNum - val nodeClusterItems = displayNodes.map { node -> - val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.long_name}", - myNodeNum = myNodeNum, - ) - } val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val theme by mapViewModel.theme.collectAsStateWithLifecycle() val dark = @@ -321,20 +327,69 @@ fun MapView( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() else -> isSystemInDarkTheme() } - val mapColorScheme = - when (dark) { - true -> ComposeMapColorScheme.DARK - else -> ComposeMapColorScheme.LIGHT + val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT + + // --- Mode-specific data --- + // Node track: apply time filter + val sortedTrackPositions = + if (mode is GoogleMapMode.NodeTrack) { + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + remember(mode.positions, lastHeardTrackFilter) { + mode.positions + .filter { + lastHeardTrackFilter == LastHeardFilter.Any || + it.time > nowSeconds - lastHeardTrackFilter.seconds + } + .sortedBy { it.time } + } + } else { + emptyList() } - val tracerouteForwardPoints = - remember(tracerouteOverlay, displayNodes) { - val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + + // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules + // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all + // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops + // whose positions come from snapshots. + val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) + val tracerouteSelection = + if (mode is GoogleMapMode.Traceroute) { + remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = mode.overlay, + tracerouteNodePositions = mode.nodePositions, + nodes = allNodesForTraceroute, + ) + } + } else { + null } - val tracerouteReturnPoints = - remember(tracerouteOverlay, displayNodes) { - val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList() + + if (mode is GoogleMapMode.Traceroute) { + LaunchedEffect(mode.overlay, tracerouteDisplayNodes) { + if (mode.overlay != null) { + mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size) + } + } + } + + val tracerouteForwardPoints: List = + if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { + val nodeLookup = tracerouteSelection.nodeLookup + remember(mode.overlay, nodeLookup) { + mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() + } + } else { + emptyList() + } + val tracerouteReturnPoints: List = + if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { + val nodeLookup = tracerouteSelection.nodeLookup + remember(mode.overlay, nodeLookup) { + mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() + } + } else { + emptyList() } val tracerouteHeadingReferencePoints = remember(tracerouteForwardPoints, tracerouteReturnPoints) { @@ -346,24 +401,75 @@ fun MapView( } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteForwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = 1.0, - ) + offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0) } val tracerouteReturnOffsetPoints = remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteReturnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = -1.0, - ) + offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0) } - var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } + // Auto-centering for NodeTrack / Traceroute modes + var hasCentered by remember(mode) { mutableStateOf(false) } + + if (mode is GoogleMapMode.NodeTrack) { + LaunchedEffect(sortedTrackPositions, hasCentered) { + if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect + val points = sortedTrackPositions.map { it.toLatLng() } + val cameraUpdate = + if (points.size == 1) { + CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f)) + } else { + val bounds = LatLngBounds.builder() + points.forEach { bounds.include(it) } + CameraUpdateFactory.newLatLngBounds(bounds.build(), 80) + } + try { + cameraPositionState.animate(cameraUpdate) + hasCentered = true + } catch (e: IllegalStateException) { + 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) { + LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) { + if (mode.overlay == null || hasCentered) return@LaunchedEffect + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() + if (allPoints.isNotEmpty()) { + val cameraUpdate = + if (allPoints.size == 1) { + CameraUpdateFactory.newLatLngZoom( + allPoints.first(), + max(cameraPositionState.position.zoom, 12f), + ) + } else { + val bounds = LatLngBounds.builder() + allPoints.forEach { bounds.include(it) } + CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) + } + try { + cameraPositionState.animate(cameraUpdate) + hasCentered = true + } catch (e: IllegalStateException) { + Logger.d { "Error centering traceroute overlay: ${e.message}" } + } + } + } + } + + // --- Tile & layers state --- var showLayersBottomSheet by remember { mutableStateOf(false) } val onAddLayerClicked = { @@ -386,45 +492,23 @@ fun MapView( val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) } val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) } - val effectiveGoogleMapType = - if (currentCustomTileProviderUrl != null) { - MapType.NONE - } else { - selectedGoogleMapType - } + val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType var showClusterItemsDialog by remember { mutableStateOf?>(null) } + // --- Keep screen on while location tracking --- LaunchedEffect(isLocationTrackingEnabled) { val activity = context as? Activity ?: return@LaunchedEffect val window = activity.window - if (isLocationTrackingEnabled) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - val cameraUpdate = - if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f)) - } else { - val bounds = LatLngBounds.builder() - allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCenteredTraceroute = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering traceroute overlay: ${e.message}" } - } - } - } + + // --- Main UI --- + val isMainMode = mode is GoogleMapMode.Main Box(modifier = modifier) { GoogleMap( @@ -434,12 +518,12 @@ fun MapView( uiSettings = MapUiSettings( zoomControlsEnabled = true, - mapToolbarEnabled = true, + mapToolbarEnabled = isMainMode, compassEnabled = false, myLocationButtonEnabled = false, rotationGesturesEnabled = true, scrollGesturesEnabled = true, - tiltGesturesEnabled = true, + tiltGesturesEnabled = isMainMode, zoomGesturesEnabled = true, ), properties = @@ -448,16 +532,16 @@ fun MapView( isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, ), onMapLongClick = { latLng -> - if (isConnected) { - val newWaypoint = + if (isMainMode && isConnected) { + editingWaypoint = Waypoint( latitude_i = (latLng.latitude / DEG_D).toInt(), longitude_i = (latLng.longitude / DEG_D).toInt(), ) - editingWaypoint = newWaypoint } }, ) { + // Custom tile overlay (all modes) key(currentCustomTileProviderUrl) { currentCustomTileProviderUrl?.let { url -> val config = @@ -470,178 +554,145 @@ fun MapView( } } - if (tracerouteForwardPoints.size >= 2) { - Polyline( - points = tracerouteForwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 3.0f, - ) - } - if (tracerouteReturnPoints.size >= 2) { - Polyline( - points = tracerouteReturnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 2.5f, - ) - } - - if (nodeTracks != null && focusedNodeNum != null) { - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - val timeFilteredPositions = nodeTracks.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - val sortedPositions = timeFilteredPositions.sortedBy { it.time } - allNodes - .find { it.num == focusedNodeNum } - ?.let { focusedNode -> - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) - val color = Color(focusedNode.colors.second).copy(alpha = alpha) - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - - if (index == sortedPositions.lastIndex) { - MarkerComposable( - state = markerState, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - ) { - NodeChip(node = focusedNode) - } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = 1f + alpha, - infoContent = { - PositionInfoWindowContent(position = position, displayUnits = displayUnits) - }, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - ) - } - } - } - } - - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) - Polyline( - points = segmentPoints.map { it.toLatLng() }, - jointType = JointType.ROUND, - color = Color(focusedNode.colors.second).copy(alpha = alpha), - width = 8f, - zIndex = 0.6f, + when (mode) { + is GoogleMapMode.Main -> + MainMapContent( + nodeClusterItems = + filteredNodes.map { node -> + val latLng = + LatLng( + (node.position.latitude_i ?: 0) * DEG_D, + (node.position.longitude_i ?: 0) * DEG_D, ) - } - } + NodeClusterItem( + node = node, + nodePosition = latLng, + nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", + nodeSnippet = "${node.user.long_name}", + myNodeNum = myNodeNum, + ) + }, + mapFilterState = mapFilterState, + navigateToNodeDetails = navigateToNodeDetails, + displayableWaypoints = displayableWaypoints, + myNodeNum = myNodeNum, + isConnected = isConnected, + onEditWaypointRequest = { editingWaypoint = it }, + selectedWaypointId = selectedWaypointId, + mapLayers = mapLayers, + mapViewModel = mapViewModel, + cameraPositionState = cameraPositionState, + coroutineScope = coroutineScope, + onShowClusterItemsDialog = { showClusterItemsDialog = it }, + ) + + is GoogleMapMode.NodeTrack -> { + val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() + if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) { + NodeTrackOverlay( + focusedNode = mode.focusedNode, + sortedPositions = sortedTrackPositions, + displayUnits = displayUnits, + myNodeNum = myNodeNum, + selectedPositionTime = mode.selectedPositionTime, + onPositionSelected = mode.onPositionSelected, + ) } - } else { - NodeClusterMarkers( - nodeClusterItems = nodeClusterItems, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - onClusterClick = { cluster -> - val items = cluster.items.toList() - val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } + } - if (allSameLocation) { - showClusterItemsDialog = items - } else { - val bounds = LatLngBounds.builder() - cluster.items.forEach { bounds.include(it.position) } - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) + is GoogleMapMode.Traceroute -> + TracerouteMapContent( + forwardOffsetPoints = tracerouteForwardOffsetPoints, + returnOffsetPoints = tracerouteReturnOffsetPoints, + forwardPointCount = tracerouteForwardPoints.size, + returnPointCount = tracerouteReturnPoints.size, + displayNodes = tracerouteDisplayNodes, + ) } - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, - mapFilterState = mapFilterState, - myNodeNum = mapViewModel.myNodeNum ?: 0, - isConnected = isConnected, - unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap, - onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit }, - selectedWaypointId = selectedWaypointId, - ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } + // Scale bar ScaleBar( cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp), ) - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) - } - if ((updatedWp.icon ?: 0) == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy(expire = 1) - mapViewModel.sendWaypoint(deleteMarkerWp) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) + // Waypoint edit dialog (Main mode only) + if (isMainMode) { + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) + } + if ((updatedWp.icon ?: 0) == 0) { + finalWp = finalWp.copy(icon = 0x1F4CD) + } + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null + }, + onDeleteClicked = { wpToDelete -> + if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { + mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1)) + } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null + }, + onDismissRequest = { editingWaypoint = null }, + ) + } } + // Controls overlay val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } val showRefresh = visibleNetworkLayers.isNotEmpty() val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } MapControlsOverlay( modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - mapFilterMenuExpanded = mapFilterMenuExpanded, - onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false }, - onToggleMapFilterMenu = { mapFilterMenuExpanded = true }, - mapViewModel = mapViewModel, - mapTypeMenuExpanded = mapTypeMenuExpanded, - onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false }, - onToggleMapTypeMenu = { mapTypeMenuExpanded = true }, - onManageLayersClicked = { showLayersBottomSheet = true }, - onManageCustomTileProvidersClicked = { - mapTypeMenuExpanded = false - showCustomTileManagerSheet = true + onToggleFilterMenu = { mapFilterMenuExpanded = true }, + filterDropdownContent = { + if (mode is GoogleMapMode.NodeTrack) { + NodeMapFilterDropdown( + expanded = mapFilterMenuExpanded, + onDismissRequest = { mapFilterMenuExpanded = false }, + mapViewModel = mapViewModel, + ) + } else { + MapFilterDropdown( + expanded = mapFilterMenuExpanded, + onDismissRequest = { mapFilterMenuExpanded = false }, + mapViewModel = mapViewModel, + ) + } + }, + mapTypeContent = { + Box { + MapButton( + icon = MeshtasticIcons.Map, + contentDescription = stringResource(Res.string.map_tile_source), + onClick = { mapTypeMenuExpanded = true }, + ) + MapTypeDropdown( + expanded = mapTypeMenuExpanded, + onDismissRequest = { mapTypeMenuExpanded = false }, + mapViewModel = mapViewModel, + onManageCustomTileProvidersClicked = { + mapTypeMenuExpanded = false + showCustomTileManagerSheet = true + }, + ) + } + }, + layersContent = { + MapButton( + icon = MeshtasticIcons.Layers, + contentDescription = stringResource(Res.string.manage_map_layers), + onClick = { showLayersBottomSheet = true }, + ) }, - isNodeMap = focusedNodeNum != null, isLocationTrackingEnabled = isLocationTrackingEnabled, onToggleLocationTracking = { if (locationPermissionsState.allPermissionsGranted) { @@ -677,6 +728,8 @@ fun MapView( onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, ) } + + // --- Bottom sheets & dialogs --- if (showLayersBottomSheet) { ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { CustomMapLayersSheet( @@ -706,116 +759,159 @@ fun MapView( } } +// region --- Main Map Content --- + +@Suppress("LongParameterList") +@OptIn(MapsComposeExperimentalApi::class) @Composable -private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { - val context = LocalContext.current - var currentLayer by remember { mutableStateOf(null) } - - MapEffect(layerItem.id, layerItem.isRefreshing) { map -> - // Cleanup old layer if we're reloading - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - - val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect - val layer = - try { - when (layerItem.layerType) { - LayerType.KML -> KmlLayer(map, inputStream, context) - LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) +private fun MainMapContent( + nodeClusterItems: List, + mapFilterState: MapFilterState, + navigateToNodeDetails: (Int) -> Unit, + displayableWaypoints: List, + myNodeNum: Int?, + isConnected: Boolean, + onEditWaypointRequest: (Waypoint) -> Unit, + selectedWaypointId: Int?, + mapLayers: List, + mapViewModel: MapViewModel, + cameraPositionState: CameraPositionState, + coroutineScope: CoroutineScope, + onShowClusterItemsDialog: (List?) -> Unit, +) { + NodeClusterMarkers( + nodeClusterItems = nodeClusterItems, + mapFilterState = mapFilterState, + navigateToNodeDetails = navigateToNodeDetails, + onClusterClick = { cluster -> + val items = cluster.items.toList() + val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } + if (allSameLocation) { + onShowClusterItemsDialog(items) + } else { + val bounds = LatLngBounds.builder() + cluster.items.forEach { bounds.include(it.position) } + coroutineScope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder() + .target(bounds.build().center) + .zoom(cameraPositionState.position.zoom + 1) + .build(), + ), + ) } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } - null + Logger.d { "Cluster clicked! $cluster" } } + true + }, + ) - layer?.let { - if (layerItem.isVisible) { - it.safeAddLayerToMap() - } - currentLayer = it - } - } + WaypointMarkers( + displayableWaypoints = displayableWaypoints, + mapFilterState = mapFilterState, + myNodeNum = myNodeNum ?: 0, + isConnected = isConnected, + onEditWaypointRequest = onEditWaypointRequest, + selectedWaypointId = selectedWaypointId, + ) - DisposableEffect(layerItem.id) { - onDispose { - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - } - } - - // Handle visibility changes without reloading the whole layer if possible, - // though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have. - LaunchedEffect(layerItem.isVisible) { - val layer = currentLayer ?: return@LaunchedEffect - if (layerItem.isVisible) { - layer.safeAddLayerToMap() - } else { - layer.safeRemoveLayerFromMap() - } - } + mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } -private fun com.google.maps.android.data.Layer.safeRemoveLayerFromMap() { - try { - removeLayerFromMap() - } catch (e: Exception) { - // Log it and ignore. This specifically handles a NullPointerException in - // KmlRenderer.hasNestedContainers which can occur when disposing layers. - Logger.withTag("MapView").e(e) { "Error removing map layer" } - } -} +// endregion -private fun com.google.maps.android.data.Layer.safeAddLayerToMap() { - try { - if (!isLayerOnMap) { - addLayerToMap() - } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error adding map layer" } - } -} +// region --- Node Track Overlay --- -internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { - String(Character.toChars(unicodeCodePoint)) -} catch (e: IllegalArgumentException) { - Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } - "\uD83D\uDCCD" -} +/** + * 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 -internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor { - val unicodeEmoji = convertIntToEmoji(icon) - val paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = 64f - color = android.graphics.Color.BLACK - textAlign = Paint.Align.CENTER - } + sortedPositions.forEachIndexed { index, position -> + key(position.time) { + val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) + val alpha = + if (sortedPositions.size > 1) { + index.toFloat() / (sortedPositions.size.toFloat() - 1) + } else { + 1f + } + val isSelected = position.time == selectedPositionTime + val color = + if (isSelected) { + selectedColor + } else { + Color(focusedNode.colors.second).copy(alpha = alpha) + } - val baseline = -paint.ascent() - val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() - val height = (baseline + paint.descent() + 0.5f).toInt() - val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888) - val canvas = Canvas(image) - canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) - - return BitmapDescriptorFactory.fromBitmap(image) -} - -@Suppress("NestedBlockDepth") -fun Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "layer_$nowMillis" - if (this.scheme == "content") { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1) { - name = cursor.getString(displayNameIndex) + 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) + } + } else { + MarkerInfoWindowComposable( + 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 + }, + 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, + ) } } } } - return name + + // Gradient polyline segments + if (sortedPositions.size > 1) { + val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) + segments.forEachIndexed { index, segmentPoints -> + val alpha = index.toFloat() / (segments.size.toFloat() - 1) + Polyline( + points = segmentPoints.map { it.toLatLng() }, + jointType = JointType.ROUND, + color = Color(focusedNode.colors.second).copy(alpha = alpha), + width = 8f, + zIndex = 0.6f, + ) + } + } } @Composable @@ -836,26 +932,20 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU label = stringResource(Res.string.latitude), value = "%.5f".format((position.latitude_i ?: 0) * DEG_D), ) - PositionRow( label = stringResource(Res.string.longitude), value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), ) - - PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "") - + PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString()) PositionRow( label = stringResource(Res.string.alt), value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), ) - PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) - PositionRow( label = stringResource(Res.string.heading), value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), ) - PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) } } @@ -865,24 +955,53 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { val speedInMps = position.ground_speed ?: 0 val mpsText = "%d m/s".format(speedInMps) - val speedText = - if (speedInMps > 10) { - when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) - else -> mpsText - } - } else { - mpsText + return if (speedInMps > 10) { + when (displayUnits) { + DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) + DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) + else -> mpsText } - return speedText + } else { + mpsText + } } -internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +// endregion -private fun Node.toLatLng(): LatLng? = this.position.toLatLng() +// region --- Traceroute Map Content --- -private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun TracerouteMapContent( + forwardOffsetPoints: List, + returnOffsetPoints: List, + forwardPointCount: Int, + returnPointCount: Int, + displayNodes: List, +) { + if (forwardPointCount >= 2) { + Polyline( + points = forwardOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.OutgoingRoute, + width = 9f, + zIndex = 3.0f, + ) + } + if (returnPointCount >= 2) { + Polyline( + points = returnOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.ReturnRoute, + width = 7f, + zIndex = 2.5f, + ) + } + displayNodes.forEach { node -> + val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng()) + MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) } + } +} private fun offsetPolyline( points: List, @@ -893,18 +1012,19 @@ private fun offsetPolyline( val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - val headings = headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - SphericalUtil.computeHeading( - headingPoints[headingPoints.lastIndex - 1], - headingPoints[headingPoints.lastIndex], - ) + val headings = + headingPoints.mapIndexed { index, _ -> + when (index) { + 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1]) + headingPoints.lastIndex -> + SphericalUtil.computeHeading( + headingPoints[headingPoints.lastIndex - 1], + headingPoints[headingPoints.lastIndex], + ) - else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) + else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) + } } - } return points.mapIndexed { index, point -> val heading = headings[index.coerceIn(0, headings.lastIndex)] @@ -912,3 +1032,94 @@ private fun offsetPolyline( SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading) } } + +// endregion + +// region --- Map Layers --- + +@Composable +private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { + val context = LocalContext.current + var currentLayer by remember { mutableStateOf(null) } + + MapEffect(layerItem.id, layerItem.isRefreshing) { map -> + currentLayer?.safeRemoveLayerFromMap() + currentLayer = null + val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect + val layer = + try { + when (layerItem.layerType) { + LayerType.KML -> KmlLayer(map, inputStream, context) + LayerType.GEOJSON -> + GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) + } + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } + null + } + layer?.let { + if (layerItem.isVisible) it.safeAddLayerToMap() + currentLayer = it + } + } + + DisposableEffect(layerItem.id) { + onDispose { + currentLayer?.safeRemoveLayerFromMap() + currentLayer = null + } + } + + LaunchedEffect(layerItem.isVisible) { + val layer = currentLayer ?: return@LaunchedEffect + if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap() + } +} + +private fun Layer.safeRemoveLayerFromMap() { + try { + removeLayerFromMap() + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error removing map layer" } + } +} + +private fun Layer.safeAddLayerToMap() { + try { + if (!isLayerOnMap) addLayerToMap() + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error adding map layer" } + } +} + +// endregion + +// region --- Utilities --- + +internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { + String(Character.toChars(unicodeCodePoint)) +} catch (e: IllegalArgumentException) { + Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } + "\uD83D\uDCCD" +} + +@Suppress("NestedBlockDepth") +fun Uri.getFileName(context: android.content.Context): String { + var name = this.lastPathSegment ?: "layer_$nowMillis" + if (this.scheme == "content") { + context.contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (displayNameIndex != -1) { + name = cursor.getString(displayNameIndex) + } + } + } + } + return name +} + +/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */ +internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) + +// endregion diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 70ff4858d..e4eabbb76 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider import com.google.android.gms.maps.model.UrlTileProvider import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.MapType -import kotlinx.coroutines.Dispatchers +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.isSuccess +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.app.map.model.CustomTileProviderConfig import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs import org.meshtastic.app.map.repository.CustomTileProviderRepository +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository @@ -77,6 +82,8 @@ data class MapCameraPosition( @KoinViewModel class MapViewModel( private val application: Application, + private val dispatchers: CoroutineDispatchers, + private val httpClient: HttpClient, mapPrefs: MapPrefs, private val googleMapsPrefs: GoogleMapsPrefs, nodeRepository: NodeRepository, @@ -404,7 +411,7 @@ class MapViewModel( } private fun loadPersistedLayers() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(dispatchers.io) { try { val layersDir = File(application.filesDir, "map_layers") if (layersDir.exists() && layersDir.isDirectory) { @@ -412,32 +419,33 @@ class MapViewModel( if (persistedLayerFiles != null) { val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - "geojson", - "json", - -> LayerType.GEOJSON - else -> null - } + val loadedItems = + persistedLayerFiles.mapNotNull { file -> + if (file.isFile) { + val layerType = + when (file.extension.lowercase()) { + "kml", + "kmz", + -> LayerType.KML + "geojson", + "json", + -> LayerType.GEOJSON + else -> null + } - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) + layerType?.let { + val uri = Uri.fromFile(file) + MapLayerItem( + name = file.nameWithoutExtension, + uri = uri, + isVisible = !hiddenLayerUrls.contains(uri.toString()), + layerType = it, + ) + } + } else { + null } - } else { - null } - } val networkItems = googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> @@ -550,7 +558,7 @@ class MapViewModel( } } - private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) { + private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { try { val inputStream = application.contentResolver.openInputStream(uri) val directory = File(application.filesDir, "map_layers") @@ -621,7 +629,7 @@ class MapViewModel( } private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { try { val file = uri.toFile() if (file.exists()) { @@ -636,11 +644,15 @@ class MapViewModel( @Suppress("Recycle") suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { val uriToLoad = layerItem.uri ?: return null - return withContext(Dispatchers.IO) { + return withContext(dispatchers.io) { try { if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val url = java.net.URL(uriToLoad.toString()) - java.io.BufferedInputStream(url.openStream()) + val response = httpClient.get(uriToLoad.toString()) + if (!response.status.isSuccess()) { + Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" } + return@withContext null + } + response.bodyAsChannel().toInputStream() } else { application.contentResolver.openInputStream(uriToLoad) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt index 85369120c..fd9272579 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 @@ -25,17 +25,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator 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 @@ -66,6 +62,11 @@ import org.meshtastic.core.resources.save import org.meshtastic.core.resources.show_layer import org.meshtastic.core.resources.url import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff @Suppress("LongMethod") @Composable @@ -119,19 +120,22 @@ fun CustomMapLayersSheet( } else { IconButton(onClick = { onRefreshLayer(layer.id) }) { Icon( - imageVector = Icons.Filled.Refresh, + imageVector = MeshtasticIcons.Refresh, contentDescription = stringResource(Res.string.refresh), ) } } } - IconButton(onClick = { onToggleVisibility(layer.id) }) { + IconToggleButton( + checked = layer.isVisible, + onCheckedChange = { onToggleVisibility(layer.id) }, + ) { Icon( imageVector = if (layer.isVisible) { - Icons.Filled.Visibility + MeshtasticIcons.Visibility } else { - Icons.Filled.VisibilityOff + MeshtasticIcons.VisibilityOff }, contentDescription = stringResource( @@ -145,7 +149,7 @@ fun CustomMapLayersSheet( } IconButton(onClick = { onRemoveLayer(layer.id) }) { Icon( - imageVector = Icons.Filled.Delete, + imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.remove_layer), ) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt index 458de9f56..8082e40d1 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt @@ -27,9 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -71,6 +68,9 @@ import org.meshtastic.core.resources.url_must_contain_placeholders import org.meshtastic.core.resources.url_template import org.meshtastic.core.resources.url_template_hint import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.Edit +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.showToast @Suppress("LongMethod") @@ -155,13 +155,13 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) { }, ) { Icon( - Icons.Filled.Edit, + MeshtasticIcons.Edit, contentDescription = stringResource(Res.string.edit_custom_tile_source), ) } IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) { Icon( - Icons.Filled.Delete, + MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete_custom_tile_source), ) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt index df808c615..18eb0ac83 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt @@ -33,9 +33,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -60,7 +57,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.datetime.atTime @@ -82,6 +78,9 @@ import org.meshtastic.core.resources.time import org.meshtastic.core.resources.waypoint_edit import org.meshtastic.core.resources.waypoint_new import org.meshtastic.core.ui.emoji.EmojiPickerDialog +import org.meshtastic.core.ui.icon.CalendarMonth +import org.meshtastic.core.ui.icon.Lock +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.proto.Waypoint import kotlin.time.Duration.Companion.hours @@ -120,12 +119,12 @@ fun EditWaypointDialog( val expireValue = waypointInput.expire ?: 0 if (isExpiryEnabled) { if (expireValue != 0 && expireValue != Int.MAX_VALUE) { - val instant = Instant.fromEpochSeconds(expireValue.toLong()) + val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong()) val date = java.util.Date(instant.toEpochMilliseconds()) selectedDateString = dateFormat.format(date) selectedTimeString = timeFormat.format(date) } else { // If enabled but not set, default to 8 hours from now - val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours + val futureInstant = kotlin.time.Clock.System.now() + 8.hours val date = java.util.Date(futureInstant.toEpochMilliseconds()) selectedDateString = dateFormat.format(date) selectedTimeString = timeFormat.format(date) @@ -190,7 +189,7 @@ fun EditWaypointDialog( ) { Row(verticalAlignment = Alignment.CenterVertically) { Image( - imageVector = Icons.Rounded.Lock, + imageVector = MeshtasticIcons.Lock, contentDescription = stringResource(Res.string.locked), ) Spacer(modifier = Modifier.width(8.dp)) @@ -209,7 +208,7 @@ fun EditWaypointDialog( ) { Row(verticalAlignment = Alignment.CenterVertically) { Image( - imageVector = Icons.Rounded.CalendarMonth, + imageVector = MeshtasticIcons.CalendarMonth, contentDescription = stringResource(Res.string.expires), ) Spacer(modifier = Modifier.width(8.dp)) @@ -223,7 +222,7 @@ fun EditWaypointDialog( val expireValue = waypointInput.expire ?: 0 // Default to 8 hours from now if not already set if (expireValue == 0 || expireValue == Int.MAX_VALUE) { - val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours + val futureInstant = kotlin.time.Clock.System.now() + 8.hours waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) } } else { @@ -237,9 +236,9 @@ fun EditWaypointDialog( val currentInstant = (waypointInput.expire ?: 0).let { if (it != 0 && it != Int.MAX_VALUE) { - Instant.fromEpochSeconds(it.toLong()) + kotlin.time.Instant.fromEpochSeconds(it.toLong()) } else { - kotlinx.datetime.Clock.System.now() + 8.hours + kotlin.time.Clock.System.now() + 8.hours } } val ldt = currentInstant.toLocalDateTime(tz) @@ -252,9 +251,9 @@ fun EditWaypointDialog( (waypointInput.expire ?: 0) .let { if (it != 0 && it != Int.MAX_VALUE) { - Instant.fromEpochSeconds(it.toLong()) + kotlin.time.Instant.fromEpochSeconds(it.toLong()) } else { - kotlinx.datetime.Clock.System.now() + 8.hours + kotlin.time.Clock.System.now() + 8.hours } } .toLocalDateTime(tz) @@ -287,9 +286,9 @@ fun EditWaypointDialog( (waypointInput.expire ?: 0) .let { if (it != 0 && it != Int.MAX_VALUE) { - Instant.fromEpochSeconds(it.toLong()) + kotlin.time.Instant.fromEpochSeconds(it.toLong()) } else { - kotlinx.datetime.Clock.System.now() + 8.hours + kotlin.time.Clock.System.now() + 8.hours } } .toLocalDateTime(tz) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt deleted file mode 100644 index 19cb41184..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt +++ /dev/null @@ -1,159 +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.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.material.icons.Icons -import androidx.compose.material.icons.filled.Navigation -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.Map -import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Navigation -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.rounded.LocationDisabled -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_filter -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.orient_north -import org.meshtastic.core.resources.refresh -import org.meshtastic.core.resources.toggle_my_position -import org.meshtastic.core.ui.theme.StatusColors.StatusRed - -@Composable -fun MapControlsOverlay( - modifier: Modifier = Modifier, - mapFilterMenuExpanded: Boolean, - onMapFilterMenuDismissRequest: () -> Unit, - onToggleMapFilterMenu: () -> Unit, - mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown - mapTypeMenuExpanded: Boolean, - onMapTypeMenuDismissRequest: () -> Unit, - onToggleMapTypeMenu: () -> Unit, - onManageLayersClicked: () -> Unit, - onManageCustomTileProvidersClicked: () -> Unit, // New parameter - isNodeMap: Boolean, - // Location tracking parameters - isLocationTrackingEnabled: Boolean = false, - onToggleLocationTracking: () -> Unit = {}, - bearing: Float = 0f, - onCompassClick: () -> Unit = {}, - followPhoneBearing: Boolean, - showRefresh: Boolean = false, - isRefreshing: Boolean = false, - onRefresh: () -> Unit = {}, -) { - Row(modifier = modifier) { - CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) - if (isNodeMap) { - MapButton( - icon = Icons.Outlined.Tune, - contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleMapFilterMenu, - ) - NodeMapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = onMapFilterMenuDismissRequest, - mapViewModel = mapViewModel, - ) - } else { - Box { - MapButton( - icon = Icons.Outlined.Tune, - contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleMapFilterMenu, - ) - MapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = onMapFilterMenuDismissRequest, - mapViewModel = mapViewModel, - ) - } - } - - Box { - MapButton( - icon = Icons.Outlined.Map, - contentDescription = stringResource(Res.string.map_tile_source), - onClick = onToggleMapTypeMenu, - ) - MapTypeDropdown( - expanded = mapTypeMenuExpanded, - onDismissRequest = onMapTypeMenuDismissRequest, - mapViewModel = mapViewModel, // Pass mapViewModel - onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback - ) - } - - MapButton( - icon = Icons.Outlined.Layers, - contentDescription = stringResource(Res.string.manage_map_layers), - onClick = onManageLayersClicked, - ) - - if (showRefresh) { - if (isRefreshing) { - Box(modifier = Modifier.padding(8.dp)) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - } - } else { - MapButton( - icon = Icons.Filled.Refresh, - contentDescription = stringResource(Res.string.refresh), - onClick = onRefresh, - ) - } - } - - // Location tracking button - MapButton( - icon = - if (isLocationTrackingEnabled) { - Icons.Rounded.LocationDisabled - } else { - Icons.Outlined.MyLocation - }, - contentDescription = stringResource(Res.string.toggle_my_position), - onClick = onToggleLocationTracking, - ) - } -} - -@Composable -private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) { - val icon = if (isFollowing) Icons.Filled.Navigation else Icons.Outlined.Navigation - - MapButton( - modifier = Modifier.rotate(-bearing), - icon = icon, - iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f }, - contentDescription = stringResource(Res.string.orient_north), - onClick = onClick, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt index 57886edda..d8e29120e 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt @@ -18,10 +18,6 @@ package org.meshtastic.app.map.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Place -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -45,6 +41,10 @@ import org.meshtastic.core.resources.last_heard_filter_label import org.meshtastic.core.resources.only_favorites import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_waypoints +import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.Lens +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PinDrop import org.meshtastic.feature.map.LastHeardFilter import kotlin.math.roundToInt @@ -56,7 +56,10 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, text = { Text(stringResource(Res.string.only_favorites)) }, onClick = { mapViewModel.toggleOnlyFavorites() }, leadingIcon = { - Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(Res.string.only_favorites)) + Icon( + imageVector = MeshtasticIcons.Favorite, + contentDescription = stringResource(Res.string.only_favorites), + ) }, trailingIcon = { Checkbox( @@ -69,7 +72,10 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, text = { Text(stringResource(Res.string.show_waypoints)) }, onClick = { mapViewModel.toggleShowWaypointsOnMap() }, leadingIcon = { - Icon(imageVector = Icons.Filled.Place, contentDescription = stringResource(Res.string.show_waypoints)) + Icon( + imageVector = MeshtasticIcons.PinDrop, + contentDescription = stringResource(Res.string.show_waypoints), + ) }, trailingIcon = { Checkbox( @@ -83,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, leadingIcon = { Icon( - imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon + imageVector = MeshtasticIcons.Lens, contentDescription = stringResource(Res.string.show_precision_circle), ) }, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt index 58c728cec..ad4bd58bb 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.app.map.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -36,6 +34,8 @@ import org.meshtastic.core.resources.map_type_normal import org.meshtastic.core.resources.map_type_satellite import org.meshtastic.core.resources.map_type_terrain import org.meshtastic.core.resources.selected_map_type +import org.meshtastic.core.ui.icon.Check +import org.meshtastic.core.ui.icon.MeshtasticIcons @Suppress("LongMethod") @Composable @@ -67,7 +67,12 @@ internal fun MapTypeDropdown( }, trailingIcon = if (selectedCustomUrl == null && selectedGoogleMapType == type) { - { Icon(Icons.Filled.Check, contentDescription = stringResource(Res.string.selected_map_type)) } + { + Icon( + MeshtasticIcons.Check, + contentDescription = stringResource(Res.string.selected_map_type), + ) + } } else { null }, @@ -87,7 +92,7 @@ internal fun MapTypeDropdown( if (selectedCustomUrl == config.urlTemplate) { { Icon( - Icons.Filled.Check, + MeshtasticIcons.Check, contentDescription = stringResource(Res.string.selected_map_type), ) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index fdc5ee262..61cdab9f1 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -16,30 +16,36 @@ */ package org.meshtastic.app.map.component +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import com.google.android.gms.maps.model.BitmapDescriptor +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.rememberComposeBitmapDescriptor import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch +import org.meshtastic.app.map.convertIntToEmoji +import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.locked import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.proto.Waypoint -private const val DEG_D = 1e-7 - +@OptIn(MapsComposeExperimentalApi::class) @Composable fun WaypointMarkers( displayableWaypoints: List, mapFilterState: BaseMapViewModel.MapFilterState, myNodeNum: Int, isConnected: Boolean, - unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor, onEditWaypointRequest: (Waypoint) -> Unit, selectedWaypointId: Int? = null, ) { @@ -58,14 +64,16 @@ fun WaypointMarkers( } } + val iconCodePoint = if ((waypoint.icon ?: 0) == 0) PUSHPIN else waypoint.icon!! + val emojiText = convertIntToEmoji(iconCodePoint) + val icon = + rememberComposeBitmapDescriptor(iconCodePoint) { + Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) + } + Marker( state = markerState, - icon = - if ((waypoint.icon ?: 0) == 0) { - unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin) - } else { - unicodeEmojiToBitmapProvider(waypoint.icon!!) - }, + icon = icon, title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '), snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '), visible = true, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt index f6691b5ce..fa17fedbf 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt @@ -16,13 +16,14 @@ */ package org.meshtastic.app.map.node -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.meshtastic.app.map.GoogleMapMode import org.meshtastic.app.map.MapView import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.feature.map.node.NodeMapViewModel @@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val destNum = node?.num Scaffold( topBar = { @@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) ) }, ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {}) - } + MapView( + modifier = Modifier.fillMaxSize().padding(paddingValues), + mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), + ) } } 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 new file mode 100644 index 000000000..2f7244b97 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -0,0 +1,58 @@ +/* + * 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.app.map.node + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.map.GoogleMapMode +import org.meshtastic.app.map.MapView +import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a + * [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, +) { + 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, + ), + ) +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt index e33fb1f8c..668dedbaa 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt @@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers @Module @ComponentScan("org.meshtastic.app.map") @@ -36,9 +36,10 @@ class GoogleMapsKoinModule { @Single @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) + fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, + ) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt new file mode 100644 index 000000000..d725537c8 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt @@ -0,0 +1,46 @@ +/* + * 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.app.map.traceroute + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.app.map.GoogleMapMode +import org.meshtastic.app.map.MapView +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute] + * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay). + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + MapView( + modifier = modifier, + mode = + GoogleMapMode.Traceroute( + overlay = tracerouteOverlay, + nodePositions = tracerouteNodePositions, + onMappableCountChanged = onMappableCountChanged, + ), + ) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f3bea85f7..f7d2ce900 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,8 +44,8 @@ - - + + @@ -288,7 +288,7 @@ + android:resource="@xml/widget_local_stats_info" /> diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json index 02c44e660..b4e3550eb 100644 --- a/app/src/main/assets/device_hardware.json +++ b/app/src/main/assets/device_hardware.json @@ -1212,7 +1212,7 @@ "Heltec" ], "requiresDfu": true, - "hasMui": false, + "hasMui": true, "partitionScheme": "16MB", "images": [ "heltec_v4.svg" @@ -1257,7 +1257,7 @@ "hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2", "platformioTarget": "heltec-wireless-tracker-v2", "architecture": "esp32-s3", - "activelySupported": false, + "activelySupported": true, "supportLevel": 1, "displayName": "Heltec Wireless Tracker V2", "tags": [ @@ -1380,7 +1380,7 @@ "hasMui": false, "partitionScheme": "8MB", "images": [ - "t5s3-epaper-pro.svg" + "t5s3_epaper.svg" ] }, { diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index c9a35366b..ffdb465d6 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -24,12 +24,26 @@ } ], "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" + }, { "id": "v2.7.20.6658ec2", "title": "Meshtastic Firmware 2.7.20.6658ec2 Alpha", "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.20.6658ec2", "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.20.6658ec2/firmware-2.7.20.6658ec2.json", - "release_notes": "## 🚀 Enhancements\r\n\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- 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- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\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- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\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 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- 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- 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\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\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.19.bb3d6d5...v2.7.20.6658ec2" + "release_notes": "## 🚀 Enhancements\r\n\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- 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- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\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- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\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 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- 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- 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\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\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.20.6658ec2" }, { "id": "v2.7.19.bb3d6d5", @@ -170,59 +184,8 @@ "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" - }, - { - "id": "v2.6.6.54c1423", - "title": "Meshtastic Firmware 2.6.6.54c1423 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.6.54c1423", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.6.54c1423/firmware-esp32-2.6.6.54c1423.zip", - "release_notes": "## 🚀 Enhancements\r\n* DIY v1/v1_1 add TCXO_OPTIONAL make it so that the firmware can try both TCXO and XTAL by @Andrik45719 in https://github.com/meshtastic/firmware/pull/6534\r\n* InkHUD support for LilyGo T3S3 E-Paper by @todd-herbert in https://github.com/meshtastic/firmware/pull/6503\r\n* Feat: Add Electronic Cats variant for Catsniffer by @JahazielLem in https://github.com/meshtastic/firmware/pull/6483\r\n* Add generic thread module by @tavdog in https://github.com/meshtastic/firmware/pull/5484\r\n* Add Meshtastic Linux desktop metadata by @vidplace7 in https://github.com/meshtastic/firmware/pull/6568\r\n* Add new hardware: Heltec MeshPocket by @Heltec-Aaron-Lee in https://github.com/meshtastic/firmware/pull/6533\r\n* Switch to actually maintained thingsboard pubsubclient by @thebentern in https://github.com/meshtastic/firmware/pull/5204\r\n* Make startup screen show the short ID by @Heltec-Aaron-Lee in https://github.com/meshtastic/firmware/pull/6591\r\n* Update platformio.ini to exclude unused modules from t1000-e by @benkyd in https://github.com/meshtastic/firmware/pull/6584\r\n* Debian: use native-tft compile target by @vidplace7 in https://github.com/meshtastic/firmware/pull/6580\r\n* Create lora-piggystick-lr1121.yaml by @markbirss in https://github.com/meshtastic/firmware/pull/6600\r\n* Add TFT docker builds (for CI) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6614\r\n* FlatHub: bump metainfo.xml on release by @ThatKalle in https://github.com/meshtastic/firmware/pull/6578\r\n\r\n## 🐛 Bug fixes and enhancements\r\n* Fix Ublox GPS for Heltec T114 by @todd-herbert in https://github.com/meshtastic/firmware/pull/6497\r\n* Portduino: Set C standard to 17 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6561\r\n* Fix: Correct underlying cause of T-Watch not functioning when set to a 16MB filesystem by @Kealper in https://github.com/meshtastic/firmware/pull/6563\r\n* Trunk fixes for heltec mesh pocket. by @fifieldt in https://github.com/meshtastic/firmware/pull/6588\r\n* Fix T-Echo display light blink on LoRa TX by @todd-herbert in https://github.com/meshtastic/firmware/pull/6590\r\n* Fix: set upload_speed for tlora_v1_3 & tlora_v2_1_16 by @MayNiklas in https://github.com/meshtastic/firmware/pull/6595\r\n* Fix tlora v1 uploadspeed by @MayNiklas in https://github.com/meshtastic/firmware/pull/6601\r\n* Fix uninitialised memory read (adminModule) by @benkyd in https://github.com/meshtastic/firmware/pull/6605\r\n* Add support for Seeed solar panel by @Dylanliacc in https://github.com/meshtastic/firmware/pull/6597\r\n* Fix compiler error in PowerFSM when WiFi is excluded by @benkyd in https://github.com/meshtastic/firmware/pull/6603\r\n* Crowpanel support by @caveman99 in https://github.com/meshtastic/firmware/pull/6355\r\n* Lib Update by @caveman99 in https://github.com/meshtastic/firmware/pull/6510\r\n* Fix crash when clearing NRF52 BLE bonds by @todd-herbert in https://github.com/meshtastic/firmware/pull/6609\r\n* Docker: Fix arg passthrough by @vidplace7 in https://github.com/meshtastic/firmware/pull/6623\r\n* RPM: Build native-tft target by @vidplace7 in https://github.com/meshtastic/firmware/pull/6613\r\n* Docker alpine: Add config templates by @vidplace7 in https://github.com/meshtastic/firmware/pull/6631\r\n* Appdata.xml: Add date to all releases by @vidplace7 in https://github.com/meshtastic/firmware/pull/6632\r\n* Rak13800 Ethernet works on rak11310 too by @Nivek-domo in https://github.com/meshtastic/firmware/pull/6622\r\n* Build and deploy event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6628\r\n* Publish firmware all together by @vidplace7 in https://github.com/meshtastic/firmware/pull/6642\r\n* Fix: SenseCAP Indicator: remove buzzer definition by @mverch67 in https://github.com/meshtastic/firmware/pull/6652\r\n* Correct a typing error in InkHUD display driver by @todd-herbert in https://github.com/meshtastic/firmware/pull/6651\r\n* Fix preamble detected IRQ flag by @GUVWAF in https://github.com/meshtastic/firmware/pull/6653\r\n* Update meshtastic-device-ui digest to 189ed6c by @renovate in https://github.com/meshtastic/firmware/pull/6657\r\n* Fix building WiPhone variant by @todd-herbert in https://github.com/meshtastic/firmware/pull/6664\r\n* Downgrade web to 2.5.4 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6669\r\n\r\n## New Contributors\r\n* @renovate made their first contribution in https://github.com/meshtastic/firmware/pull/6545\r\n* @JahazielLem made their first contribution in https://github.com/meshtastic/firmware/pull/6483\r\n* @MayNiklas made their first contribution in https://github.com/meshtastic/firmware/pull/6595\r\n* @benkyd made their first contribution in https://github.com/meshtastic/firmware/pull/6584\r\n* @Nivek-domo made their first contribution in https://github.com/meshtastic/firmware/pull/6622\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.5.fc3d9f2...v2.6.6.54c1423" } ] }, - "pullRequests": [ - { - "id": "10014", - "title": "adding dfr1195 device and support for rotating screen 180 deg", - "page_url": "https://github.com/meshtastic/firmware/pull/10014", - "zip_url": "https://discord.com/invite/meshtastic" - }, - { - "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" - }, - { - "id": "9955", - "title": "Add Env for Seeed XIAO ESP32-C6 + Wio-SX1262", - "page_url": "https://github.com/meshtastic/firmware/pull/9955", - "zip_url": "https://discord.com/invite/meshtastic" - }, - { - "id": "9954", - "title": "fix:[RTC] update time on rp2040", - "page_url": "https://github.com/meshtastic/firmware/pull/9954", - "zip_url": "https://discord.com/invite/meshtastic" - }, - { - "id": "9951", - "title": "fix: big-endian byte ordering for radio packet header fields", - "page_url": "https://github.com/meshtastic/firmware/pull/9951", - "zip_url": "https://discord.com/invite/meshtastic" - }, - { - "id": "9949", - "title": "fix: preserve higher-quality RTC time on system-time refresh", - "page_url": "https://github.com/meshtastic/firmware/pull/9949", - "zip_url": "https://discord.com/invite/meshtastic" - } - ] + "pullRequests": [] } \ 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 8b3e85b9c..628865010 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -45,11 +45,12 @@ import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Logger import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory +import com.eygraber.uri.toKmpUri import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject -import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.meshtastic.app.intro.AnalyticsIntro import org.meshtastic.app.map.getMapViewProvider @@ -57,8 +58,8 @@ import org.meshtastic.app.node.component.InlineMap import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_invalid @@ -69,18 +70,30 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalInlineMapProvider +import org.meshtastic.core.ui.util.LocalMapMainScreenProvider import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider import org.meshtastic.core.ui.util.LocalNfcScannerSupported +import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider +import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider +import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.intro.AppIntroductionScreen import org.meshtastic.feature.intro.IntroViewModel +import org.meshtastic.feature.map.MapScreen +import org.meshtastic.feature.map.SharedMapViewModel +import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.feature.node.metrics.MetricsViewModel +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. @@ -114,6 +127,8 @@ class MainActivity : ComponentActivity() { setSingletonImageLoaderFactory { get() } val theme by model.theme.collectAsStateWithLifecycle() + val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle() + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) val dynamic = theme == MODE_DYNAMIC val dark = when (theme) { @@ -131,7 +146,7 @@ class MainActivity : ComponentActivity() { } AppCompositionLocals { - AppTheme(dynamicColor = dynamic, darkTheme = dark) { + AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() // Signal to the system that the initial UI is "fully drawn" @@ -154,6 +169,16 @@ class MainActivity : ComponentActivity() { handleIntent(intent) } + override fun onResume() { + super.onResume() + // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is + // resumed while a USB device is already attached (e.g. process restart, returning + // from another app), the manifest-declared attach intent may have already fired + // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects + // reality without requiring the user to physically replug. + usbRepository.refreshState() + } + @Composable private fun AppCompositionLocals(content: @Composable () -> Unit) { CompositionLocalProvider( @@ -164,32 +189,48 @@ class MainActivity : ComponentActivity() { LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, 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, + ) + }, LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), - org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides + LocalTracerouteMapProvider provides + { overlay, nodePositions, onMappableCountChanged, modifier -> + org.meshtastic.app.map.traceroute.TracerouteMap( + tracerouteOverlay = overlay, + tracerouteNodePositions = nodePositions, + onMappableCountChanged = onMappableCountChanged, + modifier = modifier, + ) + }, + LocalNodeMapScreenProvider provides { destNum, onNavigateUp -> - val vm = koinViewModel() + val vm = koinViewModel() vm.setDestNum(destNum) org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) }, - org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides + LocalTracerouteMapScreenProvider provides { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = - koinViewModel { - org.koin.core.parameter.parametersOf(destNum) - } + val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - org.meshtastic.feature.node.metrics.TracerouteMapScreen( + TracerouteMapScreen( metricsViewModel = metricsViewModel, requestId = requestId, logUuid = logUuid, onNavigateUp = onNavigateUp, ) }, - org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides + LocalMapMainScreenProvider provides { onClickNodeChip, navigateToNodeDetails, waypointId -> - val viewModel = koinViewModel() - org.meshtastic.feature.map.MapScreen( + val viewModel = koinViewModel() + MapScreen( viewModel = viewModel, onClickNodeChip = onClickNodeChip, navigateToNodeDetails = navigateToNodeDetails, @@ -229,6 +270,11 @@ class MainActivity : ComponentActivity() { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { Logger.d { "USB device attached" } + // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared + // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository + // never sees this event. Forward it explicitly so the serialDevices StateFlow + // refreshes and the device shows up in the Connect → Serial tab. + usbRepository.refreshState() showSettingsPage() } @@ -250,7 +296,7 @@ class MainActivity : ComponentActivity() { private fun handleMeshtasticUri(uri: Uri) { Logger.d { "Handling Meshtastic URI: $uri" } - model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } + model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } } private fun createShareIntent(message: String): PendingIntent { diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index d32cc3df6..9228b6874 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -28,6 +28,7 @@ import androidx.work.WorkManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first @@ -36,9 +37,8 @@ import kotlinx.coroutines.withTimeout import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.koin.workManagerFactory -import org.koin.core.context.startKoin -import org.meshtastic.app.di.AppKoinModule -import org.meshtastic.app.di.module +import org.koin.plugin.module.dsl.startKoin +import org.meshtastic.app.di.AndroidKoinApp import org.meshtastic.core.common.ContextServices import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.repository.MeshPrefs @@ -57,16 +57,15 @@ open class MeshUtilApplication : Application(), Configuration.Provider { - private val applicationScope = CoroutineScope(Dispatchers.Default) + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() ContextServices.app = this - startKoin { + startKoin { androidContext(this@MeshUtilApplication) workManagerFactory() - modules(AppKoinModule().module()) } // Schedule periodic MeshLog cleanup diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt similarity index 67% rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt rename to app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt index 7ca9f9fe8..04f0350c8 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt @@ -14,16 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.common.util +package org.meshtastic.app.di -import kotlin.test.Test -import kotlin.test.assertEquals +import org.koin.core.annotation.KoinApplication -class MeshtasticUriTest { - @Test - fun testParseAndToString() { - val uriString = "content://com.example.provider/file.txt" - val uri = MeshtasticUri.parse(uriString) - assertEquals(uriString, uri.toString()) - } -} +/** + * Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when + * [org.koin.plugin.module.dsl.startKoin] is called with this type parameter. + */ +@KoinApplication(modules = [AppKoinModule::class]) +object AndroidKoinApp diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index fe9989f68..91ab81ec0 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -24,6 +24,8 @@ import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.memoryCacheMaxSizePercentWhileInBackground +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder @@ -31,18 +33,25 @@ import coil3.util.DebugLogger import coil3.util.Logger import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import okio.Path.Companion.toOkioPath import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.network.HttpClientDefaults +import org.meshtastic.core.network.KermitHttpLogger private const val DISK_CACHE_PERCENT = 0.02 private const val MEMORY_CACHE_PERCENT = 0.25 +private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1 @Module class NetworkModule { @@ -63,7 +72,12 @@ class NetworkModule { buildConfigProvider: BuildConfigProvider, ): ImageLoader = ImageLoader.Builder(context = application) .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) + add( + KtorNetworkFetcherFactory( + httpClient = httpClient, + concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), + ), + ) add(SvgDecoder.Factory(scaleToDensity = true)) } .memoryCache { @@ -76,21 +90,29 @@ class NetworkModule { .build() } .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null) + .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT) .crossfade(enable = true) .build() - @Single - fun provideJson(): Json = Json { - isLenient = true - ignoreUnknownKeys = true - } - @Single 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) { level = LogLevel.BODY } + install(plugin = Logging) { + logger = KermitHttpLogger + level = LogLevel.BODY + } } } } diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 19c6c9ddf..1e5b68ab0 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -32,7 +32,7 @@ import co.touchlab.kermit.Logger import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_too_old @@ -53,7 +53,7 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @Composable fun MainScreen() { val viewModel: UIViewModel = koinViewModel() - val multiBackstack = rememberMultiBackstack(NodesRoutes.NodesGraph) + val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph) val backStack = multiBackstack.activeBackStack AndroidAppVersionCheck(viewModel) diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 7b140cca8..30e1b6be7 100644 --- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -25,6 +25,7 @@ import androidx.work.WorkerParameters import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import kotlinx.coroutines.CoroutineDispatcher +import org.koin.plugin.module.dsl.koinApplication import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify @@ -60,4 +61,19 @@ class KoinVerificationTest { ), ) } + + @Test + fun verifyTypedBootstrapLoadsModuleGraph() { + // koinApplication() is a K2 compiler plugin stub. If the plugin fails to + // transform it, the stub throws NotImplementedError at runtime. This test + // validates that the production bootstrap path is correctly transformed by + // successfully creating and closing the generated Koin application. + val app = koinApplication() + try { + // No-op: reaching this point proves the typed bootstrap path did not + // throw and the generated application could be created. + } finally { + app.close() + } + } } diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 8f262c47c..37c19f477 100644 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.app.service -import android.app.Notification import dev.mokkery.MockMode import dev.mokkery.mock import org.meshtastic.core.model.Node @@ -37,7 +36,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 ac082ffa3..de6062d33 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -16,15 +16,15 @@ */ package org.meshtastic.app.ui -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest 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.NodesRoutes +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph @@ -35,16 +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() { - composeTestRule.setContent { - val backStack = rememberNavBackStack(NodesRoutes.NodesGraph) + fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest { + setContent { + val backStack = rememberNavBackStack(NodesRoute.NodesGraph) entryProvider { contactsGraph(backStack, emptyFlow()) nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow()) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index faaeb9f68..71823c763 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -54,7 +54,6 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.androidx.room.gradlePlugin) - compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt index edf2d794a..16166a776 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt @@ -17,15 +17,18 @@ 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.InstrumentationMode +import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask + import com.datadog.gradle.plugin.SdkCheckLevel import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.withType import org.meshtastic.buildlogic.libs import org.meshtastic.buildlogic.plugin +import java.io.File /** * Convention plugin for analytics (Google Services, Crashlytics, Datadog). Segregates these plugins to only affect the @@ -65,18 +68,38 @@ class AnalyticsConventionPlugin : Plugin { } } + // Disable Datadog analytics/upload tasks for fdroid, but NOT the buildId + // inject/generate tasks. The Datadog plugin wires InjectBuildIdToAssetsTask via + // variant.artifacts.toTransform(SingleArtifact.ASSETS), which replaces the merged + // assets artifact for the entire variant. Disabling that task leaves its output + // directory empty, causing compressAssets to produce zero files and stripping ALL + // assets (including Compose Multiplatform .cvr resources) from the release APK. plugins.withId("com.datadoghq.dd-sdk-android-gradle-plugin") { tasks.configureEach { if ( ( name.contains("datadog", ignoreCase = true) || - name.contains("uploadMapping", ignoreCase = true) || - name.contains("buildId", ignoreCase = true) + name.contains("uploadMapping", ignoreCase = true) ) && name.contains("fdroid", ignoreCase = true) ) { enabled = false } } + + // The inject task must stay enabled to maintain the AGP artifact pipeline, + // but we strip the datadog.buildId file from its output to preserve fdroid + // sterility — no analytics artifacts should ship in the open-source flavor. + tasks.withType().configureEach { + if (name.contains("Fdroid", ignoreCase = true)) { + doLast { + // Constant: GenerateBuildIdTask.BUILD_ID_FILE_NAME + val buildIdFile = File(outputAssets.get().asFile, "datadog.buildId") + if (buildIdFile.exists()) { + buildIdFile.delete() + } + } + } + } } // Configure variant-specific extensions. @@ -87,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin { variants { register(variant.name) { site = "US5" - composeInstrumentation = InstrumentationMode.AUTO + } } checkProjectDependencies = SdkCheckLevel.NONE diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index fd432a1fa..38cc021a7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -26,7 +25,6 @@ import org.meshtastic.buildlogic.configureTestOptions class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - apply(plugin = "com.android.application") apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") @@ -38,16 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - - defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables.useSupportLibrary = true - } - testOptions { - animationsDisabled = true - unitTests.isReturnDefaultValues = true - } + defaultConfig { vectorDrawables.useSupportLibrary = true } buildTypes { getByName("release") { @@ -55,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + rootProject.file("config/proguard/shared-rules.pro"), + "proguard-rules.pro", ) } getByName("debug") { @@ -67,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin { } } - buildFeatures { - buildConfig = true - } + buildFeatures { buildConfig = true } } configureTestOptions() } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index cf3ae81db..68771d24a 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -38,11 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testOptions { - animationsDisabled = true - unitTests.isReturnDefaultValues = true - } defaultConfig { // When flavorless modules depend on flavored modules (like :core:data), diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 4bc4cb927..be280f29c 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -42,8 +42,8 @@ class KmpFeatureConventionPlugin : Plugin { extensions.configure { sourceSets.getByName("commonMain").dependencies { // Compose Multiplatform UI + implementation(libs.library("compose-multiplatform-animation")) implementation(libs.library("compose-multiplatform-material3")) - implementation(libs.library("compose-multiplatform-materialIconsExtended")) // Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain) implementation(libs.library("jetbrains-lifecycle-viewmodel-compose")) @@ -54,19 +54,18 @@ class KmpFeatureConventionPlugin : Plugin { // Logging implementation(libs.library("kermit")) + + // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview) + // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11 + implementation(libs.library("compose-multiplatform-ui-tooling-preview")) } sourceSets.getByName("androidMain").dependencies { - // 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("androidx-compose-material-iconsExtended")) - implementation(libs.library("androidx-compose-ui-text")) - implementation(libs.library("androidx-compose-ui-tooling-preview")) + + implementation(libs.library("compose-multiplatform-ui")) } sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt index 2a9504221..67b2c8fd0 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt @@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin { apply(plugin = libs.plugin("compose-multiplatform").get().pluginId) extensions.configure { - sourceSets.getByName("commonMain").dependencies { - implementation(libs.library("compose-multiplatform-runtime")) - // API because consuming modules will usually need the resource types - api(libs.library("compose-multiplatform-resources")) + sourceSets.matching { it.name == "commonMain" }.configureEach { + dependencies { + implementation(libs.library("compose-multiplatform-runtime")) + // API because consuming modules will usually need the resource types + api(libs.library("compose-multiplatform-resources")) + } } } configureComposeCompiler() diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index a1a111a64..540834ef5 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -14,11 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import dev.mokkery.gradle.MokkeryGradleExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback import org.meshtastic.buildlogic.configureKmpTestDependencies import org.meshtastic.buildlogic.configureKotlinMultiplatform @@ -39,8 +37,6 @@ class KmpLibraryConventionPlugin : Plugin { apply(plugin = "org.gradle.test-retry") apply(plugin = libs.plugin("mokkery").get().pluginId) - extensions.configure { stubs.allowConcreteClassInstantiation.set(true) } - configureKotlinMultiplatform() configureKmpTestDependencies() configureTestOptions() diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index 9b832ce16..b4f2acfbe 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -29,11 +29,12 @@ class KoinConventionPlugin : Plugin { // Configure Koin K2 Compiler Plugin (0.4.0+) extensions.configure(KoinGradleExtension::class.java) { - // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1 - // per-module safety checks strictly enforce that all dependencies must be explicitly - // provided or included locally. This breaks decoupled Clean Architecture designs. - // We disable compile safety globally to properly rely on Koin's A3 full-graph - // validation which perfectly handles inverted dependencies at the composition root. + // Meshtastic uses dependency inversion across KMP modules — interfaces in + // commonMain, implementations wired at the composition root. Koin's compileSafety + // flag enables A1 per-module checks that treat every module as self-contained, + // which breaks this pattern. There is no separate flag for A3 full-graph + // validation. Until Koin exposes granular safety levels we keep this disabled; + // runtime graph verification is handled by KoinVerificationTest instead. compileSafety.set(false) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index 40cbe83fa..b438fe6c6 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,18 +24,46 @@ 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 { - 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")) + "debugImplementation"(libs.library("compose-multiplatform-ui-tooling")) + "implementation"(libs.library("compose-multiplatform-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 580db4c4b..088ca0d25 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { compileSdk = compileSdkVersion defaultConfig.minSdk = minSdkVersion + defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (this is ApplicationExtension) { defaultConfig.targetSdk = targetSdkVersion } - val javaVersion = if (project.name in listOf("api", "model", "proto")) { - JavaVersion.VERSION_17 - } else { - JavaVersion.VERSION_21 - } + val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21 compileOptions.sourceCompatibility = javaVersion compileOptions.targetCompatibility = javaVersion + testOptions.animationsDisabled = true + testOptions.unitTests.isReturnDefaultValues = true + // Exclude duplicate META-INF license files shipped by JUnit Platform JARs packaging.resources.excludes.addAll( listOf( @@ -72,6 +72,23 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { /** Configure Kotlin Multiplatform options */ internal fun Project.configureKotlinMultiplatform() { + // Skiko is an internal CMP implementation detail; third-party KMP libraries + // (e.g. coil3) can carry an older skiko transitive requirement that Gradle + // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' + // versions are incompatible" warning from CMP's compatibility checker. + // Force the version to match CMP so the checker sees a consistent graph. + // Pinned here rather than in the version catalog because this plugin is the + // only consumer — bump together with the compose-multiplatform version. + val skikoVersion = "0.144.5" + configurations.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "org.jetbrains.skiko") { + useVersion(skikoVersion) + because("Align Skiko with the version bundled by Compose Multiplatform") + } + } + } + extensions.configure { // Standard KMP targets for Meshtastic jvm() @@ -190,11 +207,25 @@ internal fun Project.configureKotlinJvm() { configureKotlin() } +/** Modules published for external consumers — use Java 17 for broader compatibility. */ +private val PUBLISHED_MODULES = setOf("api", "model", "proto") + +/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */ +private val SHARED_COMPILER_ARGS = listOf( + "-opt-in=kotlin.uuid.ExperimentalUuidApi", + "-opt-in=kotlin.time.ExperimentalTime", + "-Xexpect-actual-classes", + "-Xcontext-parameters", + "-Xannotation-default-target=param-property", + "-Xskip-prerelease-check", +) + /** Configure base Kotlin options */ private inline fun Project.configureKotlin() { + val isPublishedModule = project.name in PUBLISHED_MODULES + extensions.configure { - val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21 - val isPublishedModule = project.name in listOf("api", "model", "proto") + val javaVersion = if (isPublishedModule) 17 else 21 // Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments), // and Java 21 for the rest of the app. jvmToolchain(javaVersion) @@ -208,14 +239,7 @@ private inline fun Project.configureKotlin() { if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll( - "-opt-in=kotlin.uuid.ExperimentalUuidApi", - "-opt-in=kotlin.time.ExperimentalTime", - "-Xexpect-actual-classes", - "-Xcontext-parameters", - "-Xannotation-default-target=param-property", - "-Xskip-prerelease-check", - ) + freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) if (isJvmTarget) { freeCompilerArgs.add("-jvm-default=no-compatibility") } @@ -230,21 +254,13 @@ private inline fun Project.configureKotlin() { tasks.withType().configureEach { compilerOptions { - val isPublishedModule = project.name in listOf("api", "model", "proto") jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21) allWarningsAsErrors.set(warningsAsErrors) if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll( - "-opt-in=kotlin.uuid.ExperimentalUuidApi", - "-opt-in=kotlin.time.ExperimentalTime", - "-Xexpect-actual-classes", - "-Xcontext-parameters", - "-Xannotation-default-target=param-property", - "-Xskip-prerelease-check", - "-jvm-default=no-compatibility", - ) + freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) + freeCompilerArgs.add("-jvm-default=no-compatibility") } } } diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 2fa797c74..91b8ebce2 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -30,7 +30,7 @@ pluginManagement { } plugins { - id("com.gradle.develocity") version("4.4.0") + id("com.gradle.develocity") version("4.4.1") } dependencyResolutionManagement { diff --git a/codecov.yml b/codecov.yml index 6e0989227..7f77510ff 100644 --- a/codecov.yml +++ b/codecov.yml @@ -57,10 +57,6 @@ component_management: name: Desktop paths: - desktop/** - - component_id: example - name: Example - paths: - - mesh_service_example/** ignore: - "**/build/**" diff --git a/conductor/code_styleguides/general.md b/conductor/code_styleguides/general.md deleted file mode 100644 index dfcc793f4..000000000 --- a/conductor/code_styleguides/general.md +++ /dev/null @@ -1,23 +0,0 @@ -# General Code Style Principles - -This document outlines general coding principles that apply across all languages and frameworks used in this project. - -## Readability -- Code should be easy to read and understand by humans. -- Avoid overly clever or obscure constructs. - -## Consistency -- Follow existing patterns in the codebase. -- Maintain consistent formatting, naming, and structure. - -## Simplicity -- Prefer simple solutions over complex ones. -- Break down complex problems into smaller, manageable parts. - -## Maintainability -- Write code that is easy to modify and extend. -- Minimize dependencies and coupling. - -## Documentation -- Document *why* something is done, not just *what*. -- Keep documentation up-to-date with code changes. diff --git a/conductor/index.md b/conductor/index.md deleted file mode 100644 index 3a362bc99..000000000 --- a/conductor/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Project Context - -## Definition -- [Product Definition](./product.md) -- [Product Guidelines](./product-guidelines.md) -- [Tech Stack](./tech-stack.md) - -## Workflow -- [Workflow](./workflow.md) -- [Code Style Guides](./code_styleguides/) - -## Management -- [Tracks Registry](./tracks.md) -- [Tracks Directory](./tracks/) \ No newline at end of file diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md deleted file mode 100644 index b54944fea..000000000 --- a/conductor/product-guidelines.md +++ /dev/null @@ -1,19 +0,0 @@ -# Product Guidelines - -## Brand Voice and Tone -- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic. -- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety. -- **Community-Oriented:** Encourage open-source participation and community support. - -## UX Principles -- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network. -- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles. -- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure. - -## Prose Style -- **Clarity over cleverness:** Use plain English. -- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export"). -- **Consistent Terminology:** - - Use "Node" for devices on the network. - - Use "Channel" for communication groups. - - Use "Direct Message" for 1-to-1 communication. \ No newline at end of file diff --git a/conductor/product.md b/conductor/product.md deleted file mode 100644 index edfac5083..000000000 --- a/conductor/product.md +++ /dev/null @@ -1,26 +0,0 @@ -# Initial Concept -A tool for using Android with open-source mesh radios. - -# Product Guide - -## Overview -Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios. - -## Target Audience -- Off-grid communication enthusiasts and hobbyists -- Outdoor adventurers needing reliable communication without cellular networks -- Emergency response and disaster relief teams - -## Core Features -- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT) -- Decentralized text messaging across the mesh network -- Unified cross-platform notifications for messages and node events -- Adaptive node and contact management -- Offline map rendering and device positioning -- Device configuration and firmware updates -- Unified cross-platform debugging and packet inspection - -## Key Architecture Goals -- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS) -- Ensure offline-first functionality and resilient data persistence (Room 3 KMP) -- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md deleted file mode 100644 index 75237887b..000000000 --- a/conductor/tech-stack.md +++ /dev/null @@ -1,38 +0,0 @@ -# Tech Stack - -## Programming Language -- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`. - -## Frontend Frameworks -- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop. -- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android. - -## Background & Services -- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary. - -## Architecture -- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`. -- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module. - -## Dependency Injection -- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt. - -## Database & Storage -- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android). -- **Jetpack DataStore:** Shared preferences. - -## Networking & Transport -- **Ktor:** Multiplatform HTTP client for web services and TCP streaming. -- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS). -- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target. -- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library. -- **Coroutines & Flows:** For asynchronous programming and state management. - -## Testing (KMP) -- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`. -- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows. -- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`. -- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`). -- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`). -- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates. -- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios. \ No newline at end of file diff --git a/conductor/tracks.md b/conductor/tracks.md deleted file mode 100644 index 0b5c54e3d..000000000 --- a/conductor/tracks.md +++ /dev/null @@ -1,5 +0,0 @@ -# Project Tracks - -This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. - ---- diff --git a/conductor/workflow.md b/conductor/workflow.md deleted file mode 100644 index 6f9cfd8fc..000000000 --- a/conductor/workflow.md +++ /dev/null @@ -1,333 +0,0 @@ -# Project Workflow - -## Guiding Principles - -1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md` -2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation -3. **Test-Driven Development:** Write unit tests before implementing functionality -4. **High Code Coverage:** Aim for >80% code coverage for all modules -5. **User Experience First:** Every decision should prioritize user experience -6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution. - -## Task Workflow - -All tasks follow a strict lifecycle: - -### Standard Task Workflow - -1. **Select Task:** Choose the next available task from `plan.md` in sequential order - -2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]` - -3. **Write Failing Tests (Red Phase):** - - Create a new test file for the feature or bug fix. - - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task. - - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests. - -4. **Implement to Pass Tests (Green Phase):** - - Write the minimum amount of application code necessary to make the failing tests pass. - - Run the test suite again and confirm that all tests now pass. This is the "Green" phase. - -5. **Refactor (Optional but Recommended):** - - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior. - - Rerun tests to ensure they still pass after refactoring. - -6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like: - ```bash - pytest --cov=app --cov-report=html - ``` - Target: >80% coverage for new code. The specific tools and commands will vary by language and framework. - -7. **Document Deviations:** If implementation differs from tech stack: - - **STOP** implementation - - Update `tech-stack.md` with new design - - Add dated note explaining the change - - Resume implementation - -8. **Commit Code Changes:** - - Stage all code changes related to the task. - - Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`. - - Perform the commit. - -9. **Attach Task Summary with Git Notes:** - - **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`). - - **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change. - - **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit. - ```bash - # The note content from the previous step is passed via the -m flag. - git notes add -m "" - ``` - -10. **Get and Record Task Commit SHA:** - - **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash. - - **Step 10.2: Write Plan:** Write the updated content back to `plan.md`. - -11. **Commit Plan Update:** - - **Action:** Stage the modified `plan.md` file. - - **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`). - -### Phase Completion Verification and Checkpointing Protocol - -**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`. - -1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun. - -2. **Ensure Test Coverage for Phase Changes:** - - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit. - - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase. - - **Step 2.3: Verify and Create Tests:** For each file in the list: - - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`). - - For each remaining code file, verify a corresponding test file exists. - - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`). - -3. **Execute Automated Tests with Proactive Debugging:** - - Before execution, you **must** announce the exact shell command you will use to run the tests. - - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`" - - Execute the announced command. - - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance. - -4. **Propose a Detailed, Actionable Manual Verification Plan:** - - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase. - - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes. - - The plan you present to the user **must** follow this format: - - **For a Frontend Change:** - ``` - The automated tests have passed. For manual verification, please follow these steps: - - **Manual Verification Steps:** - 1. **Start the development server with the command:** `npm run dev` - 2. **Open your browser to:** `http://localhost:3000` - 3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly. - ``` - - **For a Backend Change:** - ``` - The automated tests have passed. For manual verification, please follow these steps: - - **Manual Verification Steps:** - 1. **Ensure the server is running.** - 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'` - 3. **Confirm that you receive:** A JSON response with a status of `201 Created`. - ``` - -5. **Await Explicit User Feedback:** - - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**" - - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation. - -6. **Create Checkpoint Commit:** - - Stage all changes. If no changes occurred in this step, proceed with an empty commit. - - Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`). - -7. **Attach Auditable Verification Report using Git Notes:** - - **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation. - - **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit. - -8. **Get and Record Phase Checkpoint SHA:** - - **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`). - - **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`. - - **Step 8.3: Write Plan:** Write the updated content back to `plan.md`. - -9. **Commit Plan Update:** - - **Action:** Stage the modified `plan.md` file. - - **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '' as complete`. - -10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note. - -### Quality Gates - -Before marking any task complete, verify: - -- [ ] All tests pass -- [ ] Code coverage meets requirements (>80%) -- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`) -- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc) -- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types) -- [ ] No linting or static analysis errors (using the project's configured tools) -- [ ] Works correctly on mobile (if applicable) -- [ ] Documentation updated if needed -- [ ] No security vulnerabilities introduced - -## Development Commands - -**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.** - -### Setup -```bash -# Example: Commands to set up the development environment (e.g., install dependencies, configure database) -# e.g., for a Node.js project: npm install -# e.g., for a Go project: go mod tidy -``` - -### Daily Development -```bash -# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format) -# e.g., for a Node.js project: npm run dev, npm test, npm run lint -# e.g., for a Go project: go run main.go, go test ./..., go fmt ./... -``` - -### Before Committing -```bash -# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests) -# e.g., for a Node.js project: npm run check -# e.g., for a Go project: make check (if a Makefile exists) -``` - -## Testing Requirements - -### Unit Testing -- Every module must have corresponding tests. -- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach). -- Mock external dependencies. -- Test both success and failure cases. - -### Integration Testing -- Test complete user flows -- Verify database transactions -- Test authentication and authorization -- Check form submissions - -### Mobile Testing -- Test on actual iPhone when possible -- Use Safari developer tools -- Test touch interactions -- Verify responsive layouts -- Check performance on 3G/4G - -## Code Review Process - -### Self-Review Checklist -Before requesting review: - -1. **Functionality** - - Feature works as specified - - Edge cases handled - - Error messages are user-friendly - -2. **Code Quality** - - Follows style guide - - DRY principle applied - - Clear variable/function names - - Appropriate comments - -3. **Testing** - - Unit tests comprehensive - - Integration tests pass - - Coverage adequate (>80%) - -4. **Security** - - No hardcoded secrets - - Input validation present - - SQL injection prevented - - XSS protection in place - -5. **Performance** - - Database queries optimized - - Images optimized - - Caching implemented where needed - -6. **Mobile Experience** - - Touch targets adequate (44x44px) - - Text readable without zooming - - Performance acceptable on mobile - - Interactions feel native - -## Commit Guidelines - -### Message Format -``` -(): - -[optional body] - -[optional footer] -``` - -### Types -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation only -- `style`: Formatting, missing semicolons, etc. -- `refactor`: Code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests -- `chore`: Maintenance tasks - -### Examples -```bash -git commit -m "feat(auth): Add remember me functionality" -git commit -m "fix(posts): Correct excerpt generation for short posts" -git commit -m "test(comments): Add tests for emoji reaction limits" -git commit -m "style(mobile): Improve button touch targets" -``` - -## Definition of Done - -A task is complete when: - -1. All code implemented to specification -2. Unit tests written and passing -3. Code coverage meets project requirements -4. Documentation complete (if applicable) -5. Code passes all configured linting and static analysis checks -6. Works beautifully on mobile (if applicable) -7. Implementation notes added to `plan.md` -8. Changes committed with proper message -9. Git note with task summary attached to the commit - -## Emergency Procedures - -### Critical Bug in Production -1. Create hotfix branch from main -2. Write failing test for bug -3. Implement minimal fix -4. Test thoroughly including mobile -5. Deploy immediately -6. Document in plan.md - -### Data Loss -1. Stop all write operations -2. Restore from latest backup -3. Verify data integrity -4. Document incident -5. Update backup procedures - -### Security Breach -1. Rotate all secrets immediately -2. Review access logs -3. Patch vulnerability -4. Notify affected users (if any) -5. Document and update security procedures - -## Deployment Workflow - -### Pre-Deployment Checklist -- [ ] All tests passing -- [ ] Coverage >80% -- [ ] No linting errors -- [ ] Mobile testing complete -- [ ] Environment variables configured -- [ ] Database migrations ready -- [ ] Backup created - -### Deployment Steps -1. Merge feature branch to main -2. Tag release with version -3. Push to deployment service -4. Run database migrations -5. Verify deployment -6. Test critical paths -7. Monitor for errors - -### Post-Deployment -1. Monitor analytics -2. Check error logs -3. Gather user feedback -4. Plan next iteration - -## Continuous Improvement - -- Review workflow weekly -- Update based on pain points -- Document lessons learned -- Optimize for user happiness -- Keep things simple and maintainable diff --git a/config.properties b/config.properties index 1bb8534cd..de820bc85 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=36 -COMPILE_SDK=36 +TARGET_SDK=37 +COMPILE_SDK=37 # 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 new file mode 100644 index 000000000..8d0d8efde --- /dev/null +++ b/config/proguard/shared-rules.pro @@ -0,0 +1,166 @@ +# ============================================================================ +# Meshtastic — Shared ProGuard / R8 rules +# ============================================================================ +# Cross-platform keep and dontwarn rules applied to BOTH the Android app +# release build (R8) and the Desktop distribution (ProGuard). Host-specific +# rules live in the per-module proguard-rules.pro file. +# +# Rule of thumb: anything describing a library shared between Android and +# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable, +# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries, +# Markdown renderer, QRCode, Compose Multiplatform resources, core modules) +# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android +# framework, JDK-version quirks, flavor specifics) stays in the host file. +# ============================================================================ + +# ---- Attributes ------------------------------------------------------------- + +# Preserve line numbers for meaningful stack traces, plus metadata needed for +# reflective serializer/DI/Room lookups. +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations + +# ---- Kotlin / Coroutines ---------------------------------------------------- +# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules +# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep +# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No +# explicit wildcards needed here. + +# ---- Koin DI (reflection-based injection) ----------------------------------- + +# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException +# replacing Koin's InstanceCreationException in stack traces, making crashes +# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph. +-keep class org.koin.** { *; } +-dontwarn org.koin.** + +# Keep Koin-annotated modules/components so Koin Annotations (KSP) output +# survives tree-shaking. +-keep @org.koin.core.annotation.Module class * { *; } +-keep @org.koin.core.annotation.ComponentScan class * { *; } +-keep @org.koin.core.annotation.Single class * { *; } +-keep @org.koin.core.annotation.Factory class * { *; } +-keep @org.koin.core.annotation.KoinViewModel class * { *; } + +# ---- kotlinx-serialization -------------------------------------------------- + +-keep class kotlinx.serialization.** { *; } +-dontwarn kotlinx.serialization.** + +# Keep @Serializable classes and their generated $serializer companions +-keepclassmembers @kotlinx.serialization.Serializable class ** { + static ** Companion; + kotlinx.serialization.KSerializer serializer(...); +} +-keep class **.$serializer { *; } +-keepclassmembers class **.$serializer { *; } +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# ---- Wire Protobuf ---------------------------------------------------------- + +# Wire generates an ADAPTER static field on every Message subclass accessed +# reflectively during encoding/decoding. Keep those fields and the +# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve +# the runtime itself. +-keepclassmembers class * extends com.squareup.wire.Message { + public static *** ADAPTER; +} +-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; } + +# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs +# when compiling for non-Android JVM targets; harmless on Android). +-dontwarn android.os.Parcel** +-dontwarn android.os.Parcelable** + +# ---- Room KMP (room3) ------------------------------------------------------- + +# Preserve generated database constructors (Room uses reflection to instantiate) +-keep class * extends androidx.room3.RoomDatabase { (); } +-keep class * implements androidx.room3.RoomDatabaseConstructor { *; } + +# Keep the expect/actual MeshtasticDatabaseConstructor + database surface +-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } +-keep class org.meshtastic.core.database.MeshtasticDatabase { *; } + +# Room's own consumer rules (from androidx.room3) keep DAOs, entities, +# generated _Impl classes, and TypeConverters referenced from the database. + +# ---- SQLite bundled -------------------------------------------------------- +# androidx.sqlite ships consumer rules. + +# ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- + +# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory +# implementations reflectively via ServiceLoader). +-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } + +# ---- Coil 3 (image loading) ------------------------------------------------- +# coil3 ships consumer rules. + +# ---- Kable BLE -------------------------------------------------------------- +# com.juul.kable ships consumer rules; if release builds fail with missing +# Kable classes, restore a narrow keep for the specific reflection-loaded type. + +# ---- Compose Multiplatform resources ---------------------------------------- + +# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.). +# Without these the fdroid flavor has crashed at startup with a misleading +# URLDecodeException due to R8 exception-class merging. +-keep class org.jetbrains.compose.resources.** { *; } +-keep class org.meshtastic.core.resources.Res { *; } +-keepclassmembers class org.meshtastic.core.resources.Res$* { *; } + +# ---- AboutLibraries --------------------------------------------------------- +# com.mikepenz.aboutlibraries ships consumer rules. + +# ---- Multiplatform Markdown Renderer ---------------------------------------- +# com.mikepenz.markdown ships consumer rules. + +# ---- QR Code Kotlin --------------------------------------------------------- + +-keep class io.github.g0dkar.qrcode.** { *; } +-dontwarn io.github.g0dkar.qrcode.** +-keep class qrcode.** { *; } +-dontwarn qrcode.** + +# ---- Kermit logging --------------------------------------------------------- +# co.touchlab.kermit ships consumer rules. + +# ---- Okio ------------------------------------------------------------------- +# okio ships consumer rules. + +# ---- DataStore -------------------------------------------------------------- +# androidx.datastore ships consumer rules. + +# ---- Paging ----------------------------------------------------------------- +# androidx.paging ships consumer rules. + +# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- +# androidx.lifecycle and androidx.navigation3 ship consumer rules. + +# ---- Meshtastic shared model ------------------------------------------------ +# core.model types are reached via static references from Koin-wired graphs, +# Room entities, and kotlinx-serialization @Serializable companions — all of +# which have their own keep rules above. + +# ---- Compose Runtime & Animation -------------------------------------------- + +# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that +# are referenced indirectly through compiler-generated state machines. Applies +# to BOTH R8 (Android app) and ProGuard (desktop distribution). +# +# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() / ComposerImpl.() and -assumevalues on +# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full +# mode on Android, ProGuard with optimize.set(true) on desktop) these call +# sites can be rewritten even when the target classes are kept, causing the +# recomposer / frame-clock / animation state machines to silently freeze on +# the first frame. -dontoptimize (set per-host) is the primary defence; these +# keep rules are a safety net against future toolchain changes. See #5146. +-keep class androidx.compose.runtime.** { *; } +-keep class androidx.compose.ui.** { *; } +-keep class androidx.compose.animation.core.** { *; } +-keep class androidx.compose.animation.** { *; } +-keep class androidx.compose.foundation.** { *; } +-keep class androidx.compose.material3.** { *; } diff --git a/core/api/build.gradle.kts b/core/api/build.gradle.kts index 94d10fdd9..dd3f65acf 100644 --- a/core/api/build.gradle.kts +++ b/core/api/build.gradle.kts @@ -33,6 +33,10 @@ configure { publishing { singleVariant("release") { withSourcesJar() } } } +// Suppress dep-ann warnings from AIDL-generated code where Javadoc @deprecated +// doesn't produce @Deprecated annotations on Stub/Proxy override methods. +tasks.withType().configureEach { options.compilerArgs.add("-Xlint:-dep-ann") } + // Map the Android component to a Maven publication afterEvaluate { publishing { diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index a03b02a0f..711cccc09 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -33,10 +33,9 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.iconsExtended) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.ui) + implementation(libs.compose.multiplatform.material3) + implementation(libs.compose.multiplatform.runtime) + implementation(libs.compose.multiplatform.ui) implementation(libs.accompanist.permissions) implementation(libs.kermit) @@ -53,9 +52,6 @@ dependencies { testImplementation(libs.junit) testRuntimeOnly(libs.junit.vintage.engine) testImplementation(libs.robolectric) - testImplementation(libs.androidx.compose.ui.test.junit4) - - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.compose.multiplatform.ui.test) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt deleted file mode 100644 index 6e36ca79a..000000000 --- a/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt +++ /dev/null @@ -1,29 +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.barcode - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class BarcodeScannerTest { - @Test - fun placeholder() { - // Placeholder for AndroidTest - } -} diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt index 5c266b544..fae85eba5 100644 --- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt +++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt @@ -29,8 +29,6 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -61,6 +59,8 @@ import com.google.accompanist.permissions.rememberPermissionState import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.close +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.BarcodeScanner import java.util.concurrent.Executors @@ -116,7 +116,7 @@ private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> U } IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) { Icon( - imageVector = Icons.Default.Close, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close), tint = Color.White, ) 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 bd3490566..aa222b7c2 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,21 +16,17 @@ */ package org.meshtastic.core.barcode -import androidx.compose.ui.test.junit4.createComposeRule -import org.junit.Rule +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest 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 { - @get:Rule val composeTestRule = createComposeRule() - - @Test - fun testRememberBarcodeScanner() { - composeTestRule.setContent { rememberBarcodeScanner { _ -> } } - } + @Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } } } diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index bdf449f49..f270e6aa3 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -47,15 +47,8 @@ kotlin { } commonTest.dependencies { - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) - } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.androidx.lifecycle.testing) - } + implementation(projects.core.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 c8d444688..b330453e1 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,6 +31,7 @@ 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 @@ -49,7 +50,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() } @@ -86,7 +87,7 @@ class AndroidBluetoothRepository( return } - kotlinx.coroutines.suspendCancellableCoroutine { cont -> + suspendCancellableCoroutine { cont -> val receiver = object : android.content.BroadcastReceiver() { @SuppressLint("MissingPermission") @@ -180,14 +181,15 @@ class AndroidBluetoothRepository( // user renamed the device in firmware since the cache was populated. deviceCache.keys.retainAll(bondedAddresses) return bonded.map { device -> - 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) - } - } + 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 + } } } 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 e9928f8d5..b0617635a 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,15 +20,29 @@ 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) { - // 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. + // 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. 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 004beec06..1ea11622d 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 @@ -17,12 +17,19 @@ 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. */ internal object ActiveBleConnection { - var activePeripheral: Peripheral? = null - var activeAddress: String? = null + @Volatile var active: ActiveConnection? = 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 06496aeea..59cf134de 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,6 +19,7 @@ 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 @@ -49,8 +50,8 @@ interface BleConnection { /** Connects to the given [BleDevice]. */ suspend fun connect(device: BleDevice) - /** Connects to the given [BleDevice] and waits for a terminal state. */ - suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState + /** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */ + suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState /** Disconnects from the current device. */ suspend fun disconnect() @@ -77,6 +78,17 @@ 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 a9f82c5f9..2026b0cb1 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,16 +17,53 @@ package org.meshtastic.core.ble /** Represents the state of a BLE connection. */ -sealed class BleConnectionState { - /** The peripheral is disconnected. */ - object Disconnected : BleConnectionState() +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 /** The peripheral is connecting. */ - object Connecting : BleConnectionState() + data object Connecting : BleConnectionState /** The peripheral is connected. */ - object Connected : BleConnectionState() + data object Connected : BleConnectionState /** The peripheral is disconnecting. */ - object Disconnecting : BleConnectionState() + 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 } 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 new file mode 100644 index 000000000..d273a0b90 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@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 c636d4718..5e85a52f8 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt @@ -48,9 +48,7 @@ suspend fun retryBleOperation( Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" } throw e } - Logger.w(e) { - "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..." - } + Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." } delay(delayMs) } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt deleted file mode 100644 index 9e32e4602..000000000 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt +++ /dev/null @@ -1,50 +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.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 31563aa80..f658d234c 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,9 +18,11 @@ 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 @@ -30,16 +32,18 @@ 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 import kotlinx.coroutines.flow.onEach +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. */ class KableBleService(private val peripheral: Peripheral, private val serviceUuid: Uuid) : BleService { override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean = peripheral.services.value?.any { svc -> svc.serviceUuid == serviceUuid && svc.characteristics.any { it.characteristicUuid == characteristic.uuid } @@ -48,6 +52,9 @@ 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)) @@ -73,12 +80,26 @@ 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. + */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { private var peripheral: Peripheral? = null private var stateJob: Job? = null 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 val _deviceFlow = MutableSharedFlow(replay = 1) override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() @@ -93,34 +114,32 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { ) override val connectionState: SharedFlow = _connectionState.asSharedFlow() + @Suppress("CyclomaticComplexMethod", "LongMethod") override suspend fun connect(device: BleDevice) { - val autoConnect = MutableStateFlow(device is DirectBleDevice) + 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 p = - 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}") - } + meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } } + ?: createPeripheral(device.address) { commonConfig() } - peripheral?.disconnect() - peripheral?.close() + cleanUpPeripheral(device.address) peripheral = p - ActiveBleConnection.activePeripheral = p - ActiveBleConnection.activeAddress = device.address + ActiveBleConnection.active = ActiveConnection(p, device.address) _deviceFlow.emit(device) @@ -134,57 +153,67 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { hasStartedConnecting = true } - when (device) { - is KableBleDevice -> device.updateState(mappedState) - is DirectBleDevice -> device.updateState(mappedState) - } + meshtasticDevice.updateState(mappedState) _connectionState.emit(mappedState) } .launchIn(scope) while (p.state.value !is State.Connected) { - autoConnect.value = + autoConnect = try { + connectionScope?.let { oldScope -> + Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" } + oldScope.coroutineContext.job.cancel() + } connectionScope = p.connect() false } catch (e: CancellationException) { throw e - } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) { - @Suppress("MagicNumber") - val retryDelayMs = 1000L - delay(retryDelayMs) + } 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 + } + Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" } + delay(AUTOCONNECT_FALLBACK_DELAY) true } } } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try { - withTimeout(timeoutMs) { + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try { + withTimeout(timeout) { connect(device) BleConnectionState.Connected } } catch (_: TimeoutCancellationException) { // Our own timeout expired — treat as a failed attempt so callers can retry. - BleConnectionState.Disconnected + BleConnectionState.Disconnected(DisconnectReason.Timeout) } catch (e: CancellationException) { // External cancellation (scope closed) — must propagate. throw e } catch (_: Exception) { - BleConnectionState.Disconnected + BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed) } 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)) + stateJob?.cancel() stateJob = null - peripheral?.disconnect() - peripheral?.close() + + safeClosePeripheral("disconnect") peripheral = null connectionScope = null - ActiveBleConnection.activePeripheral = null - ActiveBleConnection.activeAddress = null + ActiveBleConnection.active = null _deviceFlow.emit(null) } @@ -197,8 +226,33 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { val p = peripheral ?: error("Not connected") val cScope = connectionScope ?: error("No active connection scope") val service = KableBleService(p, serviceUuid) - return cScope.setup(service) + return withTimeout(timeout) { cScope.setup(service) } } 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 d0f3a7168..13b8a1663 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,5 +21,11 @@ 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/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index d9e27704f..5e91b3459 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,6 +17,7 @@ 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 @@ -28,6 +29,10 @@ 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 @@ -43,7 +48,15 @@ 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(KableBleDevice(advertisement)) } + scanner.advertisements.collect { advertisement -> + send( + MeshtasticBleDevice( + address = advertisement.identifier.toString(), + name = advertisement.name, + advertisement = 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 ed4df97d0..3f0e61864 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 @@ -16,93 +16,103 @@ */ 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. + */ 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) - // 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. + companion object { + private val TRANSIENT_RETRY_DELAY = 500.milliseconds + } + + private val subscriptionReady = CompletableDeferred() + + /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */ 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 { - 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) } + if (service.hasCharacteristic(fromNum)) { + service + .observe(fromNum) { + Logger.d { "FROMNUM CCCD written — notifications enabled" } + subscriptionReady.complete(Unit) } - } - // 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 (_: Exception) { - keepReading = false - } + .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 } + 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) } } } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - override val logRadio: Flow = channelFlow { - try { - if (service.hasCharacteristic(logRadioChar)) { - service.observe(logRadioChar).collect { send(it) } + 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. } - } catch (_: Exception) { - // logRadio is optional, ignore if not found + } else { + emptyFlow() } - } 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 7a03a3d89..4bd395dc5 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,14 +25,33 @@ 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? { - 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 - } - } +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) } 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 new file mode 100644 index 000000000..6884dc9e1 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt @@ -0,0 +1,51 @@ +/* + * 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 389516521..f69214187 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,8 +38,6 @@ 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/KableBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt similarity index 51% rename from core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt rename to core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt index 455779937..3342cf24f 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt @@ -17,32 +17,44 @@ 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 -class KableBleDevice(val advertisement: Advertisement) : BleDevice { - override val name: String? - get() = advertisement.name +/** + * 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 { - override val address: String - get() = advertisement.identifier.toString() - - private val _state = MutableStateFlow(BleConnectionState.Disconnected) - override val state: StateFlow = _state + private val _state = MutableStateFlow(BleConnectionState.Disconnected()) + override val state: StateFlow = _state.asStateFlow() // 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.activeAddress == address + get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address - @OptIn(com.juul.kable.ExperimentalApi::class) + @OptIn(ExperimentalApi::class) override suspend fun readRssi(): Int { - val peripheral = ActiveBleConnection.activePeripheral - return if (peripheral != null && ActiveBleConnection.activeAddress == address) { - peripheral.rssi() + val active = ActiveBleConnection.active + return if (active != null && active.address == address) { + active.peripheral.rssi() } else { - advertisement.rssi + advertisement?.rssi ?: 0 } } @@ -50,6 +62,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice { // 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/MeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt index d1a557a42..7a69e9524 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,4 +28,22 @@ 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 new file mode 100644 index 000000000..1170b973b --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt @@ -0,0 +1,67 @@ +/* + * 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 new file mode 100644 index 000000000..d947dd04d --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt @@ -0,0 +1,51 @@ +/* + * 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 new file mode 100644 index 000000000..64286fd70 --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.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.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 new file mode 100644 index 000000000..18c7be4da --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.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/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 33da61ff1..99ff6885c 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -27,5 +27,8 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral = com.juul.kable.Peripheral(address.toIdentifier(), builderAction) -// JVM/desktop Kable does not expose an MTU StateFlow; fall back to null so callers use their default. -internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null +// JVM/desktop Kable does not expose an MTU StateFlow; return a reasonable default (512) +// so callers can size their writes without falling back to an overly conservative minimum. +internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = DEFAULT_JVM_MTU + +private const val DEFAULT_JVM_MTU = 512 diff --git a/core/common/README.md b/core/common/README.md index da7700ac5..979586213 100644 --- a/core/common/README.md +++ b/core/common/README.md @@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers: - **Time**: Utilities for handling timestamps and durations. - **Exceptions**: Standardized exception types for common error scenarios. -### 2. `ByteUtils.kt` -Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets. +### 2. `MetricFormatter.kt` +Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces. ### 3. `BuildConfigProvider.kt` An interface for accessing build-time configuration in a multiplatform-friendly way. diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index f3e86f0c9..e4d94943e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -37,12 +37,11 @@ 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) } - val androidHostTest by getting { dependencies { implementation(libs.robolectric) } } - commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt b/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt deleted file mode 100644 index fc8c8d04e..000000000 --- a/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt +++ /dev/null @@ -1,49 +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 org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class CommonUriTest { - - @Test - fun testParse() { - val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment") - assertEquals("meshtastic.org", uri.host) - assertEquals("fragment", uri.fragment) - assertEquals(listOf("path", "to", "page"), uri.pathSegments) - assertEquals("value1", uri.getQueryParameter("param1")) - assertTrue(uri.getBooleanQueryParameter("param2", false)) - } - - @Test - fun testBooleanParameters() { - val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0") - assertTrue(uri.getBooleanQueryParameter("t1", false)) - assertTrue(uri.getBooleanQueryParameter("t2", false)) - assertTrue(uri.getBooleanQueryParameter("t3", false)) - assertTrue(!uri.getBooleanQueryParameter("f1", true)) - assertTrue(!uri.getBooleanQueryParameter("f2", true)) - } -} diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt index ad4629fba..92463c191 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt @@ -18,9 +18,7 @@ package org.meshtastic.core.common import android.Manifest import android.app.Application -import android.content.BroadcastReceiver import android.content.Context -import android.content.IntentFilter import android.content.pm.PackageManager import android.location.LocationManager import android.os.Build @@ -80,18 +78,3 @@ fun Context.hasLocationPermission(): Boolean { val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION) return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } } - -/** - * Extension for Context to register a BroadcastReceiver in a compatible way across Android versions. - * - * @param receiver The receiver to register. - * @param filter The intent filter. - * @param flag The export flag (defaults to [ContextCompat.RECEIVER_EXPORTED]). - */ -fun Context.registerReceiverCompat( - receiver: BroadcastReceiver, - filter: IntentFilter, - flag: Int = ContextCompat.RECEIVER_EXPORTED, -) { - ContextCompat.registerReceiver(this, receiver, filter, flag) -} diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt deleted file mode 100644 index a99bccd84..000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import android.net.Uri - -actual class CommonUri(private val uri: Uri) { - actual val host: String? - get() = uri.host - - actual val fragment: String? - get() = uri.fragment - - actual val pathSegments: List - get() = uri.pathSegments - - actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key) - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = - uri.getBooleanQueryParameter(key, defaultValue) - - actual override fun toString(): String = uri.toString() - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString)) - } - - fun toUri(): Uri = uri -} - -actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt deleted file mode 100644 index 2003092f4..000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import java.util.Date -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.time.Duration -import kotlin.time.Instant - -/** - * Awaits the latch for the given [Duration]. - * - * @param timeout The maximum time to wait. - * @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero. - */ -fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) - -/** Converts this [Instant] to a legacy [Date]. */ -fun Instant.toDate(): Date = Date(this.toEpochMilliseconds()) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt deleted file mode 100644 index c27040e73..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.core.common - -/** Utility function to make it easy to declare byte arrays */ -fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } - -fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) } - -private const val BYTE_MASK = 0xff diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt new file mode 100644 index 000000000..2a27b9690 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher + +/** + * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. + * + * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled + * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not + * cancel siblings, and by [ioDispatcher] so work runs off the main thread. + * + * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch + * and should be used sparingly. + */ +interface ApplicationCoroutineScope : CoroutineScope + +@Single(binds = [ApplicationCoroutineScope::class]) +internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { + override val coroutineContext = SupervisorJob() + ioDispatcher +} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt similarity index 62% rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt index 0babff5b1..1072801c6 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,13 +17,14 @@ package org.meshtastic.core.common.util /** - * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain - * modules without coupling them to the android.net.Uri class. + * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null, + * blank, or sentinel values (`"N"`, `"NULL"`). */ -data class MeshtasticUri(val uriString: String) { - override fun toString(): String = uriString - - companion object { - fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString) +fun normalizeAddress(addr: String?): String { + val u = addr?.trim()?.uppercase() + return when { + u.isNullOrBlank() -> "DEFAULT" + u == "N" || u == "NULL" -> "DEFAULT" + else -> u.replace(":", "") } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt index 7079cbf5e..00b15861f 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt @@ -16,22 +16,14 @@ */ package org.meshtastic.core.common.util -/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */ -expect class CommonUri { - val host: String? - val fragment: String? - val pathSegments: List +import com.eygraber.uri.Uri - fun getQueryParameter(key: String): String? - - fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean - - override fun toString(): String - - companion object { - fun parse(uriString: String): CommonUri - } -} - -/** Extension to convert platform Uri to CommonUri in Android source sets. */ -expect fun CommonUri.toPlatformUri(): Any +/** + * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp). + * + * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works + * identically on Android, JVM, and iOS without platform stubs. + * + * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`. + */ +typealias CommonUri = Uri diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt index ccd565286..92137375c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.common.util import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException object Exceptions { /** Set by the application to provide a custom crash reporting implementation. */ @@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) { } } -/** Suspend-compatible variant of [ignoreException]. */ +/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */ suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) { try { inner() + } catch (e: CancellationException) { + throw e } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) { if (!silent) { Logger.w(ex) { "Ignoring exception" } @@ -69,3 +72,41 @@ fun exceptionReporter(inner: () -> Unit) { Exceptions.report(ex, "exceptionReporter", "Uncaught Exception") } } + +/** + * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead + * of [runCatching] in coroutine contexts. + */ +@Suppress("TooGenericExceptionCaught") +inline fun safeCatching(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Result.failure(e) +} + +/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */ +@Suppress("TooGenericExceptionCaught") +inline fun T.safeCatching(block: T.() -> R): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Result.failure(e) +} + +/** + * Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources' + * lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured + * concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and + * the caller only needs a best-effort fallback. + */ +@Suppress("TooGenericExceptionCaught") +inline fun safeCatchingAll(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (t: Throwable) { + Result.failure(t) +} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt index d54455df8..7a24819a7 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -16,5 +16,114 @@ */ package org.meshtastic.core.common.util -/** Multiplatform string formatting helper. */ -expect fun formatString(pattern: String, vararg args: Any?): String +/** + * Pure-Kotlin multiplatform string formatting. + * + * Implements the subset of Java's `String.format()` patterns used in this codebase: + * - `%s`, `%d` — positional or sequential string/integer + * - `%N$s`, `%N$d` — explicit positional string/integer + * - `%N$.Nf`, `%.Nf` — float with decimal precision + * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) + * - `%%` — literal percent + */ +@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements") +fun formatString(pattern: String, vararg args: Any?): String = buildString { + var i = 0 + var autoIndex = 0 + while (i < pattern.length) { + if (pattern[i] != '%') { + append(pattern[i]) + i++ + continue + } + i++ // skip '%' + if (i >= pattern.length) break + + // Literal %% + if (pattern[i] == '%') { + append('%') + i++ + continue + } + + // Parse optional positional index (N$) + var explicitIndex: Int? = null + val startPos = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i < pattern.length && pattern[i] == '$' && i > startPos) { + explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed + i++ // skip '$' + } else { + i = startPos // rewind — digits are part of width/precision, not positional index + } + + // Parse optional flags (zero-pad) + var zeroPad = false + if (i < pattern.length && pattern[i] == '0') { + zeroPad = true + i++ + } + + // Parse optional width + var width: Int? = null + val widthStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > widthStart) { + width = pattern.substring(widthStart, i).toInt() + } + + // Parse optional precision (.N) + var precision: Int? = null + if (i < pattern.length && pattern[i] == '.') { + i++ // skip '.' + val precStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > precStart) { + precision = pattern.substring(precStart, i).toInt() + } + } + + // Parse conversion character + if (i >= pattern.length) break + val conversion = pattern[i] + i++ + + val argIndex = explicitIndex ?: autoIndex++ + val arg = args.getOrNull(argIndex) + + when (conversion) { + 's' -> append(arg?.toString() ?: "null") + 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") + 'f' -> { + val value = (arg as? Number)?.toDouble() ?: 0.0 + val places = precision ?: DEFAULT_FLOAT_PRECISION + append(NumberFormatter.format(value, places)) + } + 'x', + 'X', + -> { + val value = (arg as? Number)?.toLong() ?: 0L + // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. + val masked = if (arg is Int) value and INT_MASK else value + var hex = masked.toString(HEX_RADIX) + if (conversion == 'X') hex = hex.uppercase() + val padChar = if (zeroPad) '0' else ' ' + val padWidth = width ?: 0 + append(hex.padStart(padWidth, padChar)) + } + else -> { + // Unknown conversion — reproduce original token + append('%') + if (explicitIndex != null) append("${explicitIndex + 1}$") + if (zeroPad) append('0') + if (width != null) append(width) + if (precision != null) append(".$precision") + append(conversion) + } + } + } +} + +private const val DEFAULT_FLOAT_PRECISION = 6 +private const val HEX_RADIX = 16 +private const val INT_MASK = 0xFFFFFFFFL diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt index e3612dfda..1abb8807c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt @@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer { * @param value original string value. * @return optimized string value. */ - fun optimizeUtf8StringWithHomoglyphs(value: String): String { - val stringBuilder = StringBuilder() - for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c) - return stringBuilder.toString() + fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString { + for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c) } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt new file mode 100644 index 000000000..51905ff41 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** + * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node, + * NodeItem, and metric screens. + * + * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional + * for a mesh networking app where consistency matters. + */ +@Suppress("TooManyFunctions") +object MetricFormatter { + + fun temperature(celsius: Float, isFahrenheit: Boolean): String { + val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius + val unit = if (isFahrenheit) "°F" else "°C" + return "${NumberFormatter.format(value, 1)}$unit" + } + + fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V" + + fun current(milliAmps: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(milliAmps, decimalPlaces)} mA" + + fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%" + + fun percent(value: Int): String = "$value%" + + fun humidity(value: Float): String = percent(value, 0) + + fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa" + + fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB" + + fun rssi(value: Int): String = "$value dBm" + + fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s" + + fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(millimeters, decimalPlaces)} mm" +} + +private const val FAHRENHEIT_SCALE = 1.8f +private const val FAHRENHEIT_OFFSET = 32 diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt deleted file mode 100644 index 80251e801..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -/** A deferred execution object (with various possible implementations) */ -interface Continuation { - fun resume(res: Result) - - /** Syntactic sugar for resuming with success. */ - fun resumeSuccess(res: T) = resume(Result.success(res)) - - /** Syntactic sugar for resuming with failure. */ - fun resumeWithException(ex: Throwable) = resume(Result.failure(ex)) -} - -/** An async continuation that calls a callback when the result is available. */ -class CallbackContinuation(private val cb: (Result) -> Unit) : Continuation { - override fun resume(res: Result) = cb(res) -} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt new file mode 100644 index 000000000..040861b8d --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AddressUtilsTest { + + @Test + fun nullReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress(null)) + } + + @Test + fun blankReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("")) + assertEquals("DEFAULT", normalizeAddress(" ")) + } + + @Test + fun sentinelNReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("N")) + assertEquals("DEFAULT", normalizeAddress("n")) + } + + @Test + fun sentinelNullReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("NULL")) + assertEquals("DEFAULT", normalizeAddress("null")) + assertEquals("DEFAULT", normalizeAddress("Null")) + } + + @Test + fun stripsColons() { + assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD")) + } + + @Test + fun uppercases() { + assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd")) + } + + @Test + fun trimsWhitespace() { + assertEquals("AABBCC", normalizeAddress(" AA:BB:CC ")) + } + + @Test + fun alreadyNormalizedPassesThrough() { + assertEquals("AABBCCDD", normalizeAddress("AABBCCDD")) + } + + @Test + fun mixedCaseWithColons() { + assertEquals("AABBCC", normalizeAddress("aA:Bb:cC")) + } +} diff --git a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt similarity index 51% rename from core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt index 0e754708c..899938ba4 100644 --- a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt @@ -18,27 +18,26 @@ package org.meshtastic.core.common.util import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class CommonUriTest { - @Test - fun testParse() { - val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment") - assertEquals("meshtastic.org", uri.host) - assertEquals("fragment", uri.fragment) - assertEquals(listOf("path", "to", "page"), uri.pathSegments) - assertEquals("value1", uri.getQueryParameter("param1")) - assertTrue(uri.getBooleanQueryParameter("param2", false)) + fun testParseAndToString() { + val uriString = "content://com.example.provider/file.txt" + val uri = CommonUri.parse(uriString) + assertEquals(uriString, uri.toString()) } @Test - fun testBooleanParameters() { - val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0") - assertTrue(uri.getBooleanQueryParameter("t1", false)) - assertTrue(uri.getBooleanQueryParameter("t2", false)) - assertTrue(uri.getBooleanQueryParameter("t3", false)) - assertTrue(!uri.getBooleanQueryParameter("f1", true)) - assertTrue(!uri.getBooleanQueryParameter("f2", true)) + fun testQueryParameters() { + val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true") + assertEquals("meshtastic.org", uri.host) + assertEquals("key=value&complete=true", uri.fragment) + } + + @Test + fun testFileUri() { + val uri = CommonUri.parse("file:///tmp/export.csv") + assertEquals("file", uri.scheme) + assertEquals("/tmp/export.csv", uri.path) } } diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt index 94b81f0fb..de2d20e9e 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt @@ -93,4 +93,48 @@ class FormatStringTest { fun sequentialFloatSubstitution() { assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45)) } + + // Hex format tests + + @Test + fun lowercaseHex() { + assertEquals("ff", formatString("%x", 255)) + } + + @Test + fun uppercaseHex() { + assertEquals("FF", formatString("%X", 255)) + } + + @Test + fun zeroPaddedHex() { + assertEquals("000000ff", formatString("%08x", 255)) + } + + @Test + fun zeroPaddedHexNodeId() { + assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt())) + } + + @Test + fun hexZeroValue() { + assertEquals("00000000", formatString("%08x", 0)) + } + + @Test + fun positionalHex() { + assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42)) + } + + // Edge case tests + + @Test + fun trailingPercent() { + assertEquals("hello", formatString("hello%")) + } + + @Test + fun outOfBoundsArgIndex() { + assertEquals("null", formatString("%3\$s", "only_one")) + } } diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt new file mode 100644 index 000000000..94781fca3 --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetricFormatterTest { + + @Test + fun temperatureCelsius() { + assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false)) + } + + @Test + fun temperatureFahrenheit() { + assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true)) + } + + @Test + fun temperatureNegative() { + assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false)) + } + + @Test + fun voltage() { + assertEquals("3.72 V", MetricFormatter.voltage(3.72f)) + } + + @Test + fun voltageOneDecimal() { + assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1)) + } + + @Test + fun current() { + assertEquals("150.3 mA", MetricFormatter.current(150.3f)) + } + + @Test + fun percentFloat() { + assertEquals("85.5%", MetricFormatter.percent(85.5f)) + } + + @Test + fun percentInt() { + assertEquals("85%", MetricFormatter.percent(85)) + } + + @Test + fun humidity() { + assertEquals("65%", MetricFormatter.humidity(65.4f)) + } + + @Test + fun pressure() { + assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f)) + } + + @Test + fun snr() { + assertEquals("5.5 dB", MetricFormatter.snr(5.5f)) + } + + @Test + fun rssi() { + assertEquals("-90 dBm", MetricFormatter.rssi(-90)) + } + + @Test + fun temperatureFreezingFahrenheit() { + assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true)) + } + + @Test + fun temperatureBoilingFahrenheit() { + assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true)) + } + + @Test + fun voltageZero() { + assertEquals("0.00 V", MetricFormatter.voltage(0.0f)) + } + + @Test + fun currentZero() { + assertEquals("0.0 mA", MetricFormatter.current(0.0f)) + } + + @Test + fun percentZero() { + assertEquals("0%", MetricFormatter.percent(0)) + } + + @Test + fun percentHundred() { + assertEquals("100%", MetricFormatter.percent(100)) + } + + @Test + fun rssiZero() { + assertEquals("0 dBm", MetricFormatter.rssi(0)) + } + + @Test + fun snrNegative() { + assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f)) + } + + @Test + fun windSpeed() { + assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f)) + } + + @Test + fun windSpeedZero() { + assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f)) + } + + @Test + fun rainfall() { + assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f)) + } + + @Test + fun rainfallZero() { + assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f)) + } +} diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt deleted file mode 100644 index 1362de98b..000000000 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -/** - * Apple (iOS) implementation of string formatting. - * - * Implements a subset of Java's `String.format()` patterns used in this codebase: - * - `%s`, `%d` — positional or sequential string/integer - * - `%N$s`, `%N$d` — explicit positional string/integer - * - `%N$.Nf`, `%.Nf` — float with decimal precision - * - `%%` — 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/width (skip for now — not used in this codebase) - - // 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)) - } - else -> { - // Unknown conversion — reproduce original token - append('%') - if (explicitIndex != null) append("${explicitIndex + 1}$") - if (precision != null) append(".$precision") - append(conversion) - } - } - } -} - -private const val DEFAULT_FLOAT_PRECISION = 6 diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 35e2906ff..7556105b3 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -22,20 +22,6 @@ actual object BuildUtils { actual val sdkInt: Int = 0 } -actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) { - actual fun getQueryParameter(key: String): String? = null - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue - - actual override fun toString(): String = "" - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList()) - } -} - -actual fun CommonUri.toPlatformUri(): Any = Any() - actual object DateFormatter { actual fun formatRelativeTime(timestampMillis: Long): String = "" diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt deleted file mode 100644 index 8e9a0ec68..000000000 --- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/SyncContinuation.jvmAndroid.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import java.util.concurrent.TimeUnit -import java.util.concurrent.locks.ReentrantLock - -/** - * A blocking version of coroutine Continuation using traditional threading primitives. - * - * This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code. - */ -class SyncContinuation : Continuation { - private val lock = ReentrantLock() - private val condition = lock.newCondition() - private var result: Result? = null - - override fun resume(res: Result) { - lock.lock() - try { - result = res - condition.signal() - } finally { - lock.unlock() - } - } - - /** - * Blocks the current thread until the result is available or the timeout expires. - * - * @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely. - * @return The result of the operation. - * @throws IllegalStateException if a timeout occurs or if an internal error happens. - */ - @Suppress("NestedBlockDepth") - fun await(timeoutMsecs: Long = 0): T { - lock.lock() - try { - val startT = nowMillis - while (result == null) { - if (timeoutMsecs > 0) { - val remaining = timeoutMsecs - (nowMillis - startT) - check(remaining > 0) { "SyncContinuation timeout" } - condition.await(remaining, TimeUnit.MILLISECONDS) - } else { - condition.await() - } - } - - val r = result - checkNotNull(r) { "Unexpected null result in SyncContinuation" } - return r.getOrThrow() - } finally { - lock.unlock() - } - } -} - -/** - * Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the - * current thread until the operation completes or times out. - * - * Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine]. - */ -fun suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation) -> Unit): T { - val cont = SyncContinuation() - initfn(cont) - return cont.await(timeoutMsecs) -} diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.jvmAndroid.kt similarity index 100% rename from core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt rename to core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.jvmAndroid.kt diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt deleted file mode 100644 index c10c015bc..000000000 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import java.net.URI - -actual class CommonUri(private val uri: URI) { - private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) } - - actual val host: String? - get() = uri.host - - actual val fragment: String? - get() = uri.fragment - - actual val pathSegments: List - get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() } - - actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull() - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean { - val value = getQueryParameter(key) ?: return defaultValue - return value != "false" && value != "0" - } - - actual override fun toString(): String = uri.toString() - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString)) - } - - fun toUri(): URI = uri -} - -actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt index 4b8abdbd3..43ead91a2 100644 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt @@ -17,9 +17,6 @@ package org.meshtastic.core.common.util import java.net.InetAddress -import java.net.URLDecoder -import java.nio.charset.StandardCharsets -import java.text.DateFormat import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -76,7 +73,7 @@ actual object DateFormatter { shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) actual fun formatDateTimeShort(timestampMillis: Long): String = - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis) + shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) } @Suppress("MagicNumber") @@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean { } } -internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery - ?.split('&') - ?.filter { it.isNotBlank() } - ?.groupBy( - keySelector = { segment -> - val key = segment.substringBefore('=', missingDelimiterValue = segment) - URLDecoder.decode(key, StandardCharsets.UTF_8.name()) - }, - valueTransform = { segment -> - val value = segment.substringAfter('=', missingDelimiterValue = "") - URLDecoder.decode(value, StandardCharsets.UTF_8.name()) - }, - ) - .orEmpty() - private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}") private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(? 0 myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX - isAdmin -> + else -> channelSet.value.settings .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } .coerceAtLeast(0) - else -> destNode?.channel ?: 0 } } - private fun getAdminChannelIndex(toNum: Int): Int = getChannelIndex(toNum, isAdmin = true) + /** + * Returns the heard-on channel for a non-admin request to [toNum]. Does NOT use PKI — protocol-level requests need + * clear inner payloads. + */ + private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0 override fun sendData(p: DataPacket) { if (p.id == 0) p.id = generatePacketId() @@ -137,14 +145,11 @@ class CommandSenderImpl( if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) { val actualSize = Data.ADAPTER.encodedSize(data) p.status = MessageStatus.ERROR - // throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})") - // RemoteException is Android specific. For KMP we might want a custom exception. error("Message too long: $actualSize bytes") } else { p.status = MessageStatus.QUEUED } - // TODO: Check connection state sendNow(p) } @@ -187,7 +192,7 @@ class CommandSenderImpl( return packetHandler.sendToRadioAndAwait(packet) } - override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) { + override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) { val myNum = nodeManager.myNodeNum.value ?: return val idNum = destNum ?: myNum Logger.d { "Sending our position/time to=$idNum $pos" } @@ -213,7 +218,7 @@ class CommandSenderImpl( override fun requestPosition(destNum: Int, currentPosition: Position) { val meshPosition = - org.meshtastic.proto.Position( + ProtoPosition( latitude_i = Position.degI(currentPosition.latitude), longitude_i = Position.degI(currentPosition.longitude), altitude = currentPosition.altitude, @@ -236,7 +241,7 @@ class CommandSenderImpl( override fun setFixedPosition(destNum: Int, pos: Position) { val meshPos = - org.meshtastic.proto.Position( + ProtoPosition( latitude_i = Position.degI(pos.latitude), longitude_i = Position.degI(pos.longitude), altitude = pos.altitude, @@ -289,21 +294,17 @@ class CommandSenderImpl( if (type == TelemetryType.PAX) { portNum = PortNum.PAXCOUNTER_APP - payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString() + payloadBytes = Paxcount().encode().toByteString() } else { portNum = PortNum.TELEMETRY_APP payloadBytes = Telemetry( - device_metrics = - if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null, - environment_metrics = - if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null, - air_quality_metrics = - if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null, - power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null, - local_stats = - if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null, - host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null, + device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null, + environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null, + air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null, + power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null, + local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null, + host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null, ) .encode() .toByteString() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt new file mode 100644 index 000000000..6ca10df26 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt @@ -0,0 +1,54 @@ +/* + * 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.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/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index db598fd51..db6f6dec7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -89,6 +89,12 @@ class FromRadioPacketHandlerImpl( fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo) xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket) clientNotification != null -> handleClientNotification(clientNotification) + // Firmware rebooted without a transport-level disconnect (common on serial/TCP). + // Re-handshake immediately rather than waiting for the 30s stall guard. + proto.rebooted != null -> { + Logger.w { "Firmware rebooted (rebooted=${proto.rebooted}), re-initiating handshake" } + router.value.configFlowManager.triggerWantConfig() + } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt index b0b9e8c5f..628528391 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.repository.PacketHandler @@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan "lastRequest=$lastRequest window=$window max=$max", ) - runCatching { + safeCatching { packetHandler.sendToRadio( MeshPacket( from = myNodeNum, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 14fddde7f..ab4f3a551 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -18,12 +18,15 @@ 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 @@ -42,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -60,16 +64,13 @@ 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 @@ -95,7 +96,7 @@ class MeshActionHandlerImpl( is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) is ServiceAction.SendContact -> { val accepted = - runCatching { + safeCatching { commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) } } .getOrDefault(false) @@ -202,13 +203,13 @@ class MeshActionHandlerImpl( commandSender.sendData(p) serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) dataHandler.value.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: okio.ByteString.EMPTY + val bytes = p.bytes ?: 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 = meshPrefs.shouldProvideNodeLocation(myNodeNum).value + val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value val currentPosition = when { provideLocation && position.isValid() -> position @@ -248,6 +249,11 @@ class MeshActionHandlerImpl( override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { val c = Config.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } + // When targeting the local node, optimistically persist the config so the + // UI reflects changes immediately (matching handleSetConfig behaviour). + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } + } } override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { @@ -310,6 +316,11 @@ class MeshActionHandlerImpl( if (payload != null) { val c = Channel.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } + // When targeting the local node, optimistically persist the channel so + // the UI reflects changes immediately (matching handleSetChannel behaviour). + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } + } } } @@ -349,7 +360,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() ?: okio.ByteString.EMPTY) + AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: 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 4c1c60425..cc5cc4319 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -17,19 +17,20 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import okio.IOException +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts @@ -37,9 +38,7 @@ import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.NodeInfo -import org.meshtastic.proto.ToRadio import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo @@ -54,14 +53,13 @@ class MeshConfigFlowManagerImpl( private val serviceBroadcasts: ServiceBroadcasts, private val analytics: PlatformAnalytics, private val commandSender: CommandSender, - private val packetHandler: PacketHandler, + private val heartbeatSender: DataLayerHeartbeatSender, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigFlowManager { - private lateinit var scope: CoroutineScope private val wantConfigDelay = 100L - override fun start(scope: CoroutineScope) { - this.scope = scope - } + /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ + private val handshakeGeneration = atomic(0L) /** * Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase, @@ -80,7 +78,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, var metadata: DeviceMetadata? = null) : + data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) : HandshakeState() /** @@ -89,10 +87,8 @@ class MeshConfigFlowManagerImpl( * [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until * `config_complete_id` arrives. */ - data class ReceivingNodeInfo( - val myNodeInfo: SharedMyNodeInfo, - val nodes: MutableList = mutableListOf(), - ) : HandshakeState() + data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) : + HandshakeState() /** Both stages finished. The app is fully connected. */ data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState() @@ -138,28 +134,31 @@ class MeshConfigFlowManagerImpl( return } + // Warn if firmware is below the absolute minimum supported version. + // The UI layer already enforces this via FirmwareVersionCheck, so we just log here + // for diagnostics rather than hard-disconnecting. + finalizedInfo.firmwareVersion?.let { fwVersion -> + if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) { + Logger.w { + "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " + + "protocol incompatibilities may occur" + } + } + } + handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo) Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" } connectionManager.value.onRadioConfigLoaded() scope.handledLaunch { delay(wantConfigDelay) - sendHeartbeat() + heartbeatSender.sendHeartbeat("inter-stage") delay(wantConfigDelay) Logger.i { "Requesting NodeInfo (Stage 2)" } connectionManager.value.startNodeInfoOnly() } } - private fun sendHeartbeat() { - try { - packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat())) - Logger.d { "Heartbeat sent between nonce stages" } - } catch (ex: IOException) { - Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" } - } - } - private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) { Logger.i { "NodeInfo complete (Stage 2)" } @@ -167,16 +166,12 @@ class MeshConfigFlowManagerImpl( // Transition state immediately (synchronously) to prevent duplicate handling. // The async work below (DB writes, broadcasts) proceeds without the guard. + // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot. + // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored. handshakeState = HandshakeState.Complete(myNodeInfo = info) - // Snapshot and clear immediately so that a concurrent stall-guard retry (which - // resends want_config_id and causes the firmware to restart the node_info burst) - // starts accumulating into a fresh list rather than doubling this batch. - val nodesToProcess = state.nodes.toList() - state.nodes.clear() - val entities = - nodesToProcess.mapNotNull { nodeInfo -> + state.nodes.mapNotNull { nodeInfo -> nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) nodeManager.nodeDBbyNodeNum[nodeInfo.num] ?: run { @@ -203,12 +198,18 @@ class MeshConfigFlowManagerImpl( handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) nodeManager.setMyNodeNum(myInfo.my_node_num) + // Bump the generation so that a pending clear from a prior (interrupted) handshake + // will see a stale snapshot and skip its writes, preventing it from wiping config + // that was saved by this (newer) handshake's incoming packets. + val gen = handshakeGeneration.incrementAndGet() + // Clear persisted radio config so the new handshake starts from a clean slate. // DataStore serializes its own writes, so the clear will precede subsequent // setLocalConfig / updateChannelSettings calls dispatched by later packets in this // session (handleFromRadio processes packets sequentially, so later dispatches always // occur after this one returns). scope.handledLaunch { + if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip. radioConfigRepository.clearChannelSet() radioConfigRepository.clearLocalConfig() radioConfigRepository.clearLocalModuleConfig() @@ -221,7 +222,7 @@ class MeshConfigFlowManagerImpl( Logger.i { "Local Metadata received: ${metadata.firmware_version}" } val state = handshakeState if (state is HandshakeState.ReceivingConfig) { - state.metadata = metadata + handshakeState = state.copy(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()) { @@ -235,7 +236,7 @@ class MeshConfigFlowManagerImpl( override fun handleNodeInfo(info: NodeInfo) { val state = handshakeState if (state is HandshakeState.ReceivingNodeInfo) { - state.nodes.add(info) + handshakeState = state.copy(nodes = state.nodes + info) } else { Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index 06d973204..b622cedbf 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,6 +22,7 @@ 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 @@ -40,8 +41,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() @@ -49,8 +50,7 @@ class MeshConfigHandlerImpl( private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) override val moduleConfig = _moduleConfig.asStateFlow() - override fun start(scope: CoroutineScope) { - this.scope = scope + init { 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 3fcf157d0..022f3548d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -19,13 +19,16 @@ 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 @@ -57,6 +60,7 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Telemetry import org.meshtastic.proto.ToRadio +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit @@ -81,17 +85,26 @@ 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 { - private lateinit var scope: CoroutineScope + /** + * 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 var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null private var connectTimeMsec = 0L private var connectionRestored = false - @OptIn(FlowPreview::class) - override fun start(scope: CoroutineScope) { - this.scope = scope + 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. radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) // Ensure notification title and content stay in sync with state changes @@ -125,6 +138,13 @@ 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 @@ -141,20 +161,22 @@ class MeshConnectionManagerImpl( onConnectionChanged(effectiveState) } - private fun onConnectionChanged(c: ConnectionState) { + private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock { val current = serviceRepository.connectionState.value - if (current == c) return + if (current == c) return@withLock // 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 + return@withLock } Logger.i { "onConnectionChanged: $current -> $c" } sleepTimeout?.cancel() sleepTimeout = null + preHandshakeJob?.cancel() + preHandshakeJob = null handshakeTimeout?.cancel() handshakeTimeout = null @@ -175,16 +197,26 @@ class MeshConnectionManagerImpl( serviceRepository.setConnectionState(ConnectionState.Connecting) } serviceBroadcasts.broadcastConnection() - Logger.i { "Starting mesh handshake (Stage 1)" } connectTimeMsec = nowMillis - startConfigOnly() + + // Send a wake-up heartbeat before the config request. The firmware may be in a + // power-saving state where the NimBLE callback context needs warming up. The 100ms + // delay ensures the heartbeat BLE write is enqueued before the want_config_id + // (sendToRadio is fire-and-forget through async coroutine launches). + preHandshakeJob = + scope.handledLaunch { + heartbeatSender.sendHeartbeat("pre-handshake") + delay(PRE_HANDSHAKE_SETTLE_MS) + Logger.i { "Starting mesh handshake (Stage 1)" } + startConfigOnly() + } } - private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) { + private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) { handshakeTimeout?.cancel() handshakeTimeout = scope.handledLaunch { - delay(HANDSHAKE_TIMEOUT) + delay(timeout) if (serviceRepository.connectionState.value is ConnectionState.Connecting) { // Attempt one retry. Note: the firmware silently drops identical consecutive // writes (per-connection dedup). If the first want_config_id was received and @@ -205,6 +237,7 @@ class MeshConnectionManagerImpl( private fun tearDownConnection() { packetHandler.stopPacketQueue() + commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect. locationManager.stop() mqttManager.stop() } @@ -227,8 +260,11 @@ class MeshConnectionManagerImpl( scope.handledLaunch { try { val localConfig = radioConfigRepository.localConfigFlow.first() - val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS - Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } + val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS + // Cap the timeout so routers or power-saving configs (ls_secs=3600) don't + // leave the UI stuck in DeviceSleep for over an hour. + val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS) + Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" } delay(timeout.seconds) Logger.w { "Device timed out, setting disconnected" } onConnectionChanged(ConnectionState.Disconnected) @@ -256,19 +292,19 @@ class MeshConnectionManagerImpl( override fun startConfigOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } - startHandshakeStallGuard(1, action) + startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action) action() } override fun startNodeInfoOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } - startHandshakeStallGuard(2, action) + startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) action() } override fun onRadioConfigLoaded() { scope.handledLaunch { - val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList() + val queuedPackets = packetRepository.getQueuedPackets() queuedPackets.forEach { packet -> try { workerManager.enqueueSendMessage(packet.id) @@ -298,8 +334,7 @@ class MeshConnectionManagerImpl( // Start MQTT if enabled scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.start( - scope, + mqttManager.startProxy( moduleConfig.mqtt?.enabled == true, moduleConfig.mqtt?.proxy_to_client_enabled == true, ) @@ -346,15 +381,38 @@ class MeshConnectionManagerImpl( updateStatusNotification(t) } - override fun updateStatusNotification(telemetry: Telemetry?): Any = + override fun updateStatusNotification(telemetry: Telemetry?) { serviceNotifications.updateServiceStateNotification( serviceRepository.connectionState.value, telemetry = telemetry, ) + } companion object { private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 - private val HANDSHAKE_TIMEOUT = 30.seconds + + // Maximum time (in seconds) to wait for a sleeping device before declaring it + // disconnected, regardless of the device's ls_secs configuration. Without this + // 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 // 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 0a3f03004..384f722d8 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,6 +22,8 @@ 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 @@ -94,14 +96,8 @@ 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( @@ -252,8 +248,14 @@ class MeshDataHandlerImpl( val payload = packet.decoded?.payload ?: return val u = User.ADAPTER.decode(payload) - .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it } - .let { if (packet.via_mqtt == true) it.copy(long_name = "${it.long_name} (MQTT)") else it } + .let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } + .let { + if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) { + it.copy(long_name = "${it.long_name} (MQTT)") + } else { + it + } + } nodeManager.handleReceivedUser(packet.from, u, packet.channel) } 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 f7191c73b..d9d21ad8b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -24,6 +24,7 @@ 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 @@ -31,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.isLora +import org.meshtastic.core.model.util.toOneLineString +import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.repository.FromRadioPacketHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshMessageProcessor @@ -41,6 +44,7 @@ import org.meshtastic.proto.FromRadio import org.meshtastic.proto.LogRecord import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum +import kotlin.concurrent.Volatile import kotlin.uuid.Uuid /** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */ @@ -52,23 +56,29 @@ 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() private val logInsertJobByPacketId = mutableMapOf() + /** + * Epoch-millisecond timestamp of the last local-node `lastHeard` DB write. Used to throttle updates to at most once + * per [LOCAL_NODE_REFRESH_INTERVAL_MS] so that high-frequency FromRadio variants (log records, queue status) don't + * flood the DB. + */ + @Volatile private var lastLocalNodeRefreshMs = 0L + private val earlyMutex = Mutex() - private val earlyReceivedPackets = kotlin.collections.ArrayDeque() + private val earlyReceivedPackets = ArrayDeque() private val maxEarlyPacketBuffer = 10240 override fun clearEarlyPackets() { scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } } } - override fun start(scope: CoroutineScope) { - this.scope = scope + init { nodeManager.isNodeDbReady .onEach { ready -> if (ready) { @@ -88,13 +98,16 @@ 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." } } } } private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) { + // Any decoded FromRadio proves the radio link is alive — keep the local node fresh. + refreshLocalNodeLastHeard() + // Audit log every incoming variant logVariant(proto) @@ -114,11 +127,11 @@ class MeshMessageProcessorImpl( proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString() proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString() proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString() - proto.my_info != null -> "MyInfo" to proto.my_info.toString() - proto.node_info != null -> "NodeInfo" to proto.node_info.toString() - proto.config != null -> "Config" to proto.config.toString() - proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString() - proto.channel != null -> "Channel" to proto.channel.toString() + proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString() + proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString() + proto.config != null -> "Config" to proto.config!!.toOneLineString() + proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString() + proto.channel != null -> "Channel" to proto.channel!!.toOneLineString() proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString() else -> return } @@ -253,5 +266,33 @@ class MeshMessageProcessorImpl( } } + /** + * Refreshes the local node's [Node.lastHeard] to prove the radio link is alive. + * + * Without this, [lastHeard] is only set when a [MeshPacket] arrives from another node (see + * [processReceivedMeshPacket]). On a quiet mesh the heartbeat cycle still exchanges data with the firmware (ToRadio + * heartbeat → FromRadio queueStatus every 30 s), but that data never touched [lastHeard], causing the local node to + * appear stale in the UI even though the connection is healthy. + * + * To avoid flooding the DB on high-frequency variants (log records arrive many times per second when debug logging + * is enabled), writes are throttled to at most once per [LOCAL_NODE_REFRESH_INTERVAL_MS]. + */ + private fun refreshLocalNodeLastHeard() { + val now = nowMillis + if (now - lastLocalNodeRefreshMs < LOCAL_NODE_REFRESH_INTERVAL_MS) return + lastLocalNodeRefreshMs = now + + val myNum = nodeManager.myNodeNum.value ?: return + nodeManager.updateNode(myNum, withBroadcast = false) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) } + } + private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) } + + companion object { + /** + * Minimum interval between local-node `lastHeard` DB writes, in milliseconds. Aligned with the heartbeat + * interval (30 s) so that one write per heartbeat cycle keeps the node fresh without unnecessary DB churn. + */ + private const val LOCAL_NODE_REFRESH_INTERVAL_MS = 30_000L + } } 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 aaf109be9..8973589bd 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,7 +16,6 @@ */ 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 @@ -64,13 +63,4 @@ 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 969b67a2f..5693d343b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -20,14 +20,28 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.network.repository.resolveEndpoint import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.mqtt.ConnectionState +import org.meshtastic.mqtt.MqttClient +import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.ProbeResult +import org.meshtastic.mqtt.probe import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio @@ -36,22 +50,33 @@ 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 fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { - this.scope = scope + override val mqttConnectionState: StateFlow = + combine(proxyActive, mqttRepository.connectionState) { active, libState -> + if (!active) MqttConnectionState.Inactive else libState.toAppState() + } + .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive) + + override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { if (mqttMessageFlow?.isActive == true) return if (enabled && proxyToClientEnabled) { + proxyActive.value = true mqttMessageFlow = mqttRepository.proxyMessageFlow .onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } .catch { throwable -> - serviceRepository.setErrorMessage( - text = "MqttClientProxy failed: $throwable", - severity = Severity.Warn, - ) + proxyActive.value = false + val message = + when (throwable) { + is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)" + is MqttException.ConnectionLost -> "MQTT: connection lost" + else -> "MQTT proxy failed: ${throwable.message}" + } + serviceRepository.setErrorMessage(text = message, severity = Severity.Warn) } .launchIn(scope) } @@ -63,6 +88,7 @@ class MqttManagerImpl( mqttMessageFlow?.cancel() mqttMessageFlow = null } + proxyActive.value = false } override fun handleMqttProxyMessage(message: MqttClientProxyMessage) { @@ -79,4 +105,57 @@ class MqttManagerImpl( else -> {} } } + + private fun ConnectionState.toAppState(): MqttConnectionState = when (this) { + is ConnectionState.Connecting -> MqttConnectionState.Connecting + is ConnectionState.Connected -> MqttConnectionState.Connected + is ConnectionState.Reconnecting -> + MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message) + is ConnectionState.Disconnected -> + reason?.let { MqttConnectionState.Disconnected(reason = it.message) } + ?: MqttConnectionState.Disconnected.Idle + } + + override suspend fun probe( + address: String, + tlsEnabled: Boolean, + username: String?, + password: String?, + ): MqttProbeStatus { + val endpoint = resolveEndpoint(address, tlsEnabled) + val result = + MqttClient.probe(endpoint = endpoint) { + val user = username?.takeUnless { it.isEmpty() } + val pass = password?.takeUnless { it.isEmpty() } + if (user != null) this.username = user + if (pass != null) password(pass) + } + return result.toAppStatus() + } + + private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) { + is ProbeResult.Success -> { + val info = serverInfo + val summary = + buildList { + info.assignedClientIdentifier?.let { add("client=$it") } + info.maximumQosOrdinal?.let { add("maxQoS=$it") } + info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") } + } + .joinToString(", ") + .ifEmpty { null } + MqttProbeStatus.Success(serverInfo = summary) + } + is ProbeResult.Rejected -> + MqttProbeStatus.Rejected( + reasonCode = reasonCode.value, + reason = message, + serverReference = serverReference, + ) + is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message) + is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message) + is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message) + is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs) + is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message) + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 4019e5a9b..3f483ba25 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,7 +20,6 @@ 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 @@ -37,16 +36,11 @@ 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 85e858882..fe6d22f4c 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,6 +24,7 @@ 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 @@ -59,8 +60,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()) @@ -88,10 +89,6 @@ class NodeManagerImpl( myNodeNum.value = num } - override fun start(scope: CoroutineScope) { - this.scope = scope - } - companion object { private const val TIME_MS_TO_S = 1000L } @@ -170,19 +167,27 @@ class NodeManagerImpl( } override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { - val next = transform(_nodeDBbyNodeNum.value[nodeNum] ?: getOrCreateNode(nodeNum, channel)) - - _nodeDBbyNodeNum.update { it.put(nodeNum, next) } - if (next.user.id.isNotEmpty()) { - _nodeDBbyID.update { it.put(next.user.id, next) } + // Perform read + transform inside update{} to ensure atomicity. + // Without this, concurrent calls for the same nodeNum could read the same snapshot + // and the last writer would silently overwrite the other's changes. + var next: Node? = null + _nodeDBbyNodeNum.update { map -> + val current = map[nodeNum] ?: getOrCreateNode(nodeNum, channel) + val transformed = transform(current) + next = transformed + map.put(nodeNum, transformed) + } + val result = next ?: return + if (result.user.id.isNotEmpty()) { + _nodeDBbyID.update { it.put(result.user.id, result) } } - if (next.user.id.isNotEmpty() && isNodeDbReady.value) { - scope.handledLaunch { nodeRepository.upsert(next) } + if (result.user.id.isNotEmpty() && isNodeDbReady.value) { + scope.handledLaunch { nodeRepository.upsert(result) } } if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(next) + serviceBroadcasts.broadcastNodeChange(result) } } @@ -282,7 +287,7 @@ class NodeManagerImpl( } else { var newUser = user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } - if (info.via_mqtt) { + if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") } next = next.copy(user = newUser, publicKey = newUser.public_key) 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 2131172e1..e2e9a8432 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -22,12 +22,15 @@ 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 @@ -60,6 +63,7 @@ class PacketHandlerImpl( private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, private val serviceRepository: ServiceRepository, + @Named("ServiceScope") private val scope: CoroutineScope, ) : PacketHandler { companion object { @@ -67,11 +71,15 @@ 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 @@ -79,9 +87,18 @@ class PacketHandlerImpl( private val responseMutex = Mutex() private val queueResponse = mutableMapOf>() - override fun start(scope: CoroutineScope) { - this.scope = scope - queueStopped = false // Safe: called before any concurrent operations on this scope. + init { + // Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket) + // entry point, preserving FIFO across rapid concurrent callers. + scope.launch { + outboundChannel.consumeAsFlow().collect { packet -> + queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. + queuedPackets.add(packet) + startPacketQueueLocked() + } + } + } } override fun sendToRadio(p: ToRadio) { @@ -108,12 +125,9 @@ class PacketHandlerImpl( } override fun sendToRadio(packet: MeshPacket) { - scope.launch { - queueMutex.withLock { - queuedPackets.add(packet) - startPacketQueueLocked() - } - } + // Non-suspend entry point — order-preserving via unbounded channel drained by + // a single consumer coroutine. trySend on UNLIMITED never fails for capacity. + outboundChannel.trySend(packet) } @Suppress("TooGenericExceptionCaught", "SwallowedException") @@ -123,6 +137,7 @@ class PacketHandlerImpl( val deferred = CompletableDeferred() responseMutex.withLock { queueResponse[packet.id] = deferred } queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. queuedPackets.add(packet) startPacketQueueLocked() } @@ -199,15 +214,18 @@ class PacketHandlerImpl( Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" } } catch (e: TimeoutCancellationException) { Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" } + // Clean up the deferred for this packet. sendToRadioAndAwait callers + // also clean up in their own finally block (idempotent remove). + responseMutex.withLock { queueResponse.remove(packet.id) } } catch (e: CancellationException) { throw e // Preserve structured concurrency cancellation propagation. } catch (e: Exception) { Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" } + responseMutex.withLock { queueResponse.remove(packet.id) } } - // Do NOT remove from queueResponse here. Removal is owned by: - // - handleQueueStatus (normal completion path) - // - sendToRadioAndAwait's finally block (for await-style callers) - // - stopPacketQueue (bulk cleanup on disconnect) + // Deferred cleanup is now handled in the catch blocks above. + // handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup) + // also remove entries, and these removals are idempotent. } } finally { // Hold queueMutex so that clearing queueJob and the restart decision are 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 4f71879ce..e8ab4eeb7 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,6 +20,7 @@ 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 @@ -45,12 +46,8 @@ 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 205dd30e2..4887ff19b 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,6 +21,7 @@ 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 @@ -49,16 +50,12 @@ 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 5e8d954f6..5d2feb65e 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,6 +22,7 @@ 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 @@ -42,15 +43,11 @@ 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) } } @@ -68,7 +65,7 @@ class TracerouteHandlerImpl( routeDiscovery.getTracerouteResponse( getUser = { num -> nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" } - ?: "Unknown" // TODO: Use core:resources once available in core:data + ?: "Unknown" }, headerTowards = "Route towards destination:", headerBack = "Route back to us:", diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt index 6f05c9ccf..6e8700311 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.XModemFile import org.meshtastic.core.repository.XModemManager @@ -59,6 +60,8 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage @Volatile private var transferName = "" @Volatile private var expectedSeq = INITIAL_SEQ + + @Volatile private var lastActivityMillis = 0L private val blocks = mutableListOf() override fun setTransferName(name: String) { @@ -66,6 +69,17 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage } override fun handleIncomingXModem(packet: XModem) { + // If blocks have accumulated but no activity for INACTIVITY_TIMEOUT_MS, + // the previous transfer is stale (firmware crash, BLE disconnect, etc.). + if (blocks.isNotEmpty() && lastActivityMillis > 0L) { + val elapsed = nowMillis - lastActivityMillis + if (elapsed > INACTIVITY_TIMEOUT_MS) { + Logger.w { "XModem: inactivity timeout (${elapsed}ms) — resetting stale transfer" } + reset() + } + } + lastActivityMillis = nowMillis + when (packet.control) { XModem.Control.SOH, XModem.Control.STX, @@ -135,6 +149,7 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage expectedSeq = INITIAL_SEQ blocks.clear() transferName = "" + lastActivityMillis = 0L } // CRC-CCITT: polynomial 0x1021, initial value 0x0000 (XModem variant) @@ -157,5 +172,6 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage private const val CTRLZ = 0x1A.toByte() private const val CRC_POLY = 0x1021 private const val BITS_PER_BYTE = 8 + private const val INACTIVITY_TIMEOUT_MS = 30_000L } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 338a0d6ea..fdcc6d344 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource @@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl( } // 2. Fetch from remote API - runCatching { + safeCatching { Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" } val remoteHardware = remoteDataSource.getAllDeviceHardware() Logger.d { @@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl( hwModel: Int, target: String?, quirks: List, - ): Result = runCatching { + ): Result = safeCatching { Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" } val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset() Logger.d { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index a47a5381f..8f3154815 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource import org.meshtastic.core.database.entity.FirmwareRelease @@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl( */ private suspend fun updateCacheFromSources() { val remoteFetchSuccess = - runCatching { + safeCatching { Logger.d { "Fetching fresh firmware releases from remote API." } val networkReleases = remoteDataSource.getFirmwareReleases() @@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl( // If remote fetch failed, try the JSON fallback as a last resort. if (!remoteFetchSuccess) { Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." } - runCatching { + safeCatching { val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset() localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE) localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index f6a49f190..149c62d2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.dao.NodeInfoDao +import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ContactSettings @@ -108,7 +110,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dao.upsertContactSettings(listOf(updated)) } - override suspend fun getQueuedPackets(): List? = + override suspend fun getQueuedPackets(): List = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } suspend fun insertRoomPacket(packet: RoomPacket) = @@ -154,13 +156,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val else -> dao.getMessagesFrom(contact) } flow.mapLatest { packets -> + val cachedGetNode = memoize(getNode) + val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct() + val replyMap = batchGetPacketsByIds(replyIds) packets.map { packet -> - val message = packet.toMessage(getNode) - message.replyId - .takeIf { it != null && it != 0 } - ?.let { getPacketByPacketIdInternal(it) } - ?.let { originalPacket -> originalPacket.toMessage(getNode) } - ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + val message = packet.toMessage(cachedGetNode) + val replyId = message.replyId?.takeIf { it != 0 } + val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode) + if (originalMessage != null) message.copy(originalMessage = originalMessage) else message } } } @@ -177,13 +180,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val ) .flow .map { pagingData -> + val cachedGetNode = memoize(getNode) + val replyCache = mutableMapOf() pagingData.map { packet -> - val message = packet.toMessage(getNode) - message.replyId - .takeIf { it != null && it != 0 } - ?.let { getPacketByPacketIdInternal(it) } - ?.let { originalPacket -> originalPacket.toMessage(getNode) } - ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + val message = packet.toMessage(cachedGetNode) + val replyId = message.replyId?.takeIf { it != 0 } + val originalMessage = + replyId + ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } } + ?.toMessage(cachedGetNode) + if (originalMessage != null) message.copy(originalMessage = originalMessage) else message } } @@ -204,13 +210,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val ) .flow .map { pagingData -> + val cachedGetNode = memoize(getNode) + val replyCache = mutableMapOf() pagingData.map { packet -> - val message = packet.toMessage(getNode) - message.replyId - .takeIf { it != null && it != 0 } - ?.let { getPacketByPacketIdInternal(it) } - ?.let { originalPacket -> originalPacket.toMessage(getNode) } - ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + val message = packet.toMessage(cachedGetNode) + val replyId = message.replyId?.takeIf { it != 0 } + val originalMessage = + replyId + ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } } + ?.toMessage(cachedGetNode) + if (originalMessage != null) message.copy(originalMessage = originalMessage) else message } } @@ -230,6 +239,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val private suspend fun getPacketByPacketIdInternal(packetId: Int) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } + private suspend fun batchGetPacketsByIds(ids: List): Map = if (ids.isEmpty()) { + emptyMap() + } else { + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) + .flatMap { dao.getPacketsByPacketIds(it) } + .associateBy { it.packet.packetId } + } + } + + private fun memoize(getNode: suspend (String?) -> Node): suspend (String?) -> Node { + val cache = mutableMapOf() + return { id -> cache.getOrPut(id) { getNode(id) } } + } + override suspend fun insert( packet: DataPacket, myNodeNum: Int, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 6ac094e48..5b29e9f26 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -25,6 +25,7 @@ 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 @@ -46,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -67,6 +69,7 @@ class MeshActionHandlerImplTest { private val dataHandler = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val meshPrefs = mock(MockMode.autofill) + private val uiPrefs = mock(MockMode.autofill) private val databaseManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val messageProcessor = mock(MockMode.autofill) @@ -89,28 +92,29 @@ class MeshActionHandlerImplTest { every { nodeManager.myNodeNum } returns myNodeNumFlow every { nodeManager.getMyId() } returns "!12345678" every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - 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, - ) } + 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, + ) + // ---- handleUpdateLastAddress (device-switch path — P0 critical) ---- @Test fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -128,7 +132,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr") @@ -141,7 +145,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -156,7 +160,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow(null) @@ -168,7 +172,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -187,7 +191,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = null val node = createTestNode(REMOTE_NODE_NUM) @@ -201,7 +205,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -213,7 +217,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -227,7 +231,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false) handler.onServiceAction(ServiceAction.Ignore(node)) @@ -242,7 +246,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isMuted = false) handler.onServiceAction(ServiceAction.Mute(node)) @@ -256,7 +260,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM)) advanceUntilIdle() @@ -268,7 +272,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true val action = ServiceAction.SendContact(SharedContact()) @@ -281,7 +285,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false val action = ServiceAction.SendContact(SharedContact()) @@ -296,7 +300,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val contact = SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser")) @@ -311,7 +315,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetOwner_sendsAdminAndUpdatesLocalNode() { - handler.start(testScope) + handler = createHandler(testScope) val meshUser = MeshUser( id = "!12345678", @@ -331,7 +335,7 @@ class MeshActionHandlerImplTest { @Test fun handleSend_sendsDataAndBroadcastsStatus() { - handler.start(testScope) + handler = createHandler(testScope) val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0) handler.handleSend(packet, MY_NODE_NUM) @@ -345,7 +349,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_sameNode_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM) @@ -354,8 +358,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { - handler.start(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + handler = createHandler(testScope) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -365,8 +369,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { - handler.start(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + handler = createHandler(testScope) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() val invalidPosition = Position(0.0, 0.0, 0) @@ -378,8 +382,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_doNotProvide_sendsZeroPosition() { - handler.start(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) + handler = createHandler(testScope) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -392,7 +396,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit val config = Config(lora = Config.LoRaConfig(hop_limit = 5)) @@ -409,7 +413,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit @@ -425,7 +429,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) @@ -442,7 +446,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit val channel = Channel(index = 1) @@ -457,7 +461,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nullPayload_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleSetChannel(null, MY_NODE_NUM) @@ -468,7 +472,7 @@ class MeshActionHandlerImplTest { @Test fun handleRemoveByNodenum_removesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM) @@ -480,7 +484,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteOwner_decodesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val user = User(id = "!remote01", long_name = "Remote", short_name = "RM") val payload = User.ADAPTER.encode(user) @@ -495,7 +499,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) @@ -504,7 +508,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value) @@ -515,7 +519,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nullPayload_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null) @@ -524,7 +528,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val channel = Channel(index = 2) val payload = Channel.ADAPTER.encode(channel) @@ -538,7 +542,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withNullHash_sendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null) @@ -547,7 +551,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withHash_sendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val hash = byteArrayOf(0x01, 0x02, 0x03) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash) @@ -559,7 +563,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() { - handler.start(testScope) + handler = createHandler(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 9580d5363..fdcd8ed44 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any @@ -97,9 +98,9 @@ class MeshConfigFlowManagerImplTest { serviceBroadcasts = serviceBroadcasts, analytics = analytics, commandSender = commandSender, - packetHandler = packetHandler, + heartbeatSender = DataLayerHeartbeatSender(packetHandler), + scope = testScope, ) - manager.start(testScope) } // ---------- handleMyInfo ---------- @@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest { verify { connectionManager.startNodeInfoOnly() } } + @Test + fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest { + val sentPackets = mutableListOf() + every { packetHandler.sendToRadio(any()) } calls + { call -> + sentPackets.add(call.arg(0)) + } + + manager.handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + manager.handleLocalMetadata(metadata) + advanceUntilIdle() + + sentPackets.clear() // Clear any packets from prior phases + manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) + advanceUntilIdle() + + val heartbeats = sentPackets.filter { it.heartbeat != null } + assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat") + assertEquals( + true, + heartbeats[0].heartbeat!!.nonce != 0, + "Inter-stage heartbeat should have a non-zero nonce", + ) + } + + @Test + fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest { + val oldMetadata = + DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) + manager.handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + manager.handleLocalMetadata(oldMetadata) + advanceUntilIdle() + + manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) + advanceUntilIdle() + + // Handshake should still progress despite old firmware + verify { connectionManager.onRadioConfigLoaded() } + verify { connectionManager.startNodeInfoOnly() } + } + @Test fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest { manager.handleMyInfo(protoMyNodeInfo) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt index b71942d0e..bf3247815 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,6 +23,7 @@ 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 @@ -60,20 +61,20 @@ class MeshConfigHandlerImplTest { fun setUp() { every { radioConfigRepository.localConfigFlow } returns localConfigFlow every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - - handler = - MeshConfigHandlerImpl( - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - nodeManager = nodeManager, - ) } + private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( + radioConfigRepository = radioConfigRepository, + serviceRepository = serviceRepository, + nodeManager = nodeManager, + scope = scope, + ) + // ---------- start and flow wiring ---------- @Test fun `start wires localConfig flow from repository`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) localConfigFlow.value = config advanceUntilIdle() @@ -83,7 +84,7 @@ class MeshConfigHandlerImplTest { @Test fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) moduleConfigFlow.value = config advanceUntilIdle() @@ -95,7 +96,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) handler.handleDeviceConfig(config) advanceUntilIdle() @@ -106,7 +107,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val configs = listOf( Config(position = Config.PositionConfig()), @@ -131,7 +132,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) handler.handleModuleConfig(config) advanceUntilIdle() @@ -142,7 +143,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val myNum = 123 every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) @@ -155,7 +156,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.myNodeNum } returns MutableStateFlow(null) val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) @@ -168,7 +169,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel persists channel settings`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val channel = Channel(index = 0) handler.handleChannel(channel) advanceUntilIdle() @@ -178,7 +179,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.getMyNodeInfo() } returns MyNodeInfo( myNodeNum = 123, @@ -206,7 +207,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.getMyNodeInfo() } returns null val channel = Channel(index = 0) @@ -220,7 +221,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(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 d72e5b243..07c8914ad 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -24,10 +24,12 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CoroutineScope 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 import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.ConnectionState @@ -59,7 +61,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class MeshConnectionManagerImplTest { private val radioInterfaceService = mock(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) @@ -107,37 +109,35 @@ 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, - ) } - @AfterTest fun tearDown() {} + 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 @Test fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) { - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - - manager.start(backgroundScope) + manager = createManager(backgroundScope) radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -150,19 +150,62 @@ class MeshConnectionManagerImplTest { } @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 + 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.start(backgroundScope) + + manager = createManager(backgroundScope) + radioConnectionState.value = ConnectionState.Connected + // Advance only 50ms — within the 100ms settle window + advanceTimeBy(50) + + // Should have sent only the heartbeat so far, not want_config_id + assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes") + + // Disconnect before the settle delay completes — should cancel the pending config start + radioConnectionState.value = ConnectionState.Disconnected + advanceTimeBy(200) + + // The want_config_id should NOT have been sent because the job was cancelled + val configPackets = sentPackets.filter { it.want_config_id != null } + assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect") + } + + @Test + fun `Disconnected state stops services`() = runTest(testDispatcher) { + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + manager = createManager(backgroundScope) // Transition to Connected first so that Disconnected actually does something radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -189,14 +232,9 @@ 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.start(backgroundScope) + manager = createManager(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -214,13 +252,8 @@ 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.start(backgroundScope) + manager = createManager(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -235,7 +268,7 @@ class MeshConnectionManagerImplTest { @Test fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) { - manager.start(backgroundScope) + manager = createManager(backgroundScope) val packetId = 456 everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket) every { workerManager.enqueueSendMessage(any()) } returns Unit @@ -256,15 +289,140 @@ class MeshConnectionManagerImplTest { moduleConfigFlow.value = moduleConfig every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit every { nodeManager.myNodeNum } returns MutableStateFlow(123) - every { mqttManager.start(any(), any(), any()) } returns Unit + every { mqttManager.startProxy(any(), any()) } returns Unit every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit every { nodeManager.getMyNodeInfo() } returns null - manager.start(backgroundScope) + manager = createManager(backgroundScope) manager.onNodeDbReady() advanceUntilIdle() - verify { mqttManager.start(any(), true, true) } + verify { mqttManager.startProxy(true, true) } verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } } + + @Test + fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) { + // Router with ls_secs=3600 — previously this created a 3630s timeout. + // With the cap, it should be clamped to 300s. + val config = + LocalConfig( + power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600), + device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), + ) + every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + + manager = createManager(backgroundScope) + advanceUntilIdle() + + // Transition to Connected then DeviceSleep + radioConnectionState.value = ConnectionState.Connected + advanceUntilIdle() + radioConnectionState.value = ConnectionState.DeviceSleep + advanceUntilIdle() + + assertEquals( + ConnectionState.DeviceSleep, + serviceRepository.connectionState.value, + "Should be in DeviceSleep initially", + ) + + // Advance 300 seconds (the cap) + 1 second to trigger the timeout. + advanceTimeBy(301_000L) + + assertEquals( + ConnectionState.Disconnected, + serviceRepository.connectionState.value, + "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 5f738b439..022608be1 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 3090cf49e..251aefabe 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,6 +23,7 @@ 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 @@ -65,22 +66,22 @@ class MeshMessageProcessorImplTest { every { nodeManager.isNodeDbReady } returns isNodeDbReady every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) every { router.dataHandler } returns dataHandler - - processor = - MeshMessageProcessorImpl( - nodeManager = nodeManager, - serviceRepository = serviceRepository, - meshLogRepository = lazy { meshLogRepository }, - router = lazy { router }, - fromRadioDispatcher = fromRadioDispatcher, - ) } + private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( + nodeManager = nodeManager, + serviceRepository = serviceRepository, + meshLogRepository = lazy { meshLogRepository }, + router = lazy { router }, + fromRadioDispatcher = fromRadioDispatcher, + scope = scope, + ) + // ---------- handleFromRadio: non-packet variants ---------- @Test fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) val logRecord = LogRecord(message = "test log") val fromRadio = FromRadio(log_record = logRecord) val bytes = FromRadio.ADAPTER.encode(fromRadio) @@ -93,7 +94,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(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") @@ -108,7 +109,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) // Invalid protobuf bytes — both parses should fail val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) @@ -121,7 +122,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -141,7 +142,7 @@ class MeshMessageProcessorImplTest { @Test fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -165,7 +166,7 @@ class MeshMessageProcessorImplTest { @Test fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false // The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test, @@ -195,7 +196,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -214,7 +215,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -235,7 +236,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -255,7 +256,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val senderNode = 999 @@ -279,7 +280,7 @@ class MeshMessageProcessorImplTest { @Test fun `packet with null decoded is skipped`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = MeshPacket(id = 1, from = 999, decoded = null) @@ -293,7 +294,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -315,7 +316,7 @@ class MeshMessageProcessorImplTest { @Test fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -342,7 +343,7 @@ class MeshMessageProcessorImplTest { @Test fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(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 022590467..509066867 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,6 +18,7 @@ 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 @@ -44,12 +45,13 @@ 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) + nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) } @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 fe89063ef..e0bda6075 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,6 +21,7 @@ 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 @@ -70,8 +71,8 @@ class PacketHandlerImplTest { radioInterfaceService, lazy { meshLogRepository }, serviceRepository, + testScope, ) - handler.start(testScope) } @Test @@ -84,6 +85,8 @@ class PacketHandlerImplTest { val toRadio = ToRadio(packet = MeshPacket(id = 123)) handler.sendToRadio(toRadio) + + verify { radioInterfaceService.sendToRadio(any()) } } @Test @@ -93,6 +96,8 @@ 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 e465aaa63..900245332 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 8f295a2b6..28bf22fdc 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/build.gradle.kts b/core/database/build.gradle.kts index 4622f1be8..4ebdfbb92 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -49,20 +49,16 @@ kotlin { } commonTest.dependencies { implementation(projects.core.testing) - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.androidx.room.testing) - implementation(libs.turbine) } val androidHostTest by getting { dependencies { implementation(libs.androidx.sqlite.bundled) implementation(libs.androidx.room.testing) - implementation(libs.androidx.test.core) implementation(libs.androidx.test.ext.junit) implementation(libs.junit) - implementation(libs.robolectric) } } val androidDeviceTest by getting { diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json new file mode 100644 index 000000000..c26991ac4 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json @@ -0,0 +1,1052 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 8062afa76..451a62174 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -20,7 +20,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -59,7 +59,7 @@ class MigrationTest { ) @Before - fun createDb(): Unit = runBlocking { + fun createDb(): Unit = runTest { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder( @@ -77,7 +77,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { + fun testMigrateChannelsByPSK_duplicatePSK() = runTest { // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() @@ -103,7 +103,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_reorder() = runBlocking { + fun testMigrateChannelsByPSK_reorder() = runTest { val pskA = byteArrayOf(0x01).toByteString() val pskB = byteArrayOf(0x02).toByteString() @@ -122,7 +122,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { + fun testMigrateChannelsByPSK_disambiguateByName() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") @@ -141,7 +141,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { + fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/model/NodeTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/model/NodeTest.kt deleted file mode 100644 index 163e03b9e..000000000 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/model/NodeTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model - -import org.meshtastic.proto.HardwareModel -import kotlin.test.Test -import kotlin.test.assertEquals - -class NodeTest { - - @Test - fun `createFallback produces expected node data`() { - val nodeNum = 0x12345678 - val prefix = "Node" - val node = Node.createFallback(nodeNum, prefix) - - assertEquals(nodeNum, node.num) - assertEquals("!12345678", node.user.id) - assertEquals("Node 5678", node.user.long_name) - assertEquals("5678", node.user.short_name) - assertEquals(HardwareModel.UNSET, node.user.hw_model) - } - - @Test - fun `createFallback pads short IDs with zeros`() { - val nodeNum = 0x1 - val prefix = "Node" - val node = Node.createFallback(nodeNum, prefix) - - assertEquals(nodeNum, node.num) - assertEquals("!00000001", node.user.id) - assertEquals("Node 0001", node.user.long_name) - assertEquals("0001", node.user.short_name) - } -} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/Converters.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/Converters.kt index 35746f68f..67433459c 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/Converters.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/Converters.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.database import androidx.room3.TypeConverter import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import okio.ByteString import okio.ByteString.Companion.toByteString @@ -33,10 +34,12 @@ import org.meshtastic.proto.User @Suppress("TooManyFunctions") class Converters { + @OptIn(ExperimentalSerializationApi::class) private val json = Json { isLenient = true ignoreUnknownKeys = true encodeDefaults = true + exceptionsWithDebugInfo = false } @TypeConverter fun dataFromString(value: String): DataPacket = json.decodeFromString(DataPacket.serializer(), value) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt index c917ee066..b2c89ad73 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.database import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.core.common.util.normalizeAddress object DatabaseConstants { const val DB_PREFIX: String = "meshtastic_database" @@ -40,17 +41,6 @@ object DatabaseConstants { const val ADDRESS_ANON_EDGE_LEN: Int = 2 } -fun normalizeAddress(addr: String?): String { - val u = addr?.trim()?.uppercase() - val normalized = - when { - u.isNullOrBlank() -> "DEFAULT" - u == "N" || u == "NULL" -> "DEFAULT" - else -> u.replace(":", "") - } - return normalized -} - fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN) fun buildDbName(address: String?): String = if (address.isNullOrBlank()) { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 8bfb1164e..108345265 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -23,6 +23,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob @@ -135,10 +136,12 @@ open class DatabaseManager( // Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp previousDbName?.let { markLastUsed(it) } - // Now safe to close the previous DB — collectors have switched to the new instance. - if (previousDbName != null && previousDbName != dbName) { - closeCachedDatabase(previousDbName) - } + // Do NOT close the previous DB synchronously here. Even though _currentDb has been + // updated, in-flight `withDb` calls may still hold a reference to the old database + // (captured before the emission). Closing the connection pool while those queries are + // executing causes "Connection pool is closed" crashes. Instead, let LRU eviction + // (enforceCacheLimit) handle cleanup — it only runs on databases that are not the + // active target and have not been used recently. // Defer LRU eviction so switch is not blocked by filesystem work managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = dbName) } @@ -167,11 +170,26 @@ open class DatabaseManager( private val limitedIo = dispatchers.io.limitedParallelism(4) /** Execute [block] with the current DB instance. */ + @Suppress("TooGenericExceptionCaught") override suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) { val db = _currentDb.value ?: return@withContext null val active = buildDbName(_currentAddress.value) markLastUsed(active) - block(db) + try { + block(db) + } catch (e: CancellationException) { + throw e // Preserve structured concurrency cancellation propagation. + } catch (e: Exception) { + // If the connection pool was closed between capturing `db` and executing the query + // (e.g., during a database switch), retry once with the current DB instance. + if (e.message?.contains("Connection pool is closed") == true) { + Logger.w { "withDb: connection pool closed, retrying with current DB" } + val retryDb = _currentDb.value ?: return@withContext null + block(retryDb) + } else { + throw e + } + } } /** Returns true if a database exists for the given device address. */ @@ -223,6 +241,7 @@ open class DatabaseManager( victims.forEach { name -> runCatching { + // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(name) deleteDatabase(name) datastore.edit { it.remove(lastUsedKey(name)) } @@ -248,6 +267,7 @@ open class DatabaseManager( if (fs.exists(legacyPath)) { runCatching { + // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(legacy) deleteDatabase(legacy) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 7bf9014ce..13451e5fc 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class), AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), + AutoMigration(from = 37, to = 38), ], - version = 37, + version = 38, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt index fcdc079f2..c1e399c97 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt @@ -17,18 +17,15 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import org.meshtastic.core.database.entity.DeviceHardwareEntity @Dao interface DeviceHardwareDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(deviceHardware: DeviceHardwareEntity) + @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(deviceHardware: List) + @Upsert suspend fun insertAll(deviceHardware: List) @Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel") suspend fun getByHwModel(hwModel: Int): List diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt index 0a5520a07..040941a49 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt @@ -17,16 +17,14 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import org.meshtastic.core.database.entity.FirmwareReleaseEntity import org.meshtastic.core.database.entity.FirmwareReleaseType @Dao interface FirmwareReleaseDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) + @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) @Query("DELETE FROM firmware_release") suspend fun deleteAll() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 967a97ec5..35d29c161 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog @Dao interface MeshLogDao { - @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem") + @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem") fun getAllLogs(maxItem: Int): Flow> - @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem") + @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem") fun getAllLogsInReceiveOrder(maxItem: Int): Flow> /** @@ -40,7 +40,7 @@ interface MeshLogDao { """ SELECT * FROM log WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) - ORDER BY received_date DESC LIMIT 0,:maxItem + ORDER BY received_date DESC LIMIT :maxItem """, ) fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 752619014..407a4d853 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -17,9 +17,7 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert import androidx.room3.MapColumn -import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Upsert @@ -37,6 +35,9 @@ interface NodeInfoDao { companion object { const val KEY_SIZE = 32 + + /** SQLite has a limit of ~999 bind parameters per query. */ + const val MAX_BIND_PARAMS = 999 } /** @@ -168,8 +169,7 @@ interface NodeInfoDao { @Query("SELECT * FROM my_node") fun getMyNodeInfo(): Flow - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun setMyNodeInfo(myInfo: MyNodeEntity) + @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity) @Query("DELETE FROM my_node") suspend fun clearMyNodeInfo() @@ -284,27 +284,99 @@ 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 suspend fun upsert(node: NodeEntity) { val verifiedNode = getVerifiedNodeForUpsert(node) doUpsert(verifiedNode) } - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun putAll(nodes: List) + @Upsert suspend fun putAll(nodes: List) @Query("UPDATE nodes SET notes = :notes WHERE num = :num") suspend fun setNodeNotes(num: Int, notes: String) + /** + * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two + * queries instead of N individual queries, then processes each node in memory. + */ + @Suppress("NestedBlockDepth") + private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List { + // Prepare all incoming nodes (populate denormalized fields) + incomingNodes.forEach { node -> + node.publicKey = node.user.public_key + if (node.user.hw_model != HardwareModel.UNSET) { + node.longName = node.user.long_name + node.shortName = node.user.short_name + } else { + node.longName = null + node.shortName = null + } + } + + // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit) + val existingNodesMap = + incomingNodes + .map { it.num } + .chunked(MAX_BIND_PARAMS) + .flatMap { getNodeEntitiesByNums(it) } + .associateBy { it.num } + + // Partition into updates vs. inserts and resolve existing nodes in-memory + val result = mutableListOf() + val newNodes = mutableListOf() + for (incoming in incomingNodes) { + val existing = existingNodesMap[incoming.num] + if (existing != null) { + result.add(handleExistingNodeUpsertValidation(existing, incoming)) + } else { + newNodes.add(incoming) + } + } + + // Batch validate new nodes' public keys (one query instead of N) + val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct() + val pkConflicts = + if (publicKeysToCheck.isNotEmpty()) { + publicKeysToCheck + .chunked(MAX_BIND_PARAMS) + .flatMap { findNodesByPublicKeys(it) } + .associateBy { it.publicKey } + } else { + emptyMap() + } + + for (newNode in newNodes) { + if ((newNode.publicKey?.size ?: 0) > 0) { + val conflicting = pkConflicts[newNode.publicKey] + if (conflicting != null && conflicting.num != newNode.num) { + result.add(conflicting) + } else { + result.add(newNode) + } + } else { + result.add(newNode) + } + } + + return result + } + @Transaction suspend fun installConfig(mi: MyNodeEntity, nodes: List) { clearMyNodeInfo() setMyNodeInfo(mi) - putAll(nodes.map { getVerifiedNodeForUpsert(it) }) + putAll(getVerifiedNodesForUpsert(nodes)) } /** diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 1419d51e7..c2ef9c516 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao import androidx.paging.PagingSource import androidx.room3.Dao +import androidx.room3.Insert import androidx.room3.MapColumn +import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Update @@ -307,6 +309,16 @@ interface PacketDao { ) suspend fun getPacketByPacketId(packetId: Int): PacketEntity? + @Transaction + @Query( + """ + SELECT * FROM packet + WHERE packet_id IN (:packetIds) + AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + """, + ) + suspend fun getPacketsByPacketIds(packetIds: List): List + @Query( """ SELECT * FROM packet @@ -326,8 +338,15 @@ interface PacketDao { ) suspend fun findPacketBySfppHash(hash: ByteString): Packet? - @Transaction - suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } + @Query( + """ + SELECT data FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND json_extract(data, '${"$"}.status') = 'QUEUED' + ORDER BY received_time ASC + """, + ) + suspend fun getQueuedPackets(): List @Query( """ @@ -359,23 +378,24 @@ interface PacketDao { @Upsert suspend fun upsertContactSettings(contacts: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertContactSettingsIgnore(contacts: List) + + @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)") + suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long) + @Transaction suspend fun setMuteUntil(contacts: List, until: Long) { - val contactList = contacts.map { contact -> - // Always mute - val absoluteMuteUntil = - if (until == Long.MAX_VALUE) { - Long.MAX_VALUE - } else if (until == 0L) { // unmute - 0L - } else { - nowMillis + until - } - - getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil) - ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil) - } - upsertContactSettings(contactList) + val absoluteMuteUntil = + when { + until == Long.MAX_VALUE -> Long.MAX_VALUE + until == 0L -> 0L + else -> nowMillis + until + } + // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data) + insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) }) + // Atomic column-level update — no read-then-write race + updateMuteUntil(contacts, absoluteMuteUntil) } @Upsert suspend fun insert(reaction: ReactionEntity) @@ -479,9 +499,10 @@ interface PacketDao { val indexMap = oldSettings .mapIndexed { oldIndex, oldChannel -> - val pskMatches = newSettings.mapIndexedNotNull { index, channel -> - if (channel.psk == oldChannel.psk) index to channel else null - } + val pskMatches = + newSettings.mapIndexedNotNull { index, channel -> + if (channel.psk == oldChannel.psk) index to channel else null + } val newIndex = when { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt index 2e7f6c549..fde388ce5 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt @@ -17,9 +17,8 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @@ -32,6 +31,5 @@ interface TracerouteNodePositionDao { @Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid") suspend fun deleteByLogUuid(logUuid: String) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(entities: List) + @Upsert suspend fun insertAll(entities: List) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 13d10193c..fed88eef9 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -118,6 +118,7 @@ data class MetadataEntity( Index(value = ["hops_away"]), Index(value = ["is_favorite"]), Index(value = ["last_heard", "is_favorite"]), + Index(value = ["public_key"]), ], ) data class NodeEntity( diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 16b1e66e4..d01171751 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -74,6 +74,9 @@ data class PacketEntity( Index(value = ["contact_key"]), Index(value = ["contact_key", "port_num", "received_time"]), Index(value = ["packet_id"]), + Index(value = ["received_time"]), + Index(value = ["filtered"]), + Index(value = ["read"]), ], ) data class Packet( @@ -98,9 +101,12 @@ data class Packet( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = nodes.filter { - it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = + nodes.filter { + it.num != ourNodeNum && + it.lastHeard != 0 && + (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt similarity index 100% rename from core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt rename to core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 6da9df5b7..71a7fef1c 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -271,6 +271,42 @@ abstract class CommonPacketDaoTest { assertFalse(excludingFiltered.any { it.packet.filtered }) } + @Test + fun testGetPacketsByPacketIdsChunked() = runTest { + // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and + // looking them up by id must not throw; callers are expected to chunk, and each chunk + // must return the correct rows. + val totalPackets = 2000 + val chunkSize = NodeInfoDao.MAX_BIND_PARAMS + val contactKey = "chunk-test" + val baseTime = nowMillis + val packetIds = (1..totalPackets).toList() + + packetIds.forEach { id -> + packetDao.insert( + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = PortNum.TEXT_MESSAGE_APP.value, + contact_key = contactKey, + received_time = baseTime + id, + read = false, + data = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = "Chunk $id".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ), + packetId = id, + ), + ) + } + + val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) } + assertEquals(totalPackets, fetched.size) + assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet()) + } + companion object { private const val SAMPLE_SIZE = 10 } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 903dde119..7d46cc831 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -24,7 +24,11 @@ plugins { kotlin { jvm() - android { namespace = "org.meshtastic.core.datastore" } + android { + namespace = "org.meshtastic.core.datastore" + androidResources.enable = false + withHostTest {} + } sourceSets { commonMain.dependencies { @@ -36,5 +40,11 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.okio) + } } } diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt index 94ef1c605..9de792a84 100644 --- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt +++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt @@ -50,7 +50,7 @@ class PreferencesDataStoreModule { @Named("CorePreferencesDataStore") fun providePreferencesDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), migrations = @@ -66,7 +66,7 @@ class LocalConfigDataStoreModule { @Named("CoreLocalConfigDataStore") fun provideLocalConfigDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule { @Named("CoreModuleConfigDataStore") fun provideModuleConfigDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -104,7 +104,7 @@ class ChannelSetDataStoreModule { @Named("CoreChannelSetDataStore") fun provideChannelSetDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -123,7 +123,7 @@ class LocalStatsDataStoreModule { @Named("CoreLocalStatsDataStore") fun provideLocalStatsDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt index aa81f1ac6..3cb3cabe8 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt @@ -24,10 +24,17 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher +/** + * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances. + * + * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules. + */ +const val DATASTORE_SCOPE = "DataStoreScope" + @Module @ComponentScan("org.meshtastic.core.datastore") class CoreDatastoreModule { @Single - @Named("DataStoreScope") + @Named(DATASTORE_SCOPE) fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) } diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt new file mode 100644 index 000000000..3acd29cb9 --- /dev/null +++ b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.datastore + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import okio.FileSystem +import okio.Path +import org.meshtastic.core.datastore.model.RecentAddress +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +class RecentAddressesDataSourceTest { + private lateinit var tmpDir: Path + private lateinit var dataSource: RecentAddressesDataSource + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @BeforeTest + fun setup() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + val dataStore = + PreferenceDataStoreFactory.createWithPath( + scope = testScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + dataSource = RecentAddressesDataSource(dataStore) + } + + @AfterTest + fun tearDown() { + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + // ---- recentAddresses flow ---- + + @Test + fun `recentAddresses emits empty list when no data stored`() = testScope.runTest { + val result = dataSource.recentAddresses.first() + assertTrue(result.isEmpty()) + } + + @Test + fun `setRecentAddresses persists and emits the list`() = testScope.runTest { + val addresses = + listOf( + RecentAddress(address = "192.168.1.1", name = "Home"), + RecentAddress(address = "10.0.0.1", name = "Office"), + ) + dataSource.setRecentAddresses(addresses) + + val result = dataSource.recentAddresses.first() + assertEquals(addresses, result) + } + + @Test + fun `setRecentAddresses overwrites previous value`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old"))) + dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New"))) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + // ---- add() LRU behaviour ---- + + @Test + fun `add to empty list stores single entry`() = testScope.runTest { + dataSource.add(RecentAddress("192.168.0.1", "Router")) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("192.168.0.1", result[0].address) + } + + @Test + fun `add prepends new address to front`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing"))) + dataSource.add(RecentAddress("2.2.2.2", "New")) + + val result = dataSource.recentAddresses.first() + assertEquals("2.2.2.2", result[0].address) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second"))) + dataSource.add(RecentAddress("2.2.2.2", "Second-updated")) + + val result = dataSource.recentAddresses.first() + assertEquals(2, result.size) + assertEquals("2.2.2.2", result[0].address) + assertEquals("Second-updated", result[0].name) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("4.4.4.4", "D")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("4.4.4.4", result[0].address) + assertEquals("1.1.1.1", result[1].address) + assertEquals("2.2.2.2", result[2].address) + assertFalse(result.any { it.address == "3.3.3.3" }) + } + + @Test + fun `add re-adding the same address at front keeps capacity`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("1.1.1.1", "A")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("1.1.1.1", result[0].address) + } + + // ---- remove() ---- + + @Test + fun `remove deletes the matching address`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"))) + dataSource.remove("1.1.1.1") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("2.2.2.2", result[0].address) + } + + @Test + fun `remove on unknown address is a no-op`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("9.9.9.9") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + } + + @Test + fun `remove last address yields empty list`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("1.1.1.1") + + assertTrue(dataSource.recentAddresses.first().isEmpty()) + } + + // ---- legacy JSON parsing (via LegacyParsingHarness) ---- + + @Test + fun `legacy JsonObject array is parsed correctly`() = testScope.runTest { + val legacyJson = + """[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("192.168.1.100", result[0].address) + assertEquals("NodeA", result[0].name) + assertEquals("192.168.1.101", result[1].address) + assertEquals("NodeB", result[1].name) + } + + @Test + fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest { + // Old clients stored plain IP strings with no name field + val legacyJson = """["192.168.1.50","10.0.0.2"]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("192.168.1.50", result[0].address) + assertEquals("Meshtastic", result[0].name) + assertEquals("10.0.0.2", result[1].address) + assertEquals("Meshtastic", result[1].name) + } + + @Test + fun `legacy JsonObject missing address field is skipped`() = testScope.runTest { + val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @Test + fun `legacy JsonObject missing name field is skipped`() = testScope.runTest { + val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + @Test + fun `legacy nested JsonArray entries are skipped`() = testScope.runTest { + val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @Test + fun `legacy mixed array handles all element types`() = testScope.runTest { + // JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray + val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("10.0.0.1", result[0].address) + assertEquals("Meshtastic", result[0].name) + assertEquals("10.0.0.2", result[1].address) + } +} + +/** + * Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass + * encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the + * production fallback path. + */ +private class LegacyParsingHarness(private val rawJson: String) { + val recentAddresses: Flow> = flow { + val jsonArray = Json.parseToJsonElement(rawJson).jsonArray + emit( + jsonArray.mapNotNull { item -> + when (item) { + is JsonObject -> { + val address = item["address"]?.jsonPrimitive?.contentOrNull + val name = item["name"]?.jsonPrimitive?.contentOrNull + if (address != null && name != null) { + RecentAddress(address = address, name = name) + } else { + null + } + } + is JsonPrimitive -> { + item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") } + } + is JsonArray -> null + } + }, + ) + } +} diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index e08765edb..918570a6d 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -39,15 +39,10 @@ kotlin { implementation(projects.core.resources) implementation(libs.kermit) - implementation(libs.compose.multiplatform.resources) implementation(libs.okio) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) } - commonTest.dependencies { - implementation(projects.core.testing) - implementation(kotlin("test")) - } - val androidHostTest by getting { dependencies { implementation(kotlin("test")) } } + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 092417ad9..16d94f20c 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -57,8 +57,8 @@ constructor( if (nodeNums.isEmpty()) return nodeRepository.deleteNodes(nodeNums) - val packetId = radioController.getPacketId() for (nodeNum in nodeNums) { + val packetId = radioController.getPacketId() radioController.removeByNodenum(packetId, nodeNum) } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt similarity index 74% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt rename to core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt index 5d9991e34..fa708d165 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt @@ -14,12 +14,14 @@ * 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 +package org.meshtastic.core.domain.usecase.settings import org.koin.core.annotation.Single +import org.meshtastic.core.repository.UiPrefs -/** Factory for creating `NopInterface` instances. */ @Single -class NopInterfaceFactory { - fun create(rest: String): NopInterface = NopInterface(rest) +open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) { + operator fun invoke(value: Int) { + uiPrefs.setContrastLevel(value) + } } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 4726457fd..92374706a 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -52,19 +52,14 @@ kotlin { api(libs.androidx.annotation) api(libs.androidx.core.ktx) } - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.robolectric) - implementation(libs.androidx.test.ext.junit) - } - } val androidDeviceTest by getting { dependencies { implementation(libs.androidx.test.ext.junit) implementation(libs.androidx.test.runner) } } + + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro deleted file mode 100644 index 5f75d687d..000000000 --- a/core/model/consumer-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ --keep class org.meshtastic.core.model.DataPacket --keep class org.meshtastic.core.model.DataPacket$CREATOR diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt deleted file mode 100644 index 473e482e2..000000000 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import org.meshtastic.core.common.util.nowInstant -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant -import java.text.DateFormat -import kotlin.time.Duration.Companion.hours - -private val DAY_DURATION = 24.hours - -/** - * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string - * representing the date. - * - * @param time The time in milliseconds - * @return Formatted date or time string, or null if time is 0 - */ -fun getShortDate(time: Long): String? { - if (time == 0L) return null - val instant = time.toInstant() - val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION - - return if (isWithin24Hours) { - DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate()) - } else { - DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate()) - } -} - -/** - * Calculates the remaining mute time in days and hours. - * - * @param remainingMillis The remaining time in milliseconds - * @return Pair of (days, hours), where days is Int and hours is Double - */ diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt index 13b0789de..99debb5ab 100644 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt +++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt @@ -17,12 +17,13 @@ package org.meshtastic.core.model.util import android.net.Uri +import com.eygraber.uri.toKmpUri import org.meshtastic.core.common.util.CommonUri import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact /** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */ -fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString()) +fun Uri.toCommonUri(): CommonUri = this.toKmpUri() /** Bridge extension for Android clients. */ fun Uri.dispatchMeshtasticUri( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 65096604f..4e02ae2a7 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) - /** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */ + /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */ 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.7.17. */ - val supportsStatusMessage = atLeast(V2_7_17) + /** Support for Status Message module. Supported since firmware v2.8.0. */ + val supportsStatusMessage = atLeast(V2_8_0) /** 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 0af5a0efd..c8bbdadb5 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,24 +16,16 @@ */ package org.meshtastic.core.model -sealed class ConnectionState { +sealed interface 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() - - fun isConnected() = this == Connected - - fun isConnecting() = this == Connecting - - fun isDisconnected() = this == Disconnected - - fun isDeviceSleep() = this == DeviceSleep + data object DeviceSleep : ConnectionState } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt new file mode 100644 index 000000000..4d3bfca10 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +/** + * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. + * + * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for + * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to + * depend on the MQTT library's exception types. + */ +sealed class MqttConnectionState { + /** The MQTT proxy has not been started (disabled or not yet initialized). */ + data object Inactive : MqttConnectionState() + + /** The MQTT client is actively connecting to the broker. */ + data object Connecting : MqttConnectionState() + + /** The MQTT client is connected and subscribed to topics. */ + data object Connected : MqttConnectionState() + + /** + * The MQTT client lost connection and is attempting to reconnect. + * + * @property attempt 1-based attempt counter for the current reconnect loop. + * @property lastError Localized message from the most recent reconnect failure, if any. + */ + data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState() + + /** + * The MQTT client is not connected to the broker. + * + * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial / + * intentional-close case (use [Idle]). + */ + data class Disconnected(val reason: String? = null) : MqttConnectionState() { + companion object { + /** Singleton for the idle / no-reason disconnected state. */ + val Idle: Disconnected = Disconnected(reason = null) + } + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt new file mode 100644 index 000000000..e3cb7c77a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +/** + * UI-friendly outcome of a one-shot MQTT broker reachability probe. + * + * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can + * consume the result without depending on the MQTT library. + */ +sealed class MqttProbeStatus { + /** Probe is currently in flight. */ + data object Probing : MqttProbeStatus() + + /** + * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are + * useful to surface to the user. + */ + data class Success(val serverInfo: String?) : MqttProbeStatus() + + /** Broker rejected the connection (CONNACK with non-zero reason code). */ + data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus() + + /** DNS lookup failed. */ + data class DnsFailure(val message: String?) : MqttProbeStatus() + + /** TCP socket could not be opened. */ + data class TcpFailure(val message: String?) : MqttProbeStatus() + + /** TLS handshake failed. */ + data class TlsFailure(val message: String?) : MqttProbeStatus() + + /** Probe exceeded its timeout. */ + data class Timeout(val timeoutMs: Long) : MqttProbeStatus() + + /** Any other / unclassified failure. */ + data class Other(val message: String?) : MqttProbeStatus() +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 13eccae2a..70dea8574 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -19,10 +19,9 @@ package org.meshtastic.core.model import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.GPSFormat +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.bearing -import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.latLongToMeter -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.proto.Config @@ -143,34 +142,26 @@ data class Node( private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List { val temp = if ((temperature ?: 0f) != 0f) { - if (isFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f)) - } else { - formatString("%.1f°C", temperature) - } + MetricFormatter.temperature(temperature ?: 0f, isFahrenheit) } else { null } - val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null + val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null val soilTemperatureStr = if ((soil_temperature ?: 0f) != 0f) { - if (isFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f)) - } else { - formatString("%.1f°C", soil_temperature) - } + MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit) } else { null } val soilMoistureRange = 0..100 val soilMoisture = if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) { - formatString("%d%%", soil_moisture) + MetricFormatter.percent(soil_moisture ?: 0) } else { null } - val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null - val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null + val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null + val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null return listOfNotNull( @@ -199,9 +190,12 @@ data class Node( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = nodes.filter { - it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = + nodes.filter { + it.num != ourNodeNum && + it.lastHeard != 0 && + (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 54797eb75..84994e628 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,7 +28,16 @@ import org.meshtastic.proto.ClientNotification */ @Suppress("TooManyFunctions") interface RadioController { - /** Reactive connection state of the radio. */ + /** + * 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. + */ val connectionState: StateFlow /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt similarity index 65% rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt index 7a9bb6627..97b5507ad 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt @@ -14,15 +14,24 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.map.model +package org.meshtastic.core.model +/** + * Represents a traceroute result with forward and return routes as ordered lists of node nums. + * + * @property requestId The mesh packet request ID that initiated this traceroute. + * @property forwardRoute Ordered node nums along the path towards the destination. + * @property returnRoute Ordered node nums along the return path back to the originator. + */ data class TracerouteOverlay( val requestId: Int, val forwardRoute: List = emptyList(), val returnRoute: List = emptyList(), ) { + /** All unique node nums involved in either route direction. */ val relatedNodeNums: Set = (forwardRoute + returnRoute).toSet() + /** True if at least one route direction contains nodes. */ val hasRoutes: Boolean get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index 6f27bb0e6..dfe70fd92 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -18,8 +18,11 @@ package org.meshtastic.core.model.util +import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.Telemetry /** @@ -32,7 +35,7 @@ val Any?.anonymize: String get() = this.anonymize() /** A version of anonymize that allows passing in a custom minimum length */ -fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" +fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null" // A toString that makes sure all newlines are removed (for nice logging). fun Any.toOneLineString() = this.toString().replace('\n', ' ') @@ -48,6 +51,24 @@ fun MeshPacket.toOneLineString(): String { return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') } +fun Channel.toOneLineString(): String { + // Redact the channel preshared key (psk) from logs. + val redactedFields = """(psk)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + +fun ModuleConfig.toOneLineString(): String { + // Redact MQTT credentials from logs. + val redactedFields = """(password|username)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + +fun MyNodeInfo.toOneLineString(): String { + // Redact the hardware unique identifier from logs. + val redactedFields = """(device_id)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + fun Any.toPIIString() = if (!isDebug) { "" } else { diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt similarity index 57% rename from core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt index a450b9856..252297754 100644 --- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.common.util +package org.meshtastic.core.model.util -/** JVM/Android implementation of string formatting. */ -actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args) +/** Common geographic constants for coordinate conversions. */ +object GeoConstants { + /** Multiplier to convert protobuf integer coordinates (1e-7 degree units) to decimal degrees. */ + const val DEG_D = 1e-7 + + /** Multiplier to convert protobuf integer heading values (1e-5 degree units) to decimal degrees. */ + const val HEADING_DEG = 1e-5 + + /** Mean radius of the Earth in meters, for haversine calculations. */ + const val EARTH_RADIUS_METERS = 6_371_000.0 +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt index ca035a7fd..ebdcc0f5e 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt @@ -16,7 +16,27 @@ */ package org.meshtastic.core.model.util +import okio.ByteString.Companion.toByteString + /** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */ -expect object SfppHasher { - fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray +object SfppHasher { + private const val HASH_SIZE = 16 + private const val INT_BYTES = 4 + private const val INT_COUNT = 3 + private const val SHIFT_8 = 8 + private const val SHIFT_16 = 16 + private const val SHIFT_24 = 24 + + fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT) + encryptedPayload.copyInto(input) + var offset = encryptedPayload.size + for (value in intArrayOf(to, from, id)) { + input[offset++] = value.toByte() + input[offset++] = (value shr SHIFT_8).toByte() + input[offset++] = (value shr SHIFT_16).toByte() + input[offset++] = (value shr SHIFT_24).toByte() + } + return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE) + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index b2e175382..4b3f5d149 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String { return if (changes.isEmpty()) { "No changes detected." } else { - "Changes:\n" + changes.joinToString("\n") + "Changes:\n${changes.joinToString("\n")}" } } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index ecaf88db6..365a47c61 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_7_17() { - assertFalse(caps("2.7.16").supportsStatusMessage) - assertTrue(caps("2.7.17").supportsStatusMessage) + fun supportsStatusMessage_requires_V2_8_0() { + assertFalse(caps("2.7.21").supportsStatusMessage) + assertTrue(caps("2.8.0").supportsStatusMessage) } @Test diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt new file mode 100644 index 000000000..a89f2b886 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt @@ -0,0 +1,133 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests for [evaluateTracerouteMapAvailability] — the pure function that determines whether a traceroute can be + * visualised on a map based on node position data. + */ +@Suppress("MagicNumber") +class RouteDiscoveryTest { + + @Test + fun ok_whenAllNodesHavePositions() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + val positioned = setOf(1, 2, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun ok_whenEndpointsPositioned_andIntermediateNot() { + // Endpoints (1 and 3) are positioned, intermediate (2) is not + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun missingEndpoints_whenForwardStartMissing() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + // Node 1 (forward start / back end) is missing from positioned set + val positioned = setOf(2, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun missingEndpoints_whenForwardEndMissing() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + // Node 3 (forward end / back start) is missing + val positioned = setOf(1, 2) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun noMappableNodes_whenNonePositioned() { + val forward = listOf(1, 2, 3) + val back = emptyList() + // No node in the routes has a position — but first check endpoints + // Endpoints 1 and 3 are missing → MissingEndpoints takes precedence + val positioned = emptySet() + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun noMappableNodes_whenEmptyRoutes() { + // Empty routes → no endpoints, no related nodes → NoMappableNodes + val result = evaluateTracerouteMapAvailability(emptyList(), emptyList(), setOf(1, 2)) + + assertEquals(TracerouteMapAvailability.NoMappableNodes, result) + } + + @Test + fun ok_whenOnlyForwardRoute_endpointsPositioned() { + // Only forward route, no return route + val forward = listOf(1, 2, 3) + val back = emptyList() + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun missingEndpoints_whenReturnRouteEndpointMissing() { + // Return route has different endpoints than forward (asymmetric path) + val forward = listOf(1, 2, 3) + val back = listOf(3, 4, 1) + // All forward endpoints (1, 3) are positioned, but checking back endpoints too + // back first = 3 (positioned), back last = 1 (positioned) → all endpoints OK + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun directRoute_withTwoNodes() { + val forward = listOf(1, 2) + val back = listOf(2, 1) + val positioned = setOf(1, 2) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } +} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt similarity index 95% rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt index 51f6a5c76..14dfd72c8 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt @@ -14,12 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.common +package org.meshtastic.core.model.util import kotlin.test.Test import kotlin.test.assertEquals -class ByteUtilsTest { +class CommonUtilsTest { @Test fun testByteArrayOfInts() { diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt new file mode 100644 index 000000000..917414e3d --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SfppHasherTest { + + @Test + fun outputIsAlways16Bytes() { + val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1) + assertEquals(16, hash.size) + } + + @Test + fun emptyPayloadProduces16Bytes() { + val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0) + assertEquals(16, hash.size) + } + + @Test + fun deterministicOutput() { + val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) + assertEquals(a.toList(), b.toList()) + } + + @Test + fun differentPayloadsProduceDifferentHashes() { + val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun differentIdsProduceDifferentHashes() { + val payload = byteArrayOf(0x10, 0x20) + val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100) + val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun differentFromProduceDifferentHashes() { + val payload = byteArrayOf(0x10, 0x20) + val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun maxIntValues() { + val hash = + SfppHasher.computeMessageHash( + byteArrayOf(0xFF.toByte()), + to = Int.MAX_VALUE, + from = Int.MAX_VALUE, + id = Int.MAX_VALUE, + ) + assertEquals(16, hash.size) + } + + @Test + fun littleEndianByteOrder() { + // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian) + val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0) + val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0) + // Different byte orderings must produce different hashes + assertNotEquals(hashA.toList(), hashB.toList()) + } +} diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt index 7545a00a7..d17abd4a3 100644 --- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt +++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt @@ -20,7 +20,3 @@ package org.meshtastic.core.model.util actual fun getShortDateTime(time: Long): String = "" actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size) - -actual object SfppHasher { - actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32) -} diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt deleted file mode 100644 index b1c25110b..000000000 --- a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest - -actual object SfppHasher { - private const val HASH_SIZE = 16 - private const val INT_BYTES = 4 - - actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(encryptedPayload) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array()) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array()) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array()) - return digest.digest().copyOf(HASH_SIZE) - } -} diff --git a/core/navigation/README.md b/core/navigation/README.md index 9927ebf7d..61e8b00ea 100644 --- a/core/navigation/README.md +++ b/core/navigation/README.md @@ -12,7 +12,7 @@ Contains serializable `NavKey` route classes/objects used by shared feature grap Parses Meshtastic deep-link URIs and synthesizes a typed backstack (for example `/nodes/1234/device-metrics`). ### 3. `NavigationConfig.kt` -Defines `MeshtasticNavSavedStateConfig` so Navigation 3 backstacks can be persisted/restored safely. +Defines `MeshtasticNavSavedStateConfig` using sealed interface hierarchies so Navigation 3 backstacks can be persisted/restored safely — new routes are auto-registered at compile time. ## Features - **Type-Safety**: Uses serializable `NavKey` routes instead of ad-hoc string routes. @@ -25,10 +25,10 @@ Feature modules depend on this module to define their entry points and navigate ```kotlin import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.NodesRoute fun openNodeDetail(backStack: NavBackStack, destNum: Int) { - backStack.add(NodesRoutes.NodeDetail(destNum)) + backStack.add(NodesRoute.NodeDetail(destNum)) } ``` diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 9b0977a2e..858229b69 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -33,6 +33,6 @@ kotlin { implementation(libs.kermit) } - commonTest.dependencies { implementation(kotlin("test")) } + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index 12f5a911c..ed28ebccd 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -59,11 +59,11 @@ object DeepLinkRouter { "messages", "quickchat", -> routeContacts(uri, pathSegments) - "connections" -> listOf(ConnectionsRoutes.ConnectionsGraph) + "connections" -> listOf(ConnectionsRoute.ConnectionsGraph) "map" -> routeMap(uri, pathSegments) "nodes" -> routeNodes(uri, pathSegments) "settings" -> routeSettings(pathSegments) - "channels" -> listOf(ChannelsRoutes.ChannelsGraph) + "channels" -> listOf(ChannelsRoute.ChannelsGraph) "firmware" -> routeFirmware(pathSegments) "wifi-provision" -> routeWifiProvision(uri) else -> { @@ -78,31 +78,31 @@ object DeepLinkRouter { return when (firstSegment) { "share" -> { val message = uri.getQueryParameter("message") ?: "" - listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.Share(message)) + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share(message)) } "quickchat" -> { - listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.QuickChat) + listOf(ContactsRoute.ContactsGraph, ContactsRoute.QuickChat) } "messages" -> { val contactKey = if (segments.size > 1) segments[1] else uri.getQueryParameter("contactKey") ?: "" val message = uri.getQueryParameter("message") ?: "" if (contactKey.isNotBlank()) { listOf( - ContactsRoutes.ContactsGraph, - ContactsRoutes.Messages(contactKey = contactKey, message = message), + ContactsRoute.ContactsGraph, + ContactsRoute.Messages(contactKey = contactKey, message = message), ) } else { - listOf(ContactsRoutes.ContactsGraph) + listOf(ContactsRoute.ContactsGraph) } } - else -> listOf(ContactsRoutes.ContactsGraph) + else -> listOf(ContactsRoute.ContactsGraph) } } private fun routeMap(uri: CommonUri, segments: List): List { val waypointIdStr = if (segments.size > 1) segments[1] else uri.getQueryParameter("waypointId") val waypointId = waypointIdStr?.toIntOrNull() - return listOf(MapRoutes.Map(waypointId)) + return listOf(MapRoute.Map(waypointId)) } private fun routeNodes(uri: CommonUri, segments: List): List { @@ -110,17 +110,17 @@ object DeepLinkRouter { val destNum = destNumStr?.toIntOrNull() return if (destNum == null) { - listOf(NodesRoutes.NodesGraph) + listOf(NodesRoute.NodesGraph) } else if (segments.size > 2) { val subRouteStr = segments[2].lowercase() val detailRouteFn = nodeDetailSubRoutes[subRouteStr] if (detailRouteFn != null) { - listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetailGraph(destNum), detailRouteFn(destNum)) + listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetailGraph(destNum), detailRouteFn(destNum)) } else { - listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum)) + listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum)) } } else { - listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum)) + listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum)) } } @@ -142,79 +142,79 @@ object DeepLinkRouter { } if (subRouteStr == null) { - return listOf(SettingsRoutes.SettingsGraph(destNum)) + return listOf(SettingsRoute.SettingsGraph(destNum)) } val subRoute = settingsSubRoutes[subRouteStr] return if (subRoute != null) { - listOf(SettingsRoutes.SettingsGraph(destNum), subRoute) + listOf(SettingsRoute.SettingsGraph(destNum), subRoute) } else { - listOf(SettingsRoutes.SettingsGraph(destNum)) + listOf(SettingsRoute.SettingsGraph(destNum)) } } private fun routeWifiProvision(uri: CommonUri): List { val address = uri.getQueryParameter("address") - return listOf(WifiProvisionRoutes.WifiProvision(address)) + return listOf(WifiProvisionRoute.WifiProvision(address)) } private fun routeFirmware(segments: List): List { val update = if (segments.size > 1) segments[1].lowercase() == "update" else false return if (update) { - listOf(FirmwareRoutes.FirmwareGraph, FirmwareRoutes.FirmwareUpdate) + listOf(FirmwareRoute.FirmwareGraph, FirmwareRoute.FirmwareUpdate) } else { - listOf(FirmwareRoutes.FirmwareGraph) + listOf(FirmwareRoute.FirmwareGraph) } } private val settingsSubRoutes: Map = mapOf( - "device-config" to SettingsRoutes.DeviceConfiguration, - "module-config" to SettingsRoutes.ModuleConfiguration, - "admin" to SettingsRoutes.Administration, - "user" to SettingsRoutes.User, - "channel" to SettingsRoutes.ChannelConfig, - "device" to SettingsRoutes.Device, - "position" to SettingsRoutes.Position, - "power" to SettingsRoutes.Power, - "network" to SettingsRoutes.Network, - "display" to SettingsRoutes.Display, - "lora" to SettingsRoutes.LoRa, - "bluetooth" to SettingsRoutes.Bluetooth, - "security" to SettingsRoutes.Security, - "mqtt" to SettingsRoutes.MQTT, - "serial" to SettingsRoutes.Serial, - "ext-notification" to SettingsRoutes.ExtNotification, - "store-forward" to SettingsRoutes.StoreForward, - "range-test" to SettingsRoutes.RangeTest, - "telemetry" to SettingsRoutes.Telemetry, - "canned-message" to SettingsRoutes.CannedMessage, - "audio" to SettingsRoutes.Audio, - "remote-hardware" to SettingsRoutes.RemoteHardware, - "neighbor-info" to SettingsRoutes.NeighborInfo, - "ambient-lighting" to SettingsRoutes.AmbientLighting, - "detection-sensor" to SettingsRoutes.DetectionSensor, - "paxcounter" to SettingsRoutes.Paxcounter, - "status-message" to SettingsRoutes.StatusMessage, - "traffic-management" to SettingsRoutes.TrafficManagement, - "tak" to SettingsRoutes.TAK, - "clean-node-db" to SettingsRoutes.CleanNodeDb, - "debug-panel" to SettingsRoutes.DebugPanel, - "about" to SettingsRoutes.About, - "filter-settings" to SettingsRoutes.FilterSettings, + "device-config" to SettingsRoute.DeviceConfiguration, + "module-config" to SettingsRoute.ModuleConfiguration, + "admin" to SettingsRoute.Administration, + "user" to SettingsRoute.User, + "channel" to SettingsRoute.ChannelConfig, + "device" to SettingsRoute.Device, + "position" to SettingsRoute.Position, + "power" to SettingsRoute.Power, + "network" to SettingsRoute.Network, + "display" to SettingsRoute.Display, + "lora" to SettingsRoute.LoRa, + "bluetooth" to SettingsRoute.Bluetooth, + "security" to SettingsRoute.Security, + "mqtt" to SettingsRoute.MQTT, + "serial" to SettingsRoute.Serial, + "ext-notification" to SettingsRoute.ExtNotification, + "store-forward" to SettingsRoute.StoreForward, + "range-test" to SettingsRoute.RangeTest, + "telemetry" to SettingsRoute.Telemetry, + "canned-message" to SettingsRoute.CannedMessage, + "audio" to SettingsRoute.Audio, + "remote-hardware" to SettingsRoute.RemoteHardware, + "neighbor-info" to SettingsRoute.NeighborInfo, + "ambient-lighting" to SettingsRoute.AmbientLighting, + "detection-sensor" to SettingsRoute.DetectionSensor, + "paxcounter" to SettingsRoute.Paxcounter, + "status-message" to SettingsRoute.StatusMessage, + "traffic-management" to SettingsRoute.TrafficManagement, + "tak" to SettingsRoute.TAK, + "clean-node-db" to SettingsRoute.CleanNodeDb, + "debug-panel" to SettingsRoute.DebugPanel, + "about" to SettingsRoute.About, + "filter-settings" to SettingsRoute.FilterSettings, ) private val nodeDetailSubRoutes: Map Route> = mapOf( - "device-metrics" to { destNum -> NodeDetailRoutes.DeviceMetrics(destNum) }, - "map" to { destNum -> NodeDetailRoutes.NodeMap(destNum) }, - "position" to { destNum -> NodeDetailRoutes.PositionLog(destNum) }, - "environment" to { destNum -> NodeDetailRoutes.EnvironmentMetrics(destNum) }, - "signal" to { destNum -> NodeDetailRoutes.SignalMetrics(destNum) }, - "power" to { destNum -> NodeDetailRoutes.PowerMetrics(destNum) }, - "traceroute" to { destNum -> NodeDetailRoutes.TracerouteLog(destNum) }, - "host-metrics" to { destNum -> NodeDetailRoutes.HostMetricsLog(destNum) }, - "pax" to { destNum -> NodeDetailRoutes.PaxMetrics(destNum) }, - "neighbors" to { destNum -> NodeDetailRoutes.NeighborInfoLog(destNum) }, + "device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) }, + "map" to { destNum -> NodeDetailRoute.PositionLog(destNum) }, + "position" to { destNum -> NodeDetailRoute.PositionLog(destNum) }, + "environment" to { destNum -> NodeDetailRoute.EnvironmentMetrics(destNum) }, + "signal" to { destNum -> NodeDetailRoute.SignalMetrics(destNum) }, + "power" to { destNum -> NodeDetailRoute.PowerMetrics(destNum) }, + "traceroute" to { destNum -> NodeDetailRoute.TracerouteLog(destNum) }, + "host-metrics" to { destNum -> NodeDetailRoute.HostMetricsLog(destNum) }, + "pax" to { destNum -> NodeDetailRoute.PaxMetrics(destNum) }, + "neighbors" to { destNum -> NodeDetailRoute.NeighborInfoLog(destNum) }, ) } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt index fe5c6225a..f52273f30 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt @@ -18,99 +18,28 @@ package org.meshtastic.core.navigation import androidx.navigation3.runtime.NavKey import androidx.savedstate.serialization.SavedStateConfiguration +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclassesOfSealed /** - * Shared polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used - * across Android and Desktop navigation graphs. + * Shared polymorphic serialization configuration for Navigation 3 saved-state support. Uses sealed interface + * hierarchies so that new routes are automatically registered at compile time — no manual `subclass()` calls needed. */ +@OptIn(ExperimentalSerializationApi::class) val MeshtasticNavSavedStateConfig = SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { - // Nodes - subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer()) - subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer()) - subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer()) - subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer()) - - // Node detail sub-screens - subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer()) - subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer()) - subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer()) - subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer()) - subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer()) - subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer()) - subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer()) - subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer()) - subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer()) - subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer()) - subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer()) - - // Conversations - subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer()) - subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer()) - subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer()) - subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer()) - subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer()) - - // Map - subclass(MapRoutes.Map::class, MapRoutes.Map.serializer()) - - // Firmware - subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer()) - subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer()) - - // Settings - subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer()) - subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer()) - subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer()) - subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer()) - subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer()) - - // Settings - Config routes - subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer()) - subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer()) - subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer()) - subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer()) - subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer()) - subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer()) - subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer()) - subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer()) - subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer()) - subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer()) - - // Settings - Module routes - subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer()) - subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer()) - subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer()) - subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer()) - subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer()) - subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer()) - subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer()) - subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer()) - subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer()) - subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer()) - subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer()) - subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer()) - subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer()) - subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer()) - subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer()) - subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer()) - - // Settings - Advanced routes - subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer()) - subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer()) - subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer()) - subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer()) - - // Channels - subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer()) - subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer()) - - // Connections - subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer()) - subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer()) + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() + subclassesOfSealed() } } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index b58b20f2b..7f43bf549 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -25,160 +25,172 @@ interface Route : NavKey interface Graph : Route -object ChannelsRoutes { - @Serializable data object ChannelsGraph : Graph +@Serializable +sealed interface ChannelsRoute : Route { + @Serializable data object ChannelsGraph : ChannelsRoute, Graph - @Serializable data object Channels : Route + @Serializable data object Channels : ChannelsRoute } -object ConnectionsRoutes { - @Serializable data object ConnectionsGraph : Graph +@Serializable +sealed interface ConnectionsRoute : Route { + @Serializable data object ConnectionsGraph : ConnectionsRoute, Graph - @Serializable data object Connections : Route + @Serializable data object Connections : ConnectionsRoute } -object ContactsRoutes { - @Serializable data object ContactsGraph : Graph +@Serializable +sealed interface ContactsRoute : Route { + @Serializable data object ContactsGraph : ContactsRoute, Graph - @Serializable data object Contacts : Route + @Serializable data object Contacts : ContactsRoute - @Serializable data class Messages(val contactKey: String, val message: String = "") : Route + @Serializable data class Messages(val contactKey: String, val message: String = "") : ContactsRoute - @Serializable data class Share(val message: String) : Route + @Serializable data class Share(val message: String) : ContactsRoute - @Serializable data object QuickChat : Route + @Serializable data object QuickChat : ContactsRoute } -object MapRoutes { - @Serializable data class Map(val waypointId: Int? = null) : Route +@Serializable +sealed interface MapRoute : Route { + @Serializable data class Map(val waypointId: Int? = null) : MapRoute } -object NodesRoutes { - @Serializable data object NodesGraph : Graph +@Serializable +sealed interface NodesRoute : Route { + @Serializable data object NodesGraph : NodesRoute, Graph - @Serializable data object Nodes : Route + @Serializable data object Nodes : NodesRoute - @Serializable data class NodeDetailGraph(val destNum: Int? = null) : Graph + @Serializable data class NodeDetailGraph(val destNum: Int? = null) : + NodesRoute, + Graph - @Serializable data class NodeDetail(val destNum: Int? = null) : Route + @Serializable data class NodeDetail(val destNum: Int? = null) : NodesRoute } -object NodeDetailRoutes { - @Serializable data class DeviceMetrics(val destNum: Int) : Route +@Serializable +sealed interface NodeDetailRoute : Route { + @Serializable data class DeviceMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class NodeMap(val destNum: Int) : Route + @Serializable data class PositionLog(val destNum: Int) : NodeDetailRoute - @Serializable data class PositionLog(val destNum: Int) : Route + @Serializable data class EnvironmentMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class EnvironmentMetrics(val destNum: Int) : Route + @Serializable data class SignalMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class SignalMetrics(val destNum: Int) : Route + @Serializable data class PowerMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class PowerMetrics(val destNum: Int) : Route + @Serializable data class TracerouteLog(val destNum: Int) : NodeDetailRoute - @Serializable data class TracerouteLog(val destNum: Int) : Route + @Serializable + data class TracerouteMap(val destNum: Int, val requestId: Int, val logUuid: String? = null) : NodeDetailRoute - @Serializable data class TracerouteMap(val destNum: Int, val requestId: Int, val logUuid: String? = null) : Route + @Serializable data class HostMetricsLog(val destNum: Int) : NodeDetailRoute - @Serializable data class HostMetricsLog(val destNum: Int) : Route + @Serializable data class PaxMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class PaxMetrics(val destNum: Int) : Route - - @Serializable data class NeighborInfoLog(val destNum: Int) : Route + @Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute } -object SettingsRoutes { - @Serializable data class SettingsGraph(val destNum: Int? = null) : Graph +@Serializable +sealed interface SettingsRoute : Route { + @Serializable data class SettingsGraph(val destNum: Int? = null) : + SettingsRoute, + Graph - @Serializable data class Settings(val destNum: Int? = null) : Route + @Serializable data class Settings(val destNum: Int? = null) : SettingsRoute - @Serializable data object DeviceConfiguration : Route + @Serializable data object DeviceConfiguration : SettingsRoute - @Serializable data object ModuleConfiguration : Route + @Serializable data object ModuleConfiguration : SettingsRoute - @Serializable data object Administration : Route + @Serializable data object Administration : SettingsRoute // region radio Config Routes - @Serializable data object User : Route + @Serializable data object User : SettingsRoute - @Serializable data object ChannelConfig : Route + @Serializable data object ChannelConfig : SettingsRoute - @Serializable data object Device : Route + @Serializable data object Device : SettingsRoute - @Serializable data object Position : Route + @Serializable data object Position : SettingsRoute - @Serializable data object Power : Route + @Serializable data object Power : SettingsRoute - @Serializable data object Network : Route + @Serializable data object Network : SettingsRoute - @Serializable data object Display : Route + @Serializable data object Display : SettingsRoute - @Serializable data object LoRa : Route + @Serializable data object LoRa : SettingsRoute - @Serializable data object Bluetooth : Route + @Serializable data object Bluetooth : SettingsRoute - @Serializable data object Security : Route + @Serializable data object Security : SettingsRoute // endregion // region module config routes - @Serializable data object MQTT : Route + @Serializable data object MQTT : SettingsRoute - @Serializable data object Serial : Route + @Serializable data object Serial : SettingsRoute - @Serializable data object ExtNotification : Route + @Serializable data object ExtNotification : SettingsRoute - @Serializable data object StoreForward : Route + @Serializable data object StoreForward : SettingsRoute - @Serializable data object RangeTest : Route + @Serializable data object RangeTest : SettingsRoute - @Serializable data object Telemetry : Route + @Serializable data object Telemetry : SettingsRoute - @Serializable data object CannedMessage : Route + @Serializable data object CannedMessage : SettingsRoute - @Serializable data object Audio : Route + @Serializable data object Audio : SettingsRoute - @Serializable data object RemoteHardware : Route + @Serializable data object RemoteHardware : SettingsRoute - @Serializable data object NeighborInfo : Route + @Serializable data object NeighborInfo : SettingsRoute - @Serializable data object AmbientLighting : Route + @Serializable data object AmbientLighting : SettingsRoute - @Serializable data object DetectionSensor : Route + @Serializable data object DetectionSensor : SettingsRoute - @Serializable data object Paxcounter : Route + @Serializable data object Paxcounter : SettingsRoute - @Serializable data object StatusMessage : Route + @Serializable data object StatusMessage : SettingsRoute - @Serializable data object TrafficManagement : Route + @Serializable data object TrafficManagement : SettingsRoute - @Serializable data object TAK : Route + @Serializable data object TAK : SettingsRoute // endregion // region advanced config routes - @Serializable data object CleanNodeDb : Route + @Serializable data object CleanNodeDb : SettingsRoute - @Serializable data object DebugPanel : Route + @Serializable data object DebugPanel : SettingsRoute - @Serializable data object About : Route + @Serializable data object About : SettingsRoute - @Serializable data object FilterSettings : Route + @Serializable data object FilterSettings : SettingsRoute // endregion } -object FirmwareRoutes { - @Serializable data object FirmwareGraph : Graph +@Serializable +sealed interface FirmwareRoute : Route { + @Serializable data object FirmwareGraph : FirmwareRoute, Graph - @Serializable data object FirmwareUpdate : Route + @Serializable data object FirmwareUpdate : FirmwareRoute } -object WifiProvisionRoutes { - @Serializable data object WifiProvisionGraph : Graph +@Serializable +sealed interface WifiProvisionRoute : Route { + @Serializable data object WifiProvisionGraph : WifiProvisionRoute, Graph - @Serializable data class WifiProvision(val address: String? = null) : Route + @Serializable data class WifiProvision(val address: String? = null) : WifiProvisionRoute } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt index b25a61081..a8b10a23e 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt @@ -32,16 +32,15 @@ import org.meshtastic.core.resources.nodes * and Desktop navigation shells. */ enum class TopLevelDestination(val label: StringResource, val route: Route) { - Conversations(Res.string.conversations, ContactsRoutes.ContactsGraph), - Nodes(Res.string.nodes, NodesRoutes.NodesGraph), - Map(Res.string.map, MapRoutes.Map()), - Settings(Res.string.bottom_nav_settings, SettingsRoutes.SettingsGraph()), - Connections(Res.string.connections, ConnectionsRoutes.ConnectionsGraph), + Conversations(Res.string.conversations, ContactsRoute.ContactsGraph), + Nodes(Res.string.nodes, NodesRoute.NodesGraph), + Map(Res.string.map, MapRoute.Map()), + Settings(Res.string.bottom_nav_settings, SettingsRoute.SettingsGraph()), + Connections(Res.string.connections, ConnectionsRoute.ConnectionsGraph), ; companion object { - fun fromNavKey(key: NavKey?): TopLevelDestination? = entries.find { dest -> - key?.let { it::class == dest.route::class } == true - } + fun fromNavKey(key: NavKey?): TopLevelDestination? = + entries.find { dest -> key?.let { it::class == dest.route::class } == true } } } diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt new file mode 100644 index 000000000..04bda7472 --- /dev/null +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt @@ -0,0 +1,410 @@ +/* + * 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.navigation + +import org.meshtastic.core.common.util.CommonUri +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DeepLinkRouterTest { + + private fun route(path: String): List<*>? { + val uri = CommonUri.parse("$DEEP_LINK_BASE_URI$path") + return DeepLinkRouter.route(uri) + } + + // region empty / unrecognized + + @Test + fun `empty path returns null`() { + assertNull(route("")) + } + + @Test + fun `unrecognized segment returns null`() { + assertNull(route("/unknown-page")) + } + + // endregion + + // region contacts / messages + + @Test + fun `share with message`() { + assertEquals( + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("hello world")), + route("/share?message=hello%20world"), + ) + } + + @Test + fun `share without message defaults to empty string`() { + assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("")), route("/share")) + } + + @Test + fun `quickchat routes to QuickChat`() { + assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.QuickChat), route("/quickchat")) + } + + @Test + fun `messages with contactKey path segment`() { + assertEquals( + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "abc123", message = "")), + route("/messages/abc123"), + ) + } + + @Test + fun `messages with contactKey query param`() { + assertEquals( + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "")), + route("/messages?contactKey=contact1"), + ) + } + + @Test + fun `messages with contactKey and message`() { + assertEquals( + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "hi")), + route("/messages/contact1?message=hi"), + ) + } + + @Test + fun `messages without contactKey returns graph only`() { + assertEquals(listOf(ContactsRoute.ContactsGraph), route("/messages")) + } + + // endregion + + // region connections + + @Test + fun `connections routes to ConnectionsGraph`() { + assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/connections")) + } + + // endregion + + // region map + + @Test + fun `map without waypointId`() { + assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map")) + } + + @Test + fun `map with waypointId path segment`() { + assertEquals(listOf(MapRoute.Map(waypointId = 42)), route("/map/42")) + } + + @Test + fun `map with waypointId query param`() { + assertEquals(listOf(MapRoute.Map(waypointId = 99)), route("/map?waypointId=99")) + } + + @Test + fun `map with invalid waypointId falls back to null`() { + assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map/not-a-number")) + } + + // endregion + + // region nodes + + @Test + fun `nodes root returns NodesGraph`() { + assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes")) + } + + @Test + fun `nodes with destNum returns NodeDetail`() { + assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)), route("/nodes/1234")) + } + + @Test + fun `nodes with destNum and device-metrics sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 1234), + NodeDetailRoute.DeviceMetrics(destNum = 1234), + ), + route("/nodes/1234/device-metrics"), + ) + } + + @Test + fun `nodes with destNum and map sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 5678), + NodeDetailRoute.PositionLog(destNum = 5678), + ), + route("/nodes/5678/map"), + ) + } + + @Test + fun `nodes with destNum and position sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.PositionLog(destNum = 100), + ), + route("/nodes/100/position"), + ) + } + + @Test + fun `nodes with destNum and environment sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.EnvironmentMetrics(destNum = 100), + ), + route("/nodes/100/environment"), + ) + } + + @Test + fun `nodes with destNum and signal sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.SignalMetrics(destNum = 100), + ), + route("/nodes/100/signal"), + ) + } + + @Test + fun `nodes with destNum and power sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.PowerMetrics(destNum = 100), + ), + route("/nodes/100/power"), + ) + } + + @Test + fun `nodes with destNum and traceroute sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.TracerouteLog(destNum = 100), + ), + route("/nodes/100/traceroute"), + ) + } + + @Test + fun `nodes with destNum and host-metrics sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.HostMetricsLog(destNum = 100), + ), + route("/nodes/100/host-metrics"), + ) + } + + @Test + fun `nodes with destNum and pax sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.PaxMetrics(destNum = 100), + ), + route("/nodes/100/pax"), + ) + } + + @Test + fun `nodes with destNum and neighbors sub-route`() { + assertEquals( + listOf( + NodesRoute.NodesGraph, + NodesRoute.NodeDetailGraph(destNum = 100), + NodeDetailRoute.NeighborInfoLog(destNum = 100), + ), + route("/nodes/100/neighbors"), + ) + } + + @Test + fun `nodes with destNum and unknown sub-route falls back to NodeDetail`() { + assertEquals( + listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)), + route("/nodes/1234/unknown-sub"), + ) + } + + @Test + fun `nodes with non-numeric destNum returns NodesGraph only`() { + assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes/not-a-number")) + } + + @Test + fun `nodes with destNum query param`() { + assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 9999)), route("/nodes?destNum=9999")) + } + + // endregion + + // region settings + + @Test + fun `settings root returns SettingsGraph`() { + assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings")) + } + + @Test + fun `settings with destNum`() { + assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = 1234)), route("/settings/1234")) + } + + @Test + fun `settings with destNum and sub-route`() { + assertEquals( + listOf(SettingsRoute.SettingsGraph(destNum = 1234), SettingsRoute.About), + route("/settings/1234/about"), + ) + } + + @Test + fun `settings with sub-route without destNum`() { + assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null), SettingsRoute.LoRa), route("/settings/lora")) + } + + @Test + fun `settings with unknown sub-route returns SettingsGraph only`() { + assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings/nonexistent-page")) + } + + @Test + fun `settings all known sub-routes resolve correctly`() { + val expectedSubRoutes = + mapOf( + "device-config" to SettingsRoute.DeviceConfiguration, + "module-config" to SettingsRoute.ModuleConfiguration, + "admin" to SettingsRoute.Administration, + "user" to SettingsRoute.User, + "channel" to SettingsRoute.ChannelConfig, + "device" to SettingsRoute.Device, + "position" to SettingsRoute.Position, + "power" to SettingsRoute.Power, + "network" to SettingsRoute.Network, + "display" to SettingsRoute.Display, + "lora" to SettingsRoute.LoRa, + "bluetooth" to SettingsRoute.Bluetooth, + "security" to SettingsRoute.Security, + "mqtt" to SettingsRoute.MQTT, + "serial" to SettingsRoute.Serial, + "ext-notification" to SettingsRoute.ExtNotification, + "store-forward" to SettingsRoute.StoreForward, + "range-test" to SettingsRoute.RangeTest, + "telemetry" to SettingsRoute.Telemetry, + "canned-message" to SettingsRoute.CannedMessage, + "audio" to SettingsRoute.Audio, + "remote-hardware" to SettingsRoute.RemoteHardware, + "neighbor-info" to SettingsRoute.NeighborInfo, + "ambient-lighting" to SettingsRoute.AmbientLighting, + "detection-sensor" to SettingsRoute.DetectionSensor, + "paxcounter" to SettingsRoute.Paxcounter, + "status-message" to SettingsRoute.StatusMessage, + "traffic-management" to SettingsRoute.TrafficManagement, + "tak" to SettingsRoute.TAK, + "clean-node-db" to SettingsRoute.CleanNodeDb, + "debug-panel" to SettingsRoute.DebugPanel, + "about" to SettingsRoute.About, + "filter-settings" to SettingsRoute.FilterSettings, + ) + + expectedSubRoutes.forEach { (slug, expectedRoute) -> + assertEquals( + listOf(SettingsRoute.SettingsGraph(destNum = null), expectedRoute), + route("/settings/$slug"), + "Settings sub-route '$slug' did not resolve to $expectedRoute", + ) + } + } + + // endregion + + // region channels + + @Test + fun `channels routes to ChannelsGraph`() { + assertEquals(listOf(ChannelsRoute.ChannelsGraph), route("/channels")) + } + + // endregion + + // region firmware + + @Test + fun `firmware root returns FirmwareGraph`() { + assertEquals(listOf(FirmwareRoute.FirmwareGraph), route("/firmware")) + } + + @Test + fun `firmware update returns FirmwareGraph and FirmwareUpdate`() { + assertEquals(listOf(FirmwareRoute.FirmwareGraph, FirmwareRoute.FirmwareUpdate), route("/firmware/update")) + } + + // endregion + + // region wifi-provision + + @Test + fun `wifi-provision without address`() { + assertEquals(listOf(WifiProvisionRoute.WifiProvision(address = null)), route("/wifi-provision")) + } + + @Test + fun `wifi-provision with address query param`() { + assertEquals( + listOf(WifiProvisionRoute.WifiProvision(address = "AA:BB:CC:DD:EE:FF")), + route("/wifi-provision?address=AA:BB:CC:DD:EE:FF"), + ) + } + + // endregion + + // region case insensitivity + + @Test + fun `route segments are case insensitive`() { + assertEquals(listOf(NodesRoute.NodesGraph), route("/Nodes")) + assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/CONNECTIONS")) + } + + // endregion +} 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 60ba3f6eb..c36375356 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 @@ -29,7 +29,7 @@ class MultiBackstackTest { val multiBackstack = MultiBackstack(startTab) val nodesStack = - NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) } + NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } val mapStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Map.route)) } multiBackstack.backStacks = @@ -51,7 +51,7 @@ class MultiBackstackTest { val multiBackstack = MultiBackstack(startTab) val nodesStack = - NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) } + NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack) assertEquals(2, multiBackstack.activeBackStack.size) @@ -68,7 +68,7 @@ class MultiBackstackTest { val multiBackstack = MultiBackstack(startTab) val nodesStack = - NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) } + NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack) multiBackstack.goBack() @@ -104,11 +104,42 @@ class MultiBackstackTest { val settingsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Settings.route)) } multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack) - val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About) + val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoute.About) multiBackstack.handleDeepLink(deepLinkPath) assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute) assertEquals(2, multiBackstack.activeBackStack.size) - assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last()) + 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/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt new file mode 100644 index 000000000..2f013a39c --- /dev/null +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt @@ -0,0 +1,146 @@ +/* + * 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.navigation + +import androidx.navigation3.runtime.NavKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class NavBackStackExtTest { + + // region replaceLast + + @Test + fun `replaceLast on non-empty list replaces the last element`() { + val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + stack.replaceLast(NodesRoute.NodeDetail(destNum = 42)) + + assertEquals(2, stack.size) + assertEquals(NodesRoute.NodesGraph, stack[0]) + assertEquals(NodesRoute.NodeDetail(destNum = 42), stack[1]) + } + + @Test + fun `replaceLast on single-element list replaces that element`() { + val stack = mutableListOf(NodesRoute.NodesGraph) + stack.replaceLast(SettingsRoute.SettingsGraph()) + + assertEquals(1, stack.size) + assertEquals(SettingsRoute.SettingsGraph(), stack[0]) + } + + @Test + fun `replaceLast on empty list adds the element`() { + val stack = mutableListOf() + stack.replaceLast(NodesRoute.Nodes) + + assertEquals(1, stack.size) + assertEquals(NodesRoute.Nodes, stack[0]) + } + + @Test + fun `replaceLast with same element does not mutate`() { + val route = NodesRoute.Nodes + val stack = mutableListOf(NodesRoute.NodesGraph, route) + stack.replaceLast(route) + + assertEquals(2, stack.size) + assertEquals(route, stack[1]) + } + + // endregion + + // region replaceAll + + @Test + fun `replaceAll replaces entire stack with new routes`() { + val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + val newRoutes = listOf(SettingsRoute.SettingsGraph(), SettingsRoute.About) + + stack.replaceAll(newRoutes) + + assertEquals(newRoutes, stack) + } + + @Test + fun `replaceAll with shorter list trims excess elements`() { + val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 42)) + val newRoutes = listOf(SettingsRoute.SettingsGraph()) + + stack.replaceAll(newRoutes) + + assertEquals(1, stack.size) + assertEquals(SettingsRoute.SettingsGraph(), stack[0]) + } + + @Test + fun `replaceAll with longer list appends new elements`() { + val stack = mutableListOf(NodesRoute.NodesGraph) + val newRoutes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 99)) + + stack.replaceAll(newRoutes) + + assertEquals(newRoutes, stack) + } + + @Test + fun `replaceAll with empty list clears the stack`() { + val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + + stack.replaceAll(emptyList()) + + assertEquals(0, stack.size) + } + + @Test + fun `replaceAll on empty stack with new routes populates it`() { + val stack = mutableListOf() + val newRoutes = listOf(ContactsRoute.ContactsGraph, ContactsRoute.Contacts) + + stack.replaceAll(newRoutes) + + assertEquals(newRoutes, stack) + } + + @Test + fun `replaceAll with identical routes does not mutate entries`() { + val routes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + val stack = routes.toMutableList() + + stack.replaceAll(routes) + + assertEquals(routes, stack) + } + + @Test + fun `replaceAll with partial overlap only changes differing elements`() { + val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1)) + val newRoutes = + listOf( + NodesRoute.NodesGraph, // same + SettingsRoute.About, // different + ) + + stack.replaceAll(newRoutes) + + assertEquals(2, stack.size) + assertEquals(NodesRoute.NodesGraph, stack[0]) + assertEquals(SettingsRoute.About, stack[1]) + } + + // endregion +} diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt new file mode 100644 index 000000000..293c567fc --- /dev/null +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt @@ -0,0 +1,209 @@ +/* + * 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.navigation + +import androidx.navigation3.runtime.NavKey +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies that all route subclasses registered in [MeshtasticNavSavedStateConfig] can round-trip through SavedState + * serialization. This catches: + * - Missing `@Serializable` annotations on new route subclasses + * - Sealed interfaces not registered in [NavigationConfig.kt] + * - Breaking changes in the `subclassesOfSealed` experimental API + */ +class NavigationConfigTest { + + /** + * Every concrete route instance that can appear in a backstack. When adding a new route, add a representative + * instance here — the test will fail if serialization is misconfigured. + */ + private val allRouteInstances: List = + listOf( + // ChannelsRoute + ChannelsRoute.ChannelsGraph, + ChannelsRoute.Channels, + // ConnectionsRoute + ConnectionsRoute.ConnectionsGraph, + ConnectionsRoute.Connections, + // ContactsRoute + ContactsRoute.ContactsGraph, + ContactsRoute.Contacts, + ContactsRoute.Messages(contactKey = "test-contact", message = "hello"), + ContactsRoute.Messages(contactKey = "test-contact"), + ContactsRoute.Share(message = "share-text"), + ContactsRoute.QuickChat, + // MapRoute + MapRoute.Map(), + MapRoute.Map(waypointId = 42), + // NodesRoute + NodesRoute.NodesGraph, + NodesRoute.Nodes, + NodesRoute.NodeDetailGraph(destNum = 1234), + NodesRoute.NodeDetailGraph(), + NodesRoute.NodeDetail(destNum = 5678), + NodesRoute.NodeDetail(), + // NodeDetailRoute + NodeDetailRoute.DeviceMetrics(destNum = 100), + NodeDetailRoute.PositionLog(destNum = 100), + NodeDetailRoute.EnvironmentMetrics(destNum = 100), + NodeDetailRoute.SignalMetrics(destNum = 100), + NodeDetailRoute.PowerMetrics(destNum = 100), + NodeDetailRoute.TracerouteLog(destNum = 100), + NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 200, logUuid = "uuid-123"), + NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 200), + NodeDetailRoute.HostMetricsLog(destNum = 100), + NodeDetailRoute.PaxMetrics(destNum = 100), + NodeDetailRoute.NeighborInfoLog(destNum = 100), + // SettingsRoute + SettingsRoute.SettingsGraph(), + SettingsRoute.SettingsGraph(destNum = 999), + SettingsRoute.Settings(), + SettingsRoute.Settings(destNum = 999), + SettingsRoute.DeviceConfiguration, + SettingsRoute.ModuleConfiguration, + SettingsRoute.Administration, + SettingsRoute.User, + SettingsRoute.ChannelConfig, + SettingsRoute.Device, + SettingsRoute.Position, + SettingsRoute.Power, + SettingsRoute.Network, + SettingsRoute.Display, + SettingsRoute.LoRa, + SettingsRoute.Bluetooth, + SettingsRoute.Security, + SettingsRoute.MQTT, + SettingsRoute.Serial, + SettingsRoute.ExtNotification, + SettingsRoute.StoreForward, + SettingsRoute.RangeTest, + SettingsRoute.Telemetry, + SettingsRoute.CannedMessage, + SettingsRoute.Audio, + SettingsRoute.RemoteHardware, + SettingsRoute.NeighborInfo, + SettingsRoute.AmbientLighting, + SettingsRoute.DetectionSensor, + SettingsRoute.Paxcounter, + SettingsRoute.StatusMessage, + SettingsRoute.TrafficManagement, + SettingsRoute.TAK, + SettingsRoute.CleanNodeDb, + SettingsRoute.DebugPanel, + SettingsRoute.About, + SettingsRoute.FilterSettings, + // FirmwareRoute + FirmwareRoute.FirmwareGraph, + FirmwareRoute.FirmwareUpdate, + // WifiProvisionRoute + WifiProvisionRoute.WifiProvisionGraph, + WifiProvisionRoute.WifiProvision(address = "AA:BB:CC:DD:EE:FF"), + WifiProvisionRoute.WifiProvision(), + ) + + @Test + fun `all route instances round-trip through SavedState serialization`() { + allRouteInstances.forEach { route -> + val savedState = encodeToSavedState(route, MeshtasticNavSavedStateConfig) + val decoded = decodeFromSavedState(savedState, MeshtasticNavSavedStateConfig) + assertEquals( + route, + decoded, + "Round-trip failed for ${route::class.simpleName}: encoded $route but decoded $decoded", + ) + } + } + + @Test + fun `all sealed route interfaces are represented in the route instances list`() { + // Verify we have at least one instance from each sealed route interface. + // This catches the case where a new sealed interface is added to Routes.kt + // but no instances are added to allRouteInstances above. + val representedInterfaces = + allRouteInstances + .map { route -> + when (route) { + is ChannelsRoute -> "ChannelsRoute" + is ConnectionsRoute -> "ConnectionsRoute" + is ContactsRoute -> "ContactsRoute" + is MapRoute -> "MapRoute" + is NodesRoute -> "NodesRoute" + is NodeDetailRoute -> "NodeDetailRoute" + is SettingsRoute -> "SettingsRoute" + is FirmwareRoute -> "FirmwareRoute" + is WifiProvisionRoute -> "WifiProvisionRoute" + else -> "Unknown(${route::class.simpleName})" + } + } + .toSet() + + val expectedInterfaces = + setOf( + "ChannelsRoute", + "ConnectionsRoute", + "ContactsRoute", + "MapRoute", + "NodesRoute", + "NodeDetailRoute", + "SettingsRoute", + "FirmwareRoute", + "WifiProvisionRoute", + ) + + assertEquals( + expectedInterfaces, + representedInterfaces, + "Missing sealed route interfaces in test coverage. " + + "Missing: ${expectedInterfaces - representedInterfaces}", + ) + } + + @Test + fun `route instances with default parameters serialize correctly`() { + // Specifically test routes with nullable/default params to catch + // serialization issues with optional fields. + val routesWithDefaults: List> = + listOf( + MapRoute.Map() to MapRoute.Map(waypointId = null), + NodesRoute.NodeDetailGraph() to NodesRoute.NodeDetailGraph(destNum = null), + NodesRoute.NodeDetail() to NodesRoute.NodeDetail(destNum = null), + SettingsRoute.SettingsGraph() to SettingsRoute.SettingsGraph(destNum = null), + SettingsRoute.Settings() to SettingsRoute.Settings(destNum = null), + WifiProvisionRoute.WifiProvision() to WifiProvisionRoute.WifiProvision(address = null), + ) + + routesWithDefaults.forEach { (defaultInstance, explicitNullInstance) -> + assertEquals( + defaultInstance, + explicitNullInstance, + "Default and explicit null should be equal for ${defaultInstance::class.simpleName}", + ) + + val savedDefault = encodeToSavedState(defaultInstance, MeshtasticNavSavedStateConfig) + val savedExplicit = encodeToSavedState(explicitNullInstance, MeshtasticNavSavedStateConfig) + + val decodedDefault = decodeFromSavedState(savedDefault, MeshtasticNavSavedStateConfig) + val decodedExplicit = decodeFromSavedState(savedExplicit, MeshtasticNavSavedStateConfig) + + assertEquals(decodedDefault, decodedExplicit) + } + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 8a5f3fb21..f2fb85d7f 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) - implementation(libs.kmqtt.client) - implementation(libs.kmqtt.common) + api(libs.meshtastic.mqtt.client) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kermit) implementation(libs.jetbrains.lifecycle.runtime) @@ -63,9 +63,6 @@ kotlin { commonTest.dependencies { implementation(projects.core.testing) implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - implementation(libs.kotest.assertions) - implementation(libs.kotest.property) } } } 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 28eb2175d..426c6700b 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,6 +17,7 @@ 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 @@ -25,21 +26,23 @@ 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 delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific - * [InterfaceFactory]. + * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport]. */ @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, @@ -48,13 +51,50 @@ class AndroidRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) - override fun isMockInterface(): Boolean = + override fun isMockTransport(): Boolean = buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true" - override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address) + 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 createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport { - // Fallback to legacy factory for Serial, Mocks, and NOPs - return interfaceFactory.value.createInterface(address, service) + 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") + } } } 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 deleted file mode 100644 index f33cedfae..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt +++ /dev/null @@ -1,66 +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 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/SerialInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt deleted file mode 100644 index f8c53313b..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt +++ /dev/null @@ -1,28 +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 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 deleted file mode 100644 index 8597fd060..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.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/SerialInterface.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt similarity index 76% rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt rename to core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt index 2e97cff75..0f7985276 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt @@ -17,38 +17,39 @@ 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.repository.RadioInterfaceService +import org.meshtastic.core.network.transport.HeartbeatSender +import org.meshtastic.core.repository.RadioTransportCallback import java.util.concurrent.atomic.AtomicReference -/** An interface that assumes we are talking to a meshtastic device via USB serial */ -class SerialInterface( - service: RadioInterfaceService, +/** An Android USB/serial [RadioTransport] implementation. */ +class SerialRadioTransport( + callback: RadioTransportCallback, + scope: CoroutineScope, private val usbRepository: UsbRepository, private val address: String, -) : StreamInterface(service) { +) : StreamTransport(callback, scope) { private var connRef = AtomicReference() - init { + private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]") + + override fun start() { connect() } - override fun onDeviceDisconnect(waitForStopped: Boolean) { + override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { connRef.get()?.close(waitForStopped) - super.onDeviceDisconnect(waitForStopped) + super.onDeviceDisconnect(waitForStopped, isPermanent) } override fun connect() { val deviceMap = usbRepository.serialDevices.value - val device = - if (deviceMap.containsKey(address)) { - deviceMap[address]!! - } else { - deviceMap.map { (_, driver) -> driver }.firstOrNull() - } + val device = deviceMap[address] ?: deviceMap.values.firstOrNull() if (device == null) { Logger.e { "[$address] Serial device not found at address" } } else { @@ -107,7 +108,10 @@ class SerialInterface( "Uptime: ${uptime}ms, " + "Packets RX: $packetsReceived ($bytesReceived bytes)" } - onDeviceDisconnect(false) + // USB unplug / cable error is transient — the transport will reconnect when + // the device is replugged or the OS re-enumerates the port. Only an explicit + // close() (user disconnects) should signal a permanent disconnect. + onDeviceDisconnect(waitForStopped = false, isPermanent = false) } }, ) @@ -119,7 +123,9 @@ class SerialInterface( } override fun keepAlive() { - Logger.d { "[$address] Serial 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() } } override fun sendBytes(p: ByteArray) { 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 deleted file mode 100644 index 003294448..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt +++ /dev/null @@ -1,27 +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 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 deleted file mode 100644 index 2539bc13c..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt +++ /dev/null @@ -1,27 +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 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 b2ccf6545..d8b14be03 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt @@ -87,6 +87,11 @@ internal class SerialConnectionImpl( port.open(usbDeviceConnection) port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + + // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as + // present and starts its serial-side Meshtastic protocol. Empirically, omitting these + // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at + // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion. port.dtr = true port.rts = true diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt deleted file mode 100644 index 720d2a522..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.repository - -import android.annotation.SuppressLint -import java.security.cert.X509Certificate -import javax.net.ssl.X509TrustManager - -@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") -@Suppress("EmptyFunctionBlock") -class TrustAllX509TrustManager : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - - override fun checkServerTrusted(chain: Array?, authType: String?) {} - - override fun getAcceptedIssuers(): Array = arrayOf() -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index b4773dff3..c5080ec14 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -54,9 +54,7 @@ class UsbRepository( _serialDevices .mapLatest { serialDevices -> val serialProber = usbSerialProberLazy.value - buildMap { - serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } } - } + buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } } } .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) @@ -83,6 +81,8 @@ class UsbRepository( processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() } } - private suspend fun refreshStateInternal() = - withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) } + private suspend fun refreshStateInternal() = withContext(dispatchers.default) { + val devices = usbManagerLazy.value?.deviceList ?: emptyMap() + _serialDevices.emit(devices) + } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt new file mode 100644 index 000000000..87c317024 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network + +/** + * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups. + * + * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on + * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry]. + */ +object HttpClientDefaults { + /** Timeout in milliseconds for connect, request, and socket operations. */ + const val TIMEOUT_MS = 30_000L + + /** Maximum number of automatic retries on server errors (5xx). */ + const val MAX_RETRIES = 3 + + /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */ + const val API_BASE_URL = "https://api.meshtastic.org/" +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt new file mode 100644 index 000000000..cabeb977a --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt @@ -0,0 +1,40 @@ +/* + * 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/di/CoreNetworkModule.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt index 37d5726b9..0fbed14a8 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/di/CoreNetworkModule.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.network.di +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module @@ -24,9 +25,12 @@ import org.koin.core.annotation.Single @Module @ComponentScan("org.meshtastic.core.network") class CoreNetworkModule { + @OptIn(ExperimentalSerializationApi::class) @Single fun provideJson(): Json = Json { + isLenient = true ignoreUnknownKeys = true coerceInputValues = true + exceptionsWithDebugInfo = false } } 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 2c5a02784..55856abf9 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,40 +38,41 @@ abstract class BaseRadioTransportFactory( override fun isAddressValid(address: String?): Boolean { val spec = address?.firstOrNull() ?: return false - return spec in - listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) || - spec == '!' || - isPlatformAddressValid(address) + return when (spec) { + InterfaceId.TCP.id, + InterfaceId.SERIAL.id, + InterfaceId.BLUETOOTH.id, + InterfaceId.MOCK.id, + '!', + -> true + else -> 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 = 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) + 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 } - /** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */ + /** Delegate to platform for Mock, TCP, or Serial/USB transports. */ 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 deleted file mode 100644 index 7a6a8daa1..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ /dev/null @@ -1,485 +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.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 - -/** - * 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") - 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 — reset failure counter - consecutiveFailures = 0 - 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" } - } 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)" - } - - // At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can - // start its sleep timeout. Use == (not >=) to fire exactly once; repeated - // onDisconnect signals would reset upstream state machines unnecessarily. - 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)" - } - // Do NOT call service.onDisconnect() here. The reconnect while-loop handles retries - // internally. Emitting DeviceSleep on every transient disconnect creates competing state - // transitions with MeshConnectionManagerImpl's sleep timeout. Instead, handleFailure() - // is called from the catch block after RECONNECT_FAILURE_THRESHOLD consecutive failures. - } - - 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" } - } - } - } - - 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 new file mode 100644 index 000000000..f2ba25804 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -0,0 +1,457 @@ +/* + * 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 new file mode 100644 index 000000000..e4d250796 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt @@ -0,0 +1,182 @@ +/* + * 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 deleted file mode 100644 index 5354f5500..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt +++ /dev/null @@ -1,30 +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 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 deleted file mode 100644 index aec9ec667..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt +++ /dev/null @@ -1,28 +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 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/MockInterfaceFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt deleted file mode 100644 index 492b5782c..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt +++ /dev/null @@ -1,26 +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 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 deleted file mode 100644 index 0f77cb5dc..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt +++ /dev/null @@ -1,30 +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 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/MockInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt similarity index 88% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index 4990ee7ab..f8edeaa73 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -17,6 +17,7 @@ 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 @@ -25,8 +26,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 @@ -55,9 +56,13 @@ private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Co private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY) -/** A simulated interface that is used for testing in the simulator */ +/** A simulated transport that is used for testing in the simulator. */ @Suppress("detekt:TooManyFunctions", "detekt:MagicNumber") -class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport { +class MockRadioTransport( + private val callback: RadioTransportCallback, + private val scope: CoroutineScope, + val address: String, +) : RadioTransport { companion object { private const val MY_NODE = 0x42424242 @@ -68,13 +73,22 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str // an infinite sequence of ints private val packetIdSequence = generateSequence { currentPacketId++ }.iterator() - init { - Logger.i { "Starting the mock interface" } - service.onConnect() // Tell clients they can use the API + override fun start() { + Logger.i { "Starting the mock transport" } + callback.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) @@ -83,11 +97,10 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str 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 interface $pr" } + else -> Logger.i { "Ignoring data sent to mock transport $pr" } } } @@ -127,12 +140,12 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str ) } - else -> Logger.i { "Ignoring admin sent to mock interface $d" } + else -> Logger.i { "Ignoring admin sent to mock transport $d" } } } - override fun close() { - Logger.i { "Closing the mock interface" } + override suspend fun close() { + Logger.i { "Closing the mock transport" } } // / Generate a fake text message from a node @@ -279,7 +292,7 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId), ) - private fun sendQueueStatus(msgId: Int) = service.handleFromRadio( + private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio( FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(), ) @@ -291,14 +304,14 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str toIn, Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId), ) - service.handleFromRadio(p.encode()) + callback.handleFromRadio(p.encode()) } // / Send a fake ack packet back if the sender asked for want_ack - private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch { + private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch { val packet = pr.packet ?: return@handledLaunch delay(2000) - service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) + callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) } private fun sendConfigResponse(configId: Int) { @@ -313,8 +326,8 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str 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 = @@ -353,6 +366,6 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str makeNodeStatus(MY_NODE + 1), ) - packets.forEach { p -> service.handleFromRadio(p.encode()) } + packets.forEach { p -> callback.handleFromRadio(p.encode()) } } } 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 deleted file mode 100644 index df77578bf..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt +++ /dev/null @@ -1,26 +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 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/NopInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt similarity index 66% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt index 27348635c..c8143b1c7 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt @@ -18,12 +18,19 @@ package org.meshtastic.core.network.radio import org.meshtastic.core.repository.RadioTransport -class NopInterface(val address: String) : 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 { override fun handleSendToRadio(p: ByteArray) { // No-op } - override fun close() { + override suspend fun close() { // No-op } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt similarity index 52% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt index 7414def38..8c689dbcb 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt @@ -17,10 +17,11 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch +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 @@ -28,43 +29,48 @@ import org.meshtastic.core.repository.RadioTransport * * Delegates framing logic to [StreamFrameCodec] from `core:network`. */ -abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport { +abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) : + RadioTransport { - private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface") + private val codec = + StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") - override fun close() { + override suspend fun close() { Logger.d { "Closing stream for good" } - onDeviceDisconnect(true) + onDeviceDisconnect(waitForStopped = true, isPermanent = true) } /** - * Tell MeshService our device has gone away, but wait for it to come back + * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop. * - * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the - * manager callbacks + * @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. */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean) { - service.onDisconnect( - isPermanent = true, - ) // if USB device disconnects it is definitely permanently gone, not sleeping) + protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) { + callback.onDisconnect(isPermanent = isPermanent) } protected open fun connect() { - // Before telling mesh service, send a few START1s to wake a sleeping device + // Before connecting, send a few START1s to wake a sleeping device sendBytes(StreamFrameCodec.WAKE_BYTES) // Now tell clients they can (finally use the api) - service.onConnect() + callback.onConnect() } + /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */ abstract fun sendBytes(p: ByteArray) - // If subclasses need to flush at the end of a packet they can implement + /** Flushes buffered bytes to the underlying stream. No-op by default. */ 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 - service.serviceScope.launch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } + scope.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 fe092fd7c..9efb9150b 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt @@ -17,6 +17,8 @@ package org.meshtastic.core.network.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.mqtt.ConnectionState import org.meshtastic.proto.MqttClientProxyMessage /** Interface defining the MQTT interactions used for proxying messages to and from the mesh. */ @@ -38,4 +40,7 @@ interface MQTTRepository { * @param retained Whether the message should be retained by the broker. */ fun publish(topic: String, data: ByteArray, retained: Boolean) + + /** Observable MQTT connection lifecycle state (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING). */ + val connectionState: StateFlow } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index a429b90ae..47cfb6f7a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -17,36 +17,47 @@ 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.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: org.meshtastic.core.di.CoroutineDispatchers, + dispatchers: CoroutineDispatchers, ) : MQTTRepository { companion object { @@ -54,22 +65,34 @@ 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 } - private var client: MQTTClient? = null - private val json = Json { ignoreUnknownKeys = true } + @Volatile private var client: MqttClient? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected.Idle) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false + } private val scope = CoroutineScope(dispatchers.default + SupervisorJob()) - private var clientJob: Job? = null private val publishSemaphore = Semaphore(20) override fun disconnect() { Logger.i { "MQTT Disconnecting" } - clientJob?.cancel() - clientJob = null + val c = client client = null + _connectionState.value = ConnectionState.Disconnected.Idle + scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } } } - @OptIn(ExperimentalUnsignedTypes::class) + @OptIn(ExperimentalSerializationApi::class) override val proxyMessageFlow: Flow = callbackFlow { val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: "unknown"}" val channelSet = radioConfigRepository.channelSetFlow.first() @@ -77,102 +100,144 @@ class MQTTRepositoryImpl( val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT - val (host, port) = - (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let { - it[0] to (it.getOrNull(1)?.toIntOrNull() ?: if (mqttConfig?.tls_enabled == true) 8883 else 1883) - } + val rawAddress = mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS + val endpoint = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true) val newClient = - MQTTClient( - mqttVersion = MQTTVersion.MQTT5, - address = host, - port = port, - tls = if (mqttConfig?.tls_enabled == true) TLSClientSettings() else null, - userName = mqttConfig?.username, - password = mqttConfig?.password?.encodeToByteArray()?.toUByteArray(), - clientId = ownerId, - publishReceived = { packet -> - val topic = packet.topicName - val payload = packet.payload?.toByteArray() - Logger.d { "MQTT received message on topic $topic (size: ${payload?.size ?: 0} bytes)" } - - if (topic.contains("/json/")) { - try { - val jsonStr = payload?.decodeToString() ?: "" - // Validate JSON by parsing it - json.decodeFromString(jsonStr) - Logger.d { "MQTT parsed JSON payload successfully" } - - trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain)) - } catch (e: 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, - ), - ) - } - }, - ) - + MqttClient(ownerId) { + keepAliveSeconds = KEEPALIVE_SECONDS + autoReconnect = true + username = mqttConfig?.username + mqttConfig?.password?.let { password(it) } + } client = newClient - clientJob = scope.launch { - try { - Logger.i { "MQTT Starting client loop for $host:$port" } - newClient.runSuspend() - } catch (e: io.github.davidepianca98.mqtt.MQTTException) { - Logger.e(e) { "MQTT Client loop error (MQTT)" } - close(e) - } catch (e: io.github.davidepianca98.socket.IOException) { - Logger.e(e) { "MQTT Client loop error (IO)" } - close(e) - } catch (e: kotlinx.coroutines.CancellationException) { - Logger.i { "MQTT Client loop cancelled" } - throw e - } - } - - // Subscriptions - val subscriptions = mutableListOf() - channelSet.subscribeList.forEach { globalId -> - subscriptions.add( - Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), - ) - if (mqttConfig?.json_enabled == true) { - subscriptions.add( - Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), + val subscriptions: List = buildList { + channelSet.subscribeList.forEach { globalId -> + add( + Subscription( + "$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", + maxQos = QoS.AT_LEAST_ONCE, + noLocal = true, + ), ) + 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))) - if (subscriptions.isNotEmpty()) { - Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } - newClient.subscribe(subscriptions) + // Collect from the SharedFlow before connecting to avoid missing retained messages + // that arrive immediately after SUBSCRIBE. + launch { newClient.messages.collect { msg -> processMessage(msg) } } + + // Forward the client's connection state to the repo-level StateFlow for UI observation. + launch { newClient.connectionState.collect { _connectionState.value = it } } + + // Retry the initial connect with exponential backoff. Once established, + // autoReconnect handles subsequent drops and re-subscribes internally. + launch { + var reconnectDelay = INITIAL_RECONNECT_DELAY_MS + while (true) { + val result = safeCatching { + Logger.i { "MQTT Connecting to $endpoint" } + newClient.connect(endpoint) + if (subscriptions.isNotEmpty()) { + Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } + newClient.subscribe(subscriptions) + } + Logger.i { "MQTT connected and subscribed" } + } + when { + result.isSuccess -> return@launch + result.exceptionOrNull() is MqttException.ConnectionRejected -> { + Logger.e(result.exceptionOrNull()) { "MQTT connection rejected (unrecoverable), stopping" } + close(result.exceptionOrNull()!!) + return@launch + } + else -> { + Logger.e(result.exceptionOrNull()) { "MQTT connect failed, retrying in ${reconnectDelay}ms" } + delay(reconnectDelay) + reconnectDelay = + (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) + } + } + } } awaitClose { disconnect() } } - @OptIn(ExperimentalUnsignedTypes::class) + @OptIn(ExperimentalSerializationApi::class) + private fun ProducerScope.processMessage(msg: MqttMessage) { + val topic = msg.topic + val payload = msg.payload.toByteArray() + Logger.d { "MQTT received message on topic $topic (size: ${payload.size} bytes)" } + + if (topic.contains("/json/")) { + try { + val jsonStr = payload.decodeToString() + json.decodeFromString(jsonStr) + Logger.d { "MQTT parsed JSON payload successfully" } + trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = msg.retain)) + } catch (e: JsonDecodingException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } + } catch (e: SerializationException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } catch (e: IllegalArgumentException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } + } else { + trySend(MqttClientProxyMessage(topic = topic, data_ = payload.toByteString(), retained = msg.retain)) + } + } + override fun publish(topic: String, data: ByteArray, retained: Boolean) { + val currentClient = client + if (currentClient == null) { + 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 { - client?.publish( - retain = retained, - qos = Qos.AT_LEAST_ONCE, - topic = topic, - payload = data.toUByteArray(), - ) + 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" } } } } } } + +/** + * 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 1e12344b4..6c15478d9 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -23,17 +23,26 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases +/** Client for the Meshtastic public API (device hardware catalog and firmware releases). */ interface ApiService { + /** Fetches the device hardware catalog from the Meshtastic API. */ suspend fun getDeviceHardware(): List + /** Fetches the list of available firmware releases from the Meshtastic API. */ suspend fun getFirmwareReleases(): NetworkFirmwareReleases } -@Single +/** + * Ktor-based [ApiService] implementation. + * + * Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules. + * + * Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`) + * provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines. + */ +@Single(binds = []) class ApiServiceImpl(private val client: HttpClient) : ApiService { - override suspend fun getDeviceHardware(): List = - client.get("https://api.meshtastic.org/resource/deviceHardware").body() + override suspend fun getDeviceHardware(): List = client.get("resource/deviceHardware").body() - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = - client.get("https://api.meshtastic.org/github/firmware/list").body() + override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body() } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt new file mode 100644 index 000000000..045d3b7ec --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.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/BleRadioInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt deleted file mode 100644 index 342a4a766..000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt +++ /dev/null @@ -1,127 +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 dev.mokkery.MockMode -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.testing.FakeBleConnection -import org.meshtastic.core.testing.FakeBleConnectionFactory -import org.meshtastic.core.testing.FakeBleDevice -import org.meshtastic.core.testing.FakeBleScanner -import org.meshtastic.core.testing.FakeBluetoothRepository -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class BleRadioInterfaceTest { - - private val testScope = TestScope() - private val scanner = FakeBleScanner() - private val bluetoothRepository = FakeBluetoothRepository() - private val connection = FakeBleConnection() - private val connectionFactory = FakeBleConnectionFactory(connection) - private val service: RadioInterfaceService = mock(MockMode.autofill) - private val address = "00:11:22:33:44:55" - - @BeforeTest - fun setup() { - bluetoothRepository.setHasPermissions(true) - bluetoothRepository.setBluetoothEnabled(true) - } - - @Test - fun `connect attempts to scan and connect via init`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Device") - scanner.emitDevice(device) - - val bleInterface = - BleRadioInterface( - serviceScope = testScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - service = service, - address = address, - ) - - // 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, bleInterface.address) - } - - @Test - fun `address returns correct value`() { - val bleInterface = - BleRadioInterface( - serviceScope = testScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - service = service, - address = address, - ) - assertEquals(address, bleInterface.address) - } - - /** - * 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 (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 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 bleInterface = - BleRadioInterface( - serviceScope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - service = service, - address = address, - ) - - // 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(18_001L) - - verify { service.onDisconnect(any(), any()) } - - // Cancel the reconnect loop so runTest can complete. - bleInterface.close() - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt new file mode 100644 index 000000000..840dc214a --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt @@ -0,0 +1,173 @@ +/* + * 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 dev.mokkery.MockMode +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.verify.VerifyMode +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.model.RadioNotConnectedException +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.testing.FakeBleConnection +import org.meshtastic.core.testing.FakeBleConnectionFactory +import org.meshtastic.core.testing.FakeBleDevice +import org.meshtastic.core.testing.FakeBleScanner +import org.meshtastic.core.testing.FakeBluetoothRepository +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class BleRadioTransportTest { + + private val testScope = TestScope() + private val scanner = FakeBleScanner() + private val bluetoothRepository = FakeBluetoothRepository() + private val connection = FakeBleConnection() + private val connectionFactory = FakeBleConnectionFactory(connection) + private val service: RadioInterfaceService = mock(MockMode.autofill) + private val address = "00:11:22:33:44:55" + + @BeforeTest + fun setup() { + bluetoothRepository.setHasPermissions(true) + bluetoothRepository.setBluetoothEnabled(true) + } + + @Test + fun `connect attempts to scan and connect via start`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Device") + scanner.emitDevice(device) + + val bleTransport = + BleRadioTransport( + scope = testScope, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // start() begins 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) + } + + @Test + fun `address returns correct value`() { + val bleTransport = + BleRadioTransport( + scope = testScope, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + assertEquals(address, bleTransport.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]). + * + * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1 + * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms — + * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24 + * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called + */ + @Test + fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest { + 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, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // Advance through exactly 3 failure iterations (≈24 001 ms virtual time). + // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended + // and advanceTimeBy returns cleanly. + advanceTimeBy(24_001L) + + verify { service.onDisconnect(any(), any()) } + + // Cancel the reconnect loop so runTest can complete. + bleTransport.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. + * + * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw + + * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s + * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance. + */ + @Test + fun `reconnect loop never gives up - no permanent disconnect from policy`() = 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, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // Run well past where the legacy policy (maxFailures = 10) would have given up. + advanceTimeBy(800_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()) } + + bleTransport.close() + } +} 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 new file mode 100644 index 000000000..a6a7aa82c --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt @@ -0,0 +1,277 @@ +/* + * 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 007b82b45..f3514c752 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,51 +19,52 @@ 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 [BleRadioInterface] when consecutive connection attempts fail. The + * Tests the exponential backoff schedule used by [BleRadioTransport] 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_000L, computeReconnectBackoffMs(0)) + assertEquals(5.seconds, computeReconnectBackoff(0)) } @Test fun `first failure yields 5s`() { - assertEquals(5_000L, computeReconnectBackoffMs(1)) + assertEquals(5.seconds, computeReconnectBackoff(1)) } @Test fun `second failure yields 10s`() { - assertEquals(10_000L, computeReconnectBackoffMs(2)) + assertEquals(10.seconds, computeReconnectBackoff(2)) } @Test fun `third failure yields 20s`() { - assertEquals(20_000L, computeReconnectBackoffMs(3)) + assertEquals(20.seconds, computeReconnectBackoff(3)) } @Test fun `fourth failure yields 40s`() { - assertEquals(40_000L, computeReconnectBackoffMs(4)) + assertEquals(40.seconds, computeReconnectBackoff(4)) } @Test fun `fifth failure is capped at 60s`() { - assertEquals(60_000L, computeReconnectBackoffMs(5)) + assertEquals(60.seconds, computeReconnectBackoff(5)) } @Test fun `large failure count stays capped at 60s`() { - assertEquals(60_000L, computeReconnectBackoffMs(100)) + assertEquals(60.seconds, computeReconnectBackoff(100)) } @Test fun `backoff is strictly increasing up to the cap`() { - val values = (1..5).map { computeReconnectBackoffMs(it) } + val values = (1..5).map { computeReconnectBackoff(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/StreamInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt similarity index 75% rename from core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt rename to core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt index 4c4e9b4be..6faa69217 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt @@ -17,8 +17,6 @@ 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 @@ -29,17 +27,16 @@ 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.RadioInterfaceService -import kotlin.test.BeforeTest +import org.meshtastic.core.repository.RadioTransportCallback import kotlin.test.Test import kotlin.test.assertTrue -class StreamInterfaceTest { +class StreamTransportTest { - private val radioService: RadioInterfaceService = mock(MockMode.autofill) - private lateinit var fakeStream: FakeStreamInterface + private val callback: RadioTransportCallback = mock(MockMode.autofill) + private lateinit var fakeStream: FakeStreamTransport - class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) { + class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) { val sentBytes = mutableListOf() override fun sendBytes(p: ByteArray) { @@ -59,21 +56,18 @@ class StreamInterfaceTest { public override fun connect() = super.connect() } - @BeforeTest - fun setUp() { - every { radioService.serviceScope } returns TestScope() - } + private val testScope = TestScope() @Test fun `handleSendToRadio property test`() = runTest { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) } } @Test fun `readChar property test`() = runTest { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data -> data.forEach { fakeStream.feed(it) } @@ -83,11 +77,11 @@ class StreamInterfaceTest { @Test fun `connect sends wake bytes`() { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) fakeStream.connect() assertTrue(fakeStream.sentBytes.isNotEmpty()) assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES)) - verify { radioService.onConnect() } + verify { callback.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 73e096da9..26b83a420 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt @@ -18,25 +18,82 @@ package org.meshtastic.core.network.repository import kotlinx.serialization.json.Json import org.meshtastic.core.model.MqttJsonPayload +import org.meshtastic.mqtt.MqttEndpoint import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertTrue class MQTTRepositoryImplTest { - @Test - fun `test address parsing logic`() { - val address1 = "mqtt.example.com:1883" - val (host1, port1) = address1.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host1) - assertEquals(1883, port1) + // region resolveEndpoint — every behavioral branch of address parsing. - val address2 = "mqtt.example.com" - val (host2, port2) = address2.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host2) - assertEquals(1883, port2) + @Test + fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com/mqtt", ws.url) } + @Test + fun `bare host with TLS enabled is upgraded to wss`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/mqtt", ws.url) + } + + @Test + fun `host with explicit port is preserved when wrapped`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:9001/mqtt", ws.url) + } + + @Test + fun `address with ws scheme is parsed as-is and tls flag is ignored`() { + // tlsEnabled is intentionally true here — when the user supplies a full URL we + // must honor whatever scheme they provided, not silently upgrade it. + val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:8080/custom-path", ws.url) + } + + @Test + fun `address with wss scheme is parsed as-is`() { + val endpoint = resolveEndpoint(rawAddress = "wss://broker.example.com/secure-mqtt", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/secure-mqtt", ws.url) + } + + @Test + fun `address with mqtt tcp scheme is parsed as Tcp endpoint`() { + val endpoint = resolveEndpoint(rawAddress = "mqtt://broker.example.com:1883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(1883, tcp.port) + assertEquals(false, tcp.tls) + } + + @Test + fun `address with mqtts tcp scheme is parsed as Tcp endpoint with tls true`() { + val endpoint = resolveEndpoint(rawAddress = "mqtts://broker.example.com:8883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(8883, tcp.port) + assertEquals(true, tcp.tls) + } + + // endregion + + // region MqttJsonPayload — keep the existing JSON contract tests. + @Test fun `test json payload parsing`() { val jsonStr = @@ -72,4 +129,6 @@ class MQTTRepositoryImplTest { assertTrue(jsonStr.contains("\"from\":12345678")) assertTrue(jsonStr.contains("\"payload\":\"Hello World\"")) } + + // endregion } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt deleted file mode 100644 index adab96d4d..000000000 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt +++ /dev/null @@ -1,91 +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 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 new file mode 100644 index 000000000..202d8de57 --- /dev/null +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt @@ -0,0 +1,96 @@ +/* + * 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 553d9a49a..172423470 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,7 +24,6 @@ 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 @@ -34,13 +33,14 @@ 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. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class - * only exposes [sendHeartbeat] for external callers. + * 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. * * Used by Android and Desktop via the shared `SharedRadioInterfaceService`. */ @@ -65,6 +65,10 @@ 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 @@ -84,22 +88,35 @@ class TcpTransport( ) // TCP socket state - private var socket: Socket? = null - private var outStream: OutputStream? = null - private var connectionJob: Job? = null - private var currentAddress: String? = null + @Volatile private var socket: Socket? = null + + @Volatile private var outStream: OutputStream? = null + + @Volatile private var connectionJob: Job? = null + + @Volatile private var currentAddress: String? = null // Metrics - 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 + @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) /** Whether the transport is currently connected. */ val isConnected: Boolean - get() = socket?.isConnected == true && !socket!!.isClosed + get() { + val s = socket ?: return false + return s.isConnected && !s.isClosed + } /** * Start a TCP connection to the given address with automatic reconnect. @@ -127,11 +144,14 @@ class TcpTransport( */ suspend fun sendPacket(payload: ByteArray) { codec.frameAndSend(payload = payload, sendBytes = ::sendBytesRaw, flush = ::flushBytes) + packetsSent++ + bytesSent += payload.size } - /** Send a heartbeat packet to keep the connection alive. */ + /** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */ suspend fun sendHeartbeat() { - val heartbeat = ToRadio(heartbeat = Heartbeat()) + val nonce = heartbeatNonce.getAndIncrement() + val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce)) sendPacket(heartbeat.encode()) } @@ -283,8 +303,6 @@ class TcpTransport( Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" } return } - packetsSent++ - bytesSent += p.size try { stream.write(p) } catch (ex: IOException) { 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 6a8dfa93a..45ba70eb7 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -19,16 +19,19 @@ package org.meshtastic.core.network import co.touchlab.kermit.Logger import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPortTimeoutException -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import org.meshtastic.core.network.radio.StreamInterface -import org.meshtastic.core.repository.RadioInterfaceService +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 java.io.File /** - * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet + * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] 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 @@ -38,11 +41,15 @@ class SerialTransport private constructor( private val portName: String, private val baudRate: Int = DEFAULT_BAUD_RATE, - service: RadioInterfaceService, -) : StreamInterface(service) { + callback: RadioTransportCallback, + scope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : StreamTransport(callback, scope) { 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 { @@ -54,7 +61,7 @@ private constructor( port.setDTR() port.setRTS() Logger.i { "[$portName] Serial port opened (baud=$baudRate)" } - super.connect() // Sends WAKE_BYTES and signals service.onConnect() + super.connect() // Sends WAKE_BYTES and signals callback.onConnect() startReadLoop(port) true } else { @@ -71,7 +78,7 @@ private constructor( private fun startReadLoop(port: SerialPort) { Logger.d { "[$portName] Starting serial read loop" } readJob = - service.serviceScope.launch(Dispatchers.IO) { + scope.launch(dispatchers.io) { val input = port.inputStream val buffer = ByteArray(READ_BUFFER_SIZE) try { @@ -88,7 +95,7 @@ private constructor( } } catch (_: SerialPortTimeoutException) { // Expected timeout when no data is available - } catch (e: kotlinx.coroutines.CancellationException) { + } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -99,7 +106,7 @@ private constructor( reading = false } } - } catch (e: kotlinx.coroutines.CancellationException) { + } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -122,7 +129,10 @@ private constructor( // Ignore errors during port close } if (isActive) { - onDeviceDisconnect(true) + // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as + // transient — the user did not explicitly disconnect, and the port may come + // back when the device is replugged or the OS re-enumerates it. + onDeviceDisconnect(waitForStopped = true, isPermanent = false) } } } @@ -137,7 +147,9 @@ private constructor( } override fun keepAlive() { - // Not specifically needed for raw serial unless implemented + // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the + // serial link is alive. + scope.launch { heartbeatSender.sendHeartbeat() } } private fun closePortResources() { @@ -145,7 +157,7 @@ private constructor( serialPort = null } - override fun close() { + override suspend fun close() { Logger.d { "[$portName] Closing serial transport" } readJob?.cancel() readJob = null @@ -160,15 +172,23 @@ private constructor( private const val READ_TIMEOUT_MS = 100 /** - * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent - * disconnect to the [service] and returns the (non-connected) instance. + * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient + * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as + * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the + * user grants permission); only an explicit close should signal a permanent disconnect. */ - fun open(portName: String, baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService): SerialTransport { - val transport = SerialTransport(portName, baudRate, service) + fun open( + portName: String, + baudRate: Int = DEFAULT_BAUD_RATE, + callback: RadioTransportCallback, + scope: CoroutineScope, + dispatchers: CoroutineDispatchers, + ): SerialTransport { + val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers) if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - service.onDisconnect(isPermanent = true, errorMessage = errorMessage) + callback.onDisconnect(isPermanent = false, errorMessage = errorMessage) } return transport } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt index 1b46232bf..34b9e49a3 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt @@ -17,12 +17,12 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers import java.io.IOException import java.net.InetAddress import java.net.NetworkInterface @@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent import javax.jmdns.ServiceListener @Single -class JvmServiceDiscovery : ServiceDiscovery { +class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery { @Suppress("TooGenericExceptionCaught") override val resolvedServices: Flow> = callbackFlow { @@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery { } } } - .flowOn(Dispatchers.IO) + .flowOn(dispatchers.io) companion object { /** diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt index e03076f39..5884daaaf 100644 --- a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt +++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt @@ -17,16 +17,23 @@ package org.meshtastic.core.network.repository import app.cash.turbine.test +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.meshtastic.core.di.CoroutineDispatchers import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue class JvmServiceDiscoveryTest { + private val testDispatchers = + UnconfinedTestDispatcher().let { dispatcher -> + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) + } + @Test fun `resolvedServices emits initial empty list immediately`() = runTest { - val discovery = JvmServiceDiscovery() + val discovery = JvmServiceDiscovery(testDispatchers) discovery.resolvedServices.test { val first = awaitItem() assertNotNull(first, "First emission should not be null") diff --git a/core/nfc/build.gradle.kts b/core/nfc/build.gradle.kts index 801bbf8f2..c5b89c004 100644 --- a/core/nfc/build.gradle.kts +++ b/core/nfc/build.gradle.kts @@ -34,7 +34,5 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.compose.multiplatform.ui) } - - commonTest.dependencies { implementation(kotlin("test")) } } } diff --git a/core/prefs/README.md b/core/prefs/README.md index ecaf0feb6..ac01afd66 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -1,12 +1,12 @@ # `:core:prefs` ## Overview -The `:core:prefs` module provides a type-safe wrapper around `SharedPreferences` for managing application and radio configuration preferences. +The `:core:prefs` module provides a type-safe preferences layer backed by DataStore (multiplatform). On Android, legacy `SharedPreferences` are automatically migrated to DataStore on first access via `SharedPreferencesMigration`. ## Key Components -### 1. `PrefDelegate.kt` -Uses Kotlin property delegates to simplify reading and writing preferences. +### 1. DataStore Providers (`CorePrefsAndroidModule`) +Provides named `DataStore` singletons for each preference domain (analytics, app, map, mesh, radio, UI, etc.). Each DataStore uses an injected `CoroutineDispatchers.io` scope and includes a `SharedPreferencesMigration` for seamless migration from the legacy preference files. ### 2. Specialized Prefs - **`RadioPrefs`**: Manages radio-specific settings (e.g., the last connected device address). diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index 97f728e81..96bba529e 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 { isIncludeAndroidResources = true } + withHostTest {} } sourceSets { @@ -39,9 +39,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt index dfd9d048c..578c0c685 100644 --- a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt +++ b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt @@ -23,110 +23,127 @@ 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.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +/** + * Koin module providing Android [DataStore] instances for each preference domain. + * + * Each DataStore is a singleton backed by its own [CoroutineScope] using the injected [CoroutineDispatchers.io] + * dispatcher, and includes a [SharedPreferencesMigration] to migrate legacy SharedPreferences data on first access. + */ @Suppress("TooManyFunctions") @Module class CorePrefsAndroidModule { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @Single @Named("AnalyticsDataStore") - fun provideAnalyticsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("analytics_ds") }, - ) + fun provideAnalyticsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("analytics_ds") }, + ) @Single @Named("HomoglyphEncodingDataStore") - fun provideHomoglyphEncodingDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") }, - ) + fun provideHomoglyphEncodingDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") }, + ) @Single @Named("AppDataStore") - fun provideAppDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("app_ds") }, - ) + fun provideAppDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("app_ds") }, + ) @Single @Named("CustomEmojiDataStore") - fun provideCustomEmojiDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") }, - ) + fun provideCustomEmojiDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") }, + ) @Single @Named("MapDataStore") - fun provideMapDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_ds") }, - ) + fun provideMapDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_ds") }, + ) @Single @Named("MapConsentDataStore") - fun provideMapConsentDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_consent_ds") }, - ) + fun provideMapConsentDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_consent_ds") }, + ) @Single @Named("MapTileProviderDataStore") - fun provideMapTileProviderDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") }, - ) + fun provideMapTileProviderDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") }, + ) @Single @Named("MeshDataStore") - fun provideMeshDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("mesh_ds") }, - ) + fun provideMeshDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("mesh_ds") }, + ) @Single @Named("RadioDataStore") - fun provideRadioDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("radio_ds") }, - ) + fun provideRadioDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("radio_ds") }, + ) @Single @Named("UiDataStore") - fun provideUiDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("ui_ds") }, - ) + fun provideUiDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("ui_ds") }, + ) @Single @Named("MeshLogDataStore") - fun provideMeshLogDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("meshlog_ds") }, - ) + fun provideMeshLogDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("meshlog_ds") }, + ) @Single @Named("FilterDataStore") - fun provideFilterDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")), - scope = scope, - produceFile = { context.preferencesDataStoreFile("filter_ds") }, - ) + fun provideFilterDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("filter_ds") }, + ) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt index 5395ce723..d6c85d266 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt @@ -19,19 +19,31 @@ package org.meshtastic.core.prefs import kotlinx.atomicfu.AtomicRef import kotlinx.collections.immutable.PersistentMap -internal inline fun cachedFlow(cache: AtomicRef>, key: K, build: () -> V): V { - var resolved = cache.value[key] - if (resolved == null) { - val newValue = build() - while (resolved == null) { - val current = cache.value - val currentValue = current[key] - if (currentValue != null) { - resolved = currentValue - } else if (cache.compareAndSet(current, current.put(key, newValue))) { - resolved = newValue - } +/** + * Look up [key] in [cache]; if absent, construct a value via [build] and insert it atomically. + * + * [build] is wrapped in a [Lazy] before being published to [cache], so concurrent first-access of the same key never + * invokes [build] more than once — only the winner of the CAS has its [Lazy] evaluated, and all readers share that same + * result. This matters when [build] eagerly launches a coroutine (e.g. `Flow.stateIn(scope, Eagerly, …)`): the naive + * approach would leak the losing coroutine into a never-cancelled scope. + */ +@Suppress("ReturnCount") +internal inline fun cachedFlow( + cache: AtomicRef>>, + key: K, + crossinline build: () -> V, +): V { + cache.value[key]?.let { + return it.value + } + val newLazy = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { build() } + while (true) { + val current = cache.value + current[key]?.let { + return it.value + } + if (cache.compareAndSet(current, current.put(key, newLazy))) { + return newLazy.value } } - return checkNotNull(resolved) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt index 763c81120..c43d4b2bb 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt @@ -42,7 +42,7 @@ class MapConsentPrefsImpl( ) : MapConsentPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val consentFlows = atomic(persistentMapOf>()) + private val consentFlows = atomic(persistentMapOf>>()) override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) { val key = booleanPreferencesKey(nodeNum.toString()) diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index ad982e6a6..f3ddaad4e 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.prefs.mesh import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -33,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.normalizeAddress import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.repository.MeshPrefs @@ -44,8 +44,7 @@ class MeshPrefsImpl( ) : MeshPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val locationFlows = atomic(persistentMapOf>()) - private val storeForwardFlows = atomic(persistentMapOf>()) + private val storeForwardFlows = atomic(persistentMapOf>>()) override val deviceAddress: StateFlow = dataStore.data @@ -64,15 +63,6 @@ class MeshPrefsImpl( } } - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = cachedFlow(locationFlows, nodeNum) { - val key = booleanPreferencesKey(provideLocationKey(nodeNum)) - dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) - } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = provide } } - } - override fun getStoreForwardLastRequest(address: String?): StateFlow = cachedFlow(storeForwardFlows, address) { val key = intPreferencesKey(storeForwardKey(address)) dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0) @@ -91,19 +81,8 @@ class MeshPrefsImpl( } } - private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum" - private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" - private fun normalizeAddress(address: String?): String { - val raw = address?.trim()?.takeIf { it.isNotEmpty() } - return when { - raw == null -> "DEFAULT" - raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT" - else -> raw.uppercase().replace(":", "") - } - } - companion object { val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address") } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 33f688389..c0b88d385 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -46,7 +46,7 @@ class UiPrefsImpl( private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref - private val provideNodeLocationFlows = atomic(persistentMapOf>()) + private val provideNodeLocationFlows = atomic(persistentMapOf>>()) override val appIntroCompleted: StateFlow = dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) @@ -62,6 +62,13 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_THEME] = value } } } + override val contrastLevel: StateFlow = + dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0) + + override fun setContrastLevel(value: Int) { + scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } } + } + override val locale: StateFlow = dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "") @@ -152,6 +159,7 @@ class UiPrefsImpl( val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed") val KEY_THEME = intPreferencesKey("theme") + val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level") val KEY_LOCALE = stringPreferencesKey("locale") val KEY_NODE_SORT = intPreferencesKey("node-sort-option") val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown") diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt similarity index 84% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt index 3ba095531..b38c822fe 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt @@ -22,18 +22,22 @@ 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 tmpFolder: File + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var filterPrefs: FilterPrefs @@ -44,15 +48,12 @@ class FilterPrefsTest { @BeforeTest fun setup() { - tmpFolder = - File.createTempFile("filterPrefsTest", null).apply { - delete() - mkdirs() - } + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "filterPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, + produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) filterPrefs = FilterPrefsImpl(dataStore, dispatchers) @@ -60,7 +61,7 @@ class FilterPrefsTest { @AfterTest fun tearDown() { - tmpFolder.deleteRecursively() + FileSystem.SYSTEM.deleteRecursively(tmpDir) } @Test fun `filterEnabled defaults to false`() = testScope.runTest { assertFalse(filterPrefs.filterEnabled.value) } diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt similarity index 85% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt index 51571786c..a5792e800 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -22,17 +22,21 @@ 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 tmpFolder: File + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var notificationPrefs: NotificationPrefs @@ -43,15 +47,12 @@ class NotificationPrefsTest { @BeforeTest fun setup() { - tmpFolder = - File.createTempFile("notificationPrefsTest", null).apply { - delete() - mkdirs() - } + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "notificationPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, + produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers) @@ -59,7 +60,7 @@ class NotificationPrefsTest { @AfterTest fun tearDown() { - tmpFolder.deleteRecursively() + FileSystem.SYSTEM.deleteRecursively(tmpDir) } @Test diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt similarity index 79% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt index caa60fe70..2ad0ad21c 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt @@ -22,17 +22,21 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.rules.TemporaryFolder +import okio.FileSystem +import okio.Path 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 { - @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var takPrefs: TakPrefs @@ -43,15 +47,22 @@ class TakPrefsTest { @BeforeTest fun setup() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "takPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { tmpFolder.newFile("test.preferences_pb") }, + produceFile = { tmpDir / "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/proto/consumer-rules.pro b/core/proto/consumer-rules.pro deleted file mode 100644 index e9dc3751a..000000000 --- a/core/proto/consumer-rules.pro +++ /dev/null @@ -1,43 +0,0 @@ -# Core proto classes required for packet handling and serialization -# FromRadio and related message types (primary packet container) --keep class org.meshtastic.proto.FromRadio --keep class org.meshtastic.proto.Data --keep class org.meshtastic.proto.MeshPacket --keep class org.meshtastic.proto.LogRecord - -# Message type payloads (handled in packet routing) --keep class org.meshtastic.proto.AdminMessage --keep class org.meshtastic.proto.StoreAndForward --keep class org.meshtastic.proto.StoreForwardPlusPlus --keep class org.meshtastic.proto.Routing - -# User and Node information --keep class org.meshtastic.proto.User --keep class org.meshtastic.proto.NeighborInfo --keep class org.meshtastic.proto.Neighbor - -# Location and environment data --keep class org.meshtastic.proto.Position --keep class org.meshtastic.proto.Waypoint --keep class org.meshtastic.proto.StatusMessage - -# Telemetry data types --keep class org.meshtastic.proto.Telemetry --keep class org.meshtastic.proto.DeviceMetrics --keep class org.meshtastic.proto.EnvironmentMetrics --keep class org.meshtastic.proto.AirQualityMetrics --keep class org.meshtastic.proto.PowerMetrics --keep class org.meshtastic.proto.LocalStats --keep class org.meshtastic.proto.HostMetrics - -# Other data --keep class org.meshtastic.proto.Paxcount --keep class org.meshtastic.proto.DeviceMetadata - -# Configuration classes --keep class org.meshtastic.proto.ChannelSet --keep class org.meshtastic.proto.LocalConfig --keep class org.meshtastic.proto.Config --keep class org.meshtastic.proto.ModuleConfig --keep class org.meshtastic.proto.Channel --keep class org.meshtastic.proto.ClientNotification diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 349c1d5c1..4d5b500df 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 349c1d5c1e3ab716a65d7dab1597923b4542796d +Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index 1f9cdc585..ce7ac4abc 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -22,7 +22,10 @@ plugins { kotlin { @Suppress("UnstableApiUsage") - android { androidResources.enable = false } + android { + androidResources.enable = false + withHostTest {} + } sourceSets { commonMain.dependencies { @@ -37,10 +40,7 @@ kotlin { } commonTest.dependencies { implementation(projects.core.testing) - implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - implementation(libs.kotest.assertions) } } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index f5203e3c1..d7400332d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -80,6 +80,10 @@ interface UiPrefs { fun setTheme(value: Int) + val contrastLevel: StateFlow + + fun setContrastLevel(value: Int) + val locale: StateFlow fun setLocale(languageTag: String) @@ -209,10 +213,6 @@ interface MeshPrefs { fun setDeviceAddress(address: String?) - fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow - - fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) - fun getStoreForwardLastRequest(address: String?): StateFlow fun setStoreForwardLastRequest(address: String?, timestamp: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index 2b897baa9..b99a002de 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,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import okio.ByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Position @@ -27,9 +26,6 @@ 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 dca2a6bf3..9f7cbe0dd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.repository import okio.BufferedSink import okio.BufferedSource -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri /** * Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain @@ -29,11 +29,11 @@ interface FileService { * Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean + suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean /** * Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean + suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index ac92e8287..5c43efdcd 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,7 +16,6 @@ */ 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 @@ -25,9 +24,6 @@ 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 2a92f8909..b2bb6d418 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,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.MyNodeInfo @@ -24,9 +23,6 @@ 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 3f3887631..c0e60337e 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,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -27,9 +26,6 @@ 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 eae5bd9a0..9f9851072 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,14 +16,10 @@ */ 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() @@ -39,6 +35,6 @@ interface MeshConnectionManager { /** Updates the telemetry information for the local node. */ fun updateTelemetry(t: Telemetry) - /** Updates and returns the current status notification. */ - fun updateStatusNotification(telemetry: Telemetry? = null): Any + /** Updates the current status notification. */ + fun updateStatusNotification(telemetry: Telemetry? = null) } 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 2c7487cf9..7d5f2a913 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,16 +16,12 @@ */ 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 1a3657d9e..a8d6545ce 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,14 +16,10 @@ */ 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 be2830af9..42b306b17 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,13 +16,8 @@ */ 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 195a241ee..a68157943 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,6 +16,7 @@ */ 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 @@ -28,7 +29,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?): Any + fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) 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 cfda5a9d0..6701514f8 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -16,17 +16,33 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ interface MqttManager { - /** Starts the MQTT manager with the given coroutine scope and settings. */ - fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) + /** 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) /** 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 b9759ff59..903146331 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,15 +16,11 @@ */ 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 a0d115391..ac6718572 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,7 +16,6 @@ */ 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 @@ -51,9 +50,6 @@ 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 686840f40..081e2928b 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,16 +16,12 @@ */ 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 a0977c582..6bd33a4cf 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -71,7 +71,7 @@ interface PacketRepository { suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) /** Returns all packets currently queued for transmission. */ - suspend fun getQueuedPackets(): List? + suspend fun getQueuedPackets(): List /** * Persists a packet in the database. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 2788a7f07..cbaf8b3dc 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState @@ -24,26 +25,70 @@ 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. */ -interface RadioInterfaceService { +/** + * 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 { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List - /** Reactive connection state of the radio. */ + /** + * 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 + */ val connectionState: StateFlow /** Flow of the current device address. */ val currentDeviceAddressFlow: StateFlow - /** Whether we are currently using a mock interface. */ - fun isMockInterface(): Boolean + /** Whether we are currently using a mock transport. */ + fun isMockTransport(): Boolean - /** Flow of raw data received from the radio. */ - val receivedData: SharedFlow + /** + * Flow of raw data received from the radio. + * + * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware + * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee + * ordering; do not swap in a [SharedFlow] without preserving order. + */ + val receivedData: Flow /** Flow of radio activity events. */ val meshActivity: SharedFlow + /** + * Drains any bytes currently buffered in [receivedData] without emitting them to collectors. + * + * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no + * collector was attached do not get replayed ahead of the next session's handshake. + */ + fun resetReceivedBuffer() + /** Sends a raw byte array to the radio. */ fun sendToRadio(bytes: ByteArray) @@ -59,15 +104,6 @@ interface RadioInterfaceService { /** 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 41015381f..c0572f83f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt @@ -16,19 +16,34 @@ */ package org.meshtastic.core.repository -import okio.Closeable - /** * Interface for hardware transports (BLE, Serial, TCP, etc.) that handles raw byte communication. This is the * KMP-compatible replacement for the legacy Android-specific IRadioInterface. */ -interface RadioTransport : Closeable { +interface RadioTransport { /** Sends a raw byte array to the radio hardware. */ fun handleSendToRadio(p: ByteArray) + /** + * 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 new file mode 100644 index 000000000..9771062a5 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt @@ -0,0 +1,41 @@ +/* + * 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 918657e99..c3d2abff1 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 interface (e.g., Firebase Test Lab). */ - fun isMockInterface(): Boolean + /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */ + fun isMockTransport(): 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 4a8af1143..57b1d71ec 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,14 +31,39 @@ 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 { - /** Reactive flow of the current connection state. */ + /** + * 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 + */ val connectionState: StateFlow /** - * Updates the current connection state. + * 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. * * @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 51006763d..bda122ac1 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,15 +16,11 @@ */ 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 a53cd8b8a..b1f1aa2c9 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,15 +16,11 @@ */ 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 aa2e6318a..6535ef30c 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,15 +16,11 @@ */ 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 dbc951d2a..303b8a4ad 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt @@ -16,13 +16,14 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertTrue class RadioTransportTest { @Test - fun `RadioTransport can be implemented`() { + fun `RadioTransport can be implemented`() = runTest { var sentData: ByteArray? = null var closed = false var keepAliveCalled = false @@ -37,7 +38,7 @@ class RadioTransportTest { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closed = true } } diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 47d8c12e0..966ab949a 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -25,14 +25,14 @@ kotlin { @Suppress("UnstableApiUsage") android { - androidResources.enable = true + androidResources { + enable = true + resourcePrefix = "meshtastic_" + } withHostTest { isIncludeAndroidResources = true } } - sourceSets { - commonMain.dependencies { implementation(projects.core.common) } - commonTest.dependencies { implementation(kotlin("test")) } - } + sourceSets { commonMain.dependencies { implementation(projects.core.common) } } } compose.resources { diff --git a/core/resources/src/androidMain/res/raw/alert.mp3 b/core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 similarity index 100% rename from core/resources/src/androidMain/res/raw/alert.mp3 rename to core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_abc.xml b/core/resources/src/commonMain/composeResources/drawable/ic_abc.xml new file mode 100644 index 000000000..66e48ebc1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_abc.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_account_circle.xml b/core/resources/src/commonMain/composeResources/drawable/ic_account_circle.xml new file mode 100644 index 000000000..92f7b094d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_account_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_add.xml b/core/resources/src/commonMain/composeResources/drawable/ic_add.xml new file mode 100644 index 000000000..f1ba62db7 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_add_link.xml b/core/resources/src/commonMain/composeResources/drawable/ic_add_link.xml new file mode 100644 index 000000000..b2d0feeeb --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_add_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_add_reaction.xml b/core/resources/src/commonMain/composeResources/drawable/ic_add_reaction.xml new file mode 100644 index 000000000..a1e73b47b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_add_reaction.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_admin_panel_settings.xml b/core/resources/src/commonMain/composeResources/drawable/ic_admin_panel_settings.xml new file mode 100644 index 000000000..033388b05 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_admin_panel_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_air.xml b/core/resources/src/commonMain/composeResources/drawable/ic_air.xml new file mode 100644 index 000000000..5585deb3b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_air.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_alt_route.xml b/core/resources/src/commonMain/composeResources/drawable/ic_alt_route.xml new file mode 100644 index 000000000..ef0cf5152 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_alt_route.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_android.xml b/core/resources/src/commonMain/composeResources/drawable/ic_android.xml new file mode 100644 index 000000000..a8a1a2596 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_android.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_app_settings_alt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_app_settings_alt.xml new file mode 100644 index 000000000..4d69de9e1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_app_settings_alt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_arrow_back.xml b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..842837341 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_arrow_circle_up.xml b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_circle_up.xml new file mode 100644 index 000000000..c5b3a2e5e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_circle_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_arrow_downward.xml b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_downward.xml new file mode 100644 index 000000000..436250a81 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bar_chart.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bar_chart.xml new file mode 100644 index 000000000..15175d774 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bar_chart.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml index 46acf0dfd..cef548757 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml @@ -1,14 +1,9 @@ - + android:viewportWidth="960" + android:viewportHeight="960"> - \ No newline at end of file + android:fillColor="#FFFFFFFF" + android:pathData="M320,880Q303,880 291.5,868.5Q280,857 280,840L280,200Q280,183 291.5,171.5Q303,160 320,160L400,160L400,120Q400,103 411.5,91.5Q423,80 440,80L520,80Q537,80 548.5,91.5Q560,103 560,120L560,160L640,160Q657,160 668.5,171.5Q680,183 680,200L680,840Q680,857 668.5,868.5Q657,880 640,880L320,880ZM480,560Q497,560 508.5,548.5Q520,537 520,520L520,360Q520,343 508.5,331.5Q497,320 480,320Q463,320 451.5,331.5Q440,343 440,360L440,520Q440,537 451.5,548.5Q463,560 480,560ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720Z"/> + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml deleted file mode 100644 index 84515a2ae..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_horiz_000.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_horiz_000.xml new file mode 100644 index 000000000..49dd7e7bb --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_horiz_000.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml deleted file mode 100644 index 03494c93a..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml deleted file mode 100644 index 9f2ec050c..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml deleted file mode 100644 index 04ddd0c30..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_question_mark.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_question_mark.xml new file mode 100644 index 000000000..c239a0a9c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_question_mark.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml deleted file mode 100644 index 32a9765d6..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bedtime.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bedtime.xml new file mode 100644 index 000000000..7402e3d58 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bedtime.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth.xml new file mode 100644 index 000000000..7a0f7ba67 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_connected.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_connected.xml new file mode 100644 index 000000000..17d627e51 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_connected.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_searching.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_searching.xml new file mode 100644 index 000000000..b82b12b0d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bluetooth_searching.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_blur_on.xml b/core/resources/src/commonMain/composeResources/drawable/ic_blur_on.xml new file mode 100644 index 000000000..a9e62dfbf --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_blur_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bolt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bolt.xml new file mode 100644 index 000000000..e0442fcc3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bolt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_bug_report.xml b/core/resources/src/commonMain/composeResources/drawable/ic_bug_report.xml new file mode 100644 index 000000000..e6577124c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_bug_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cached.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cached.xml new file mode 100644 index 000000000..dbc757d8b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cached.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_calendar_month.xml b/core/resources/src/commonMain/composeResources/drawable/ic_calendar_month.xml new file mode 100644 index 000000000..3bc6cadfc --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_calendar_month.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cell_tower.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cell_tower.xml new file mode 100644 index 000000000..c7bc849e2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cell_tower.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_charging_station.xml b/core/resources/src/commonMain/composeResources/drawable/ic_charging_station.xml new file mode 100644 index 000000000..50c0425c9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_charging_station.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_chat_bubble_outline.xml b/core/resources/src/commonMain/composeResources/drawable/ic_chat_bubble_outline.xml new file mode 100644 index 000000000..38611380f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_chat_bubble_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_check.xml b/core/resources/src/commonMain/composeResources/drawable/ic_check.xml new file mode 100644 index 000000000..c87532011 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill0.xml b/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill0.xml new file mode 100644 index 000000000..10030f259 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill1.xml b/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill1.xml new file mode 100644 index 000000000..3705c3042 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_check_circle_fill1.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_chevron_right.xml b/core/resources/src/commonMain/composeResources/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..0cba5c4e2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cleaning_services.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cleaning_services.xml new file mode 100644 index 000000000..413b1e6d9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cleaning_services.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_close.xml b/core/resources/src/commonMain/composeResources/drawable/ic_close.xml new file mode 100644 index 000000000..87da91234 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cloud.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cloud.xml new file mode 100644 index 000000000..701060f81 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cloud.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cloud_done.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_done.xml new file mode 100644 index 000000000..9caf6a6b0 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_done.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cloud_download.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_download.xml new file mode 100644 index 000000000..a96f04d8a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cloud_sync.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_sync.xml new file mode 100644 index 000000000..71f4e4f3b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cloud_upload.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_upload.xml new file mode 100644 index 000000000..f30a1f322 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cloud_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_compress.xml b/core/resources/src/commonMain/composeResources/drawable/ic_compress.xml new file mode 100644 index 000000000..449ed300e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_compress.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_content_copy.xml b/core/resources/src/commonMain/composeResources/drawable/ic_content_copy.xml new file mode 100644 index 000000000..b77d1063e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_content_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml index 2b2f8bd7a..f22942b98 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml index b7997e6a4..170a97127 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml index e0f060afc..692f3a48f 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml index a93cc6935..eba284ac2 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml index 3c86ac847..7759a9947 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml @@ -1,22 +1,9 @@ - - - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml index 881e384c4..abffef49c 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml index 10854b64a..0d8a8d94f 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml index 9bfc82753..fb3ba0b9a 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml index b90075109..424599073 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_cruelty_free.xml b/core/resources/src/commonMain/composeResources/drawable/ic_cruelty_free.xml new file mode 100644 index 000000000..d4e145185 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_cruelty_free.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_dangerous.xml b/core/resources/src/commonMain/composeResources/drawable/ic_dangerous.xml new file mode 100644 index 000000000..9a95e5c4a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_dangerous.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_data_array.xml b/core/resources/src/commonMain/composeResources/drawable/ic_data_array.xml new file mode 100644 index 000000000..339f48690 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_data_array.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_data_usage.xml b/core/resources/src/commonMain/composeResources/drawable/ic_data_usage.xml new file mode 100644 index 000000000..649a9b452 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_data_usage.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill0.xml b/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill0.xml new file mode 100644 index 000000000..63562a0f0 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill1.xml b/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill1.xml new file mode 100644 index 000000000..60d419093 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_delete_fill1.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_dew_point.xml b/core/resources/src/commonMain/composeResources/drawable/ic_dew_point.xml index e19263e2e..2d228b832 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_dew_point.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_dew_point.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960"> + android:fillColor="#FFFFFFFF" + android:pathData="M620,440Q595,440 577.5,422.5Q560,405 560,380Q560,367 569.5,350Q579,333 590,317.5Q601,302 610.5,291Q620,280 620,280Q620,280 629.5,291Q639,302 650,317.5Q661,333 670.5,350Q680,367 680,380Q680,405 662.5,422.5Q645,440 620,440ZM780,320Q755,320 737.5,302.5Q720,285 720,260Q720,247 729.5,230Q739,213 750,197.5Q761,182 770.5,171Q780,160 780,160Q780,160 789.5,171Q799,182 810,197.5Q821,213 830.5,230Q840,247 840,260Q840,285 822.5,302.5Q805,320 780,320ZM780,560Q755,560 737.5,542.5Q720,525 720,500Q720,487 729.5,470Q739,453 750,437.5Q761,422 770.5,411Q780,400 780,400Q780,400 789.5,411Q799,422 810,437.5Q821,453 830.5,470Q840,487 840,500Q840,525 822.5,542.5Q805,560 780,560ZM360,840Q277,840 218.5,781.5Q160,723 160,640Q160,592 181,550.5Q202,509 240,480L240,240Q240,190 275,155Q310,120 360,120Q410,120 445,155Q480,190 480,240L480,480Q518,509 539,550.5Q560,592 560,640Q560,723 501.5,781.5Q443,840 360,840ZM240,640L480,640Q480,611 467.5,586Q455,561 432,544L400,520L400,240Q400,223 388.5,211.5Q377,200 360,200Q343,200 331.5,211.5Q320,223 320,240L320,520L288,544Q265,561 252.5,586Q240,611 240,640Z"/> diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_display_settings.xml b/core/resources/src/commonMain/composeResources/drawable/ic_display_settings.xml new file mode 100644 index 000000000..0bce8db60 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_display_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_do_not_disturb_on.xml b/core/resources/src/commonMain/composeResources/drawable/ic_do_not_disturb_on.xml new file mode 100644 index 000000000..8584e4cf9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_do_not_disturb_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_download.xml b/core/resources/src/commonMain/composeResources/drawable/ic_download.xml new file mode 100644 index 000000000..6431c3e05 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_drag_handle.xml b/core/resources/src/commonMain/composeResources/drawable/ic_drag_handle.xml new file mode 100644 index 000000000..6b675c008 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_drag_handle.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_edit.xml b/core/resources/src/commonMain/composeResources/drawable/ic_edit.xml new file mode 100644 index 000000000..21a3da589 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_electric_bolt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_electric_bolt.xml new file mode 100644 index 000000000..dfad77021 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_electric_bolt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_elevation.xml b/core/resources/src/commonMain/composeResources/drawable/ic_elevation.xml new file mode 100644 index 000000000..68308699c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_elevation.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_error_fill0.xml b/core/resources/src/commonMain/composeResources/drawable/ic_error_fill0.xml new file mode 100644 index 000000000..071972c15 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_error_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_error_fill1.xml b/core/resources/src/commonMain/composeResources/drawable/ic_error_fill1.xml new file mode 100644 index 000000000..5134f4364 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_error_fill1.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_expand_less.xml b/core/resources/src/commonMain/composeResources/drawable/ic_expand_less.xml new file mode 100644 index 000000000..f3bc2b43f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_expand_more.xml b/core/resources/src/commonMain/composeResources/drawable/ic_expand_more.xml new file mode 100644 index 000000000..6d3203895 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_explore.xml b/core/resources/src/commonMain/composeResources/drawable/ic_explore.xml new file mode 100644 index 000000000..070da714f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_explore.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_fast_forward.xml b/core/resources/src/commonMain/composeResources/drawable/ic_fast_forward.xml new file mode 100644 index 000000000..55e861abf --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_file_download.xml b/core/resources/src/commonMain/composeResources/drawable/ic_file_download.xml new file mode 100644 index 000000000..6597a8e9f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_file_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt.xml new file mode 100644 index 000000000..2682d0dd0 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt_off.xml new file mode 100644 index 000000000..221a8d936 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_filter_alt_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_filter_list.xml b/core/resources/src/commonMain/composeResources/drawable/ic_filter_list.xml new file mode 100644 index 000000000..1572886be --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_filter_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_filter_list_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_filter_list_off.xml new file mode 100644 index 000000000..db86ecef5 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_filter_list_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_fingerprint.xml b/core/resources/src/commonMain/composeResources/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..e571895d6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_fingerprint.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_folder.xml b/core/resources/src/commonMain/composeResources/drawable/ic_folder.xml new file mode 100644 index 000000000..f5f693514 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_folder_open.xml b/core/resources/src/commonMain/composeResources/drawable/ic_folder_open.xml new file mode 100644 index 000000000..261d9d0b1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_folder_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_fork_left.xml b/core/resources/src/commonMain/composeResources/drawable/ic_fork_left.xml new file mode 100644 index 000000000..73946e6f2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_fork_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_format_paint.xml b/core/resources/src/commonMain/composeResources/drawable/ic_format_paint.xml new file mode 100644 index 000000000..f36fd946f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_format_paint.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_format_quote.xml b/core/resources/src/commonMain/composeResources/drawable/ic_format_quote.xml new file mode 100644 index 000000000..59362fbcd --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_format_quote.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_forum.xml b/core/resources/src/commonMain/composeResources/drawable/ic_forum.xml new file mode 100644 index 000000000..88a56a2ec --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_forum.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_graphic_eq.xml b/core/resources/src/commonMain/composeResources/drawable/ic_graphic_eq.xml new file mode 100644 index 000000000..9b6498e38 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_graphic_eq.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_grass.xml b/core/resources/src/commonMain/composeResources/drawable/ic_grass.xml new file mode 100644 index 000000000..e0eeda24f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_grass.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_group.xml b/core/resources/src/commonMain/composeResources/drawable/ic_group.xml new file mode 100644 index 000000000..ed14fc68b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_group.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_groups.xml b/core/resources/src/commonMain/composeResources/drawable/ic_groups.xml new file mode 100644 index 000000000..302f0f8c8 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_groups.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_height.xml b/core/resources/src/commonMain/composeResources/drawable/ic_height.xml new file mode 100644 index 000000000..b2eb0eda3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_height.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_history.xml b/core/resources/src/commonMain/composeResources/drawable/ic_history.xml new file mode 100644 index 000000000..662ff1943 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_home.xml b/core/resources/src/commonMain/composeResources/drawable/ic_home.xml new file mode 100644 index 000000000..4d005d19f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_how_to_reg.xml b/core/resources/src/commonMain/composeResources/drawable/ic_how_to_reg.xml new file mode 100644 index 000000000..7a0bacbdc --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_how_to_reg.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_hub.xml b/core/resources/src/commonMain/composeResources/drawable/ic_hub.xml new file mode 100644 index 000000000..ca3d6d77c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_hub.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_icecream.xml b/core/resources/src/commonMain/composeResources/drawable/ic_icecream.xml new file mode 100644 index 000000000..3a4e131c7 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_icecream.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_info.xml b/core/resources/src/commonMain/composeResources/drawable/ic_info.xml new file mode 100644 index 000000000..d6d960012 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_key_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_key_off.xml new file mode 100644 index 000000000..45d27555b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_key_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_down.xml b/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_down.xml new file mode 100644 index 000000000..fa148c0bf --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_up.xml b/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_up.xml new file mode 100644 index 000000000..e880ca90c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_keyboard_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lan.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lan.xml new file mode 100644 index 000000000..4fd1e76b7 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_lan.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_language.xml b/core/resources/src/commonMain/composeResources/drawable/ic_language.xml new file mode 100644 index 000000000..3eee5a866 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_language.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_layers.xml b/core/resources/src/commonMain/composeResources/drawable/ic_layers.xml new file mode 100644 index 000000000..cd6bef169 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_layers.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lens.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lens.xml new file mode 100644 index 000000000..b7b4c8d10 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_lens.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_light_mode.xml b/core/resources/src/commonMain/composeResources/drawable/ic_light_mode.xml new file mode 100644 index 000000000..b086de9e9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_light_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_line_axis.xml b/core/resources/src/commonMain/composeResources/drawable/ic_line_axis.xml new file mode 100644 index 000000000..0a0b418ed --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_line_axis.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_link.xml b/core/resources/src/commonMain/composeResources/drawable/ic_link.xml new file mode 100644 index 000000000..41d18e2c2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_link_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_link_off.xml new file mode 100644 index 000000000..6a962e461 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_link_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_list.xml b/core/resources/src/commonMain/composeResources/drawable/ic_list.xml new file mode 100644 index 000000000..d66499010 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_location_disabled.xml b/core/resources/src/commonMain/composeResources/drawable/ic_location_disabled.xml new file mode 100644 index 000000000..eab8830d9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_location_disabled.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lock.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lock.xml new file mode 100644 index 000000000..4bd6e7caa --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lock_open.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lock_open.xml new file mode 100644 index 000000000..51a8fbccd --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml deleted file mode 100644 index f0c7f63fd..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_map.xml b/core/resources/src/commonMain/composeResources/drawable/ic_map.xml new file mode 100644 index 000000000..6d578adc6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_map.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_mark_chat_read.xml b/core/resources/src/commonMain/composeResources/drawable/ic_mark_chat_read.xml new file mode 100644 index 000000000..7e84467e1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_mark_chat_read.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_memory.xml b/core/resources/src/commonMain/composeResources/drawable/ic_memory.xml new file mode 100644 index 000000000..8807cd383 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_memory.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_message.xml b/core/resources/src/commonMain/composeResources/drawable/ic_message.xml new file mode 100644 index 000000000..48a4555c8 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_message.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_military_tech.xml b/core/resources/src/commonMain/composeResources/drawable/ic_military_tech.xml new file mode 100644 index 000000000..cece8b47e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_military_tech.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_more_vert.xml b/core/resources/src/commonMain/composeResources/drawable/ic_more_vert.xml new file mode 100644 index 000000000..1612c7c4f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_mountain_flag.xml b/core/resources/src/commonMain/composeResources/drawable/ic_mountain_flag.xml index 2ec58dc23..60b199860 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_mountain_flag.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_mountain_flag.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_my_location.xml b/core/resources/src/commonMain/composeResources/drawable/ic_my_location.xml new file mode 100644 index 000000000..dd0dc8e45 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_my_location.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_navigation.xml b/core/resources/src/commonMain/composeResources/drawable/ic_navigation.xml new file mode 100644 index 000000000..0c014e7e3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_navigation.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_near_me.xml b/core/resources/src/commonMain/composeResources/drawable/ic_near_me.xml new file mode 100644 index 000000000..4931bbaf6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_near_me.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_nfc.xml b/core/resources/src/commonMain/composeResources/drawable/ic_nfc.xml new file mode 100644 index 000000000..f0dddb3d4 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_no_cell.xml b/core/resources/src/commonMain/composeResources/drawable/ic_no_cell.xml new file mode 100644 index 000000000..766f9a600 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_no_cell.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_no_device.xml b/core/resources/src/commonMain/composeResources/drawable/ic_no_device.xml new file mode 100644 index 000000000..ebea76d42 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_no_device.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_nodes.xml b/core/resources/src/commonMain/composeResources/drawable/ic_nodes.xml new file mode 100644 index 000000000..1a3504ea2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_nodes.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_notes.xml b/core/resources/src/commonMain/composeResources/drawable/ic_notes.xml new file mode 100644 index 000000000..56b87147e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_notes.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_notifications.xml b/core/resources/src/commonMain/composeResources/drawable/ic_notifications.xml new file mode 100644 index 000000000..76adccb8b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_numbers.xml b/core/resources/src/commonMain/composeResources/drawable/ic_numbers.xml new file mode 100644 index 000000000..9710fdc52 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_numbers.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_offline_share.xml b/core/resources/src/commonMain/composeResources/drawable/ic_offline_share.xml new file mode 100644 index 000000000..2024792c3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_offline_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_output.xml b/core/resources/src/commonMain/composeResources/drawable/ic_output.xml new file mode 100644 index 000000000..efb4788a4 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_output.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_perm_scan_wifi.xml b/core/resources/src/commonMain/composeResources/drawable/ic_perm_scan_wifi.xml new file mode 100644 index 000000000..6d60d708a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_perm_scan_wifi.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_person.xml b/core/resources/src/commonMain/composeResources/drawable/ic_person.xml new file mode 100644 index 000000000..8e5be7ed1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_person_add.xml b/core/resources/src/commonMain/composeResources/drawable/ic_person_add.xml new file mode 100644 index 000000000..543ae094e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_person_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_person_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_person_off.xml new file mode 100644 index 000000000..426c7bad9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_person_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_person_search.xml b/core/resources/src/commonMain/composeResources/drawable/ic_person_search.xml new file mode 100644 index 000000000..0eddca904 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_person_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_phone_android.xml b/core/resources/src/commonMain/composeResources/drawable/ic_phone_android.xml new file mode 100644 index 000000000..fdc14d9f3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_phone_android.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_pin_drop.xml b/core/resources/src/commonMain/composeResources/drawable/ic_pin_drop.xml new file mode 100644 index 000000000..0e70fac11 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_pin_drop.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_place.xml b/core/resources/src/commonMain/composeResources/drawable/ic_place.xml new file mode 100644 index 000000000..3741f4af8 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_place.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_play_arrow.xml b/core/resources/src/commonMain/composeResources/drawable/ic_play_arrow.xml new file mode 100644 index 000000000..cd0a70c4a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_play_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_podcasts.xml b/core/resources/src/commonMain/composeResources/drawable/ic_podcasts.xml new file mode 100644 index 000000000..22f1d500c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_podcasts.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_power.xml b/core/resources/src/commonMain/composeResources/drawable/ic_power.xml new file mode 100644 index 000000000..a1f818d8c --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_power.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml b/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml deleted file mode 100644 index b231758fb..000000000 --- a/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - \ No newline at end of file diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_power_settings_new.xml b/core/resources/src/commonMain/composeResources/drawable/ic_power_settings_new.xml new file mode 100644 index 000000000..ece438155 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_power_settings_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_qr_code.xml b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code.xml new file mode 100644 index 000000000..2f1bbb997 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_2.xml b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_2.xml new file mode 100644 index 000000000..981d42cc3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_2.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_scanner.xml b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_scanner.xml new file mode 100644 index 000000000..ef1de5a93 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_radio_button_unchecked.xml b/core/resources/src/commonMain/composeResources/drawable/ic_radio_button_unchecked.xml new file mode 100644 index 000000000..f8bce094f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_radio_button_unchecked.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_refresh.xml b/core/resources/src/commonMain/composeResources/drawable/ic_refresh.xml new file mode 100644 index 000000000..1adebe584 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_reply.xml b/core/resources/src/commonMain/composeResources/drawable/ic_reply.xml new file mode 100644 index 000000000..25bfa764e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_restart_alt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_restart_alt.xml new file mode 100644 index 000000000..fcdd91f25 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_restart_alt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_restore.xml b/core/resources/src/commonMain/composeResources/drawable/ic_restore.xml new file mode 100644 index 000000000..137e8f762 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_route.xml b/core/resources/src/commonMain/composeResources/drawable/ic_route.xml new file mode 100644 index 000000000..f2f9620e8 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_route.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_router.xml b/core/resources/src/commonMain/composeResources/drawable/ic_router.xml new file mode 100644 index 000000000..869d027ef --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_router.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_satellite_alt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_satellite_alt.xml new file mode 100644 index 000000000..6acfdc624 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_satellite_alt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_save.xml b/core/resources/src/commonMain/composeResources/drawable/ic_save.xml new file mode 100644 index 000000000..50d9fb414 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_scale.xml b/core/resources/src/commonMain/composeResources/drawable/ic_scale.xml new file mode 100644 index 000000000..232b836fa --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_scale.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_schedule.xml b/core/resources/src/commonMain/composeResources/drawable/ic_schedule.xml new file mode 100644 index 000000000..e6f1a1dfb --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_search.xml b/core/resources/src/commonMain/composeResources/drawable/ic_search.xml new file mode 100644 index 000000000..cd121c00a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_security.xml b/core/resources/src/commonMain/composeResources/drawable/ic_security.xml new file mode 100644 index 000000000..735e158b0 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_security.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_select_all.xml b/core/resources/src/commonMain/composeResources/drawable/ic_select_all.xml new file mode 100644 index 000000000..457ce4efc --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_select_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_send.xml b/core/resources/src/commonMain/composeResources/drawable/ic_send.xml new file mode 100644 index 000000000..e974a9254 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_sensors.xml b/core/resources/src/commonMain/composeResources/drawable/ic_sensors.xml new file mode 100644 index 000000000..a5c15d2a7 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_sensors.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_settings.xml b/core/resources/src/commonMain/composeResources/drawable/ic_settings.xml new file mode 100644 index 000000000..0c5870e1e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_settings_ethernet.xml b/core/resources/src/commonMain/composeResources/drawable/ic_settings_ethernet.xml new file mode 100644 index 000000000..a03f3e402 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_settings_ethernet.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_settings_input_antenna.xml b/core/resources/src/commonMain/composeResources/drawable/ic_settings_input_antenna.xml new file mode 100644 index 000000000..3f17f3fa1 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_settings_input_antenna.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_settings_remote.xml b/core/resources/src/commonMain/composeResources/drawable/ic_settings_remote.xml new file mode 100644 index 000000000..f62a1c642 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_settings_remote.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_share.xml b/core/resources/src/commonMain/composeResources/drawable/ic_share.xml new file mode 100644 index 000000000..b1d30af3b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_0_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_0_bar.xml new file mode 100644 index 000000000..11f20972b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_0_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_1_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_1_bar.xml new file mode 100644 index 000000000..82143eb6b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_1_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_2_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_2_bar.xml new file mode 100644 index 000000000..e4202f9b6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_2_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_3_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_3_bar.xml new file mode 100644 index 000000000..c46ee9405 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_3_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_4_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_4_bar.xml new file mode 100644 index 000000000..db0759d20 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_4_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt.xml new file mode 100644 index 000000000..87dec5806 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_1_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_1_bar.xml new file mode 100644 index 000000000..b38b4b1d2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_1_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_2_bar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_2_bar.xml new file mode 100644 index 000000000..062acca7d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_alt_2_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_off.xml new file mode 100644 index 000000000..1ac0f2f21 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_signal_cellular_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_social_distance.xml b/core/resources/src/commonMain/composeResources/drawable/ic_social_distance.xml new file mode 100644 index 000000000..b2742ecbf --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_social_distance.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_soil_moisture.xml b/core/resources/src/commonMain/composeResources/drawable/ic_soil_moisture.xml index cee547ca5..a95e93ff6 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_soil_moisture.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_soil_moisture.xml @@ -1,11 +1,18 @@ - - - - - - - - - - + + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_soil_temperature.xml b/core/resources/src/commonMain/composeResources/drawable/ic_soil_temperature.xml index 6b1e4611f..452efdcab 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_soil_temperature.xml +++ b/core/resources/src/commonMain/composeResources/drawable/ic_soil_temperature.xml @@ -1,11 +1,18 @@ - - - - - - - - - - + + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_sort.xml b/core/resources/src/commonMain/composeResources/drawable/ic_sort.xml new file mode 100644 index 000000000..52cf98588 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes.xml b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes.xml new file mode 100644 index 000000000..79d018931 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes_off.xml new file mode 100644 index 000000000..b629dbeb9 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_notes_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_speaker_phone.xml b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_phone.xml new file mode 100644 index 000000000..fb562e87e --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_speaker_phone.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_speed.xml b/core/resources/src/commonMain/composeResources/drawable/ic_speed.xml new file mode 100644 index 000000000..e006d0f54 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_speed.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_ssid_chart.xml b/core/resources/src/commonMain/composeResources/drawable/ic_ssid_chart.xml new file mode 100644 index 000000000..be9d2ced6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_ssid_chart.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_stacked_line_chart.xml b/core/resources/src/commonMain/composeResources/drawable/ic_stacked_line_chart.xml new file mode 100644 index 000000000..d43d3ca8a --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_stacked_line_chart.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_star.xml b/core/resources/src/commonMain/composeResources/drawable/ic_star.xml new file mode 100644 index 000000000..7e23f5ac2 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_star_border.xml b/core/resources/src/commonMain/composeResources/drawable/ic_star_border.xml new file mode 100644 index 000000000..b679cae97 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_star_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_storage.xml b/core/resources/src/commonMain/composeResources/drawable/ic_storage.xml new file mode 100644 index 000000000..122fcbba5 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_system_update.xml b/core/resources/src/commonMain/composeResources/drawable/ic_system_update.xml new file mode 100644 index 000000000..b4735d3fd --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_system_update.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_terminal.xml b/core/resources/src/commonMain/composeResources/drawable/ic_terminal.xml new file mode 100644 index 000000000..53a7a529d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_terminal.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_thermostat.xml b/core/resources/src/commonMain/composeResources/drawable/ic_thermostat.xml new file mode 100644 index 000000000..5257f7fe6 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_thermostat.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_thumb_up.xml b/core/resources/src/commonMain/composeResources/drawable/ic_thumb_up.xml new file mode 100644 index 000000000..26e22dd91 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_thumb_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_trip_origin.xml b/core/resources/src/commonMain/composeResources/drawable/ic_trip_origin.xml new file mode 100644 index 000000000..7786fdcc4 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_trip_origin.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_tsunami.xml b/core/resources/src/commonMain/composeResources/drawable/ic_tsunami.xml new file mode 100644 index 000000000..dd4a4e5bc --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_tsunami.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_tune.xml b/core/resources/src/commonMain/composeResources/drawable/ic_tune.xml new file mode 100644 index 000000000..100f97e99 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_upload.xml b/core/resources/src/commonMain/composeResources/drawable/ic_upload.xml new file mode 100644 index 000000000..faba85f21 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_usb.xml b/core/resources/src/commonMain/composeResources/drawable/ic_usb.xml new file mode 100644 index 000000000..b143310ea --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_usb.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_usb_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_usb_off.xml new file mode 100644 index 000000000..4fd611054 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_usb_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_verified.xml b/core/resources/src/commonMain/composeResources/drawable/ic_verified.xml new file mode 100644 index 000000000..74642c599 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_verified.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_visibility.xml b/core/resources/src/commonMain/composeResources/drawable/ic_visibility.xml new file mode 100644 index 000000000..814640c76 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_visibility.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_visibility_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..a481a9e24 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_volume_mute.xml b/core/resources/src/commonMain/composeResources/drawable/ic_volume_mute.xml new file mode 100644 index 000000000..b04e1c600 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_volume_mute.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_volume_off.xml b/core/resources/src/commonMain/composeResources/drawable/ic_volume_off.xml new file mode 100644 index 000000000..88db37a5f --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_volume_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_volume_up.xml b/core/resources/src/commonMain/composeResources/drawable/ic_volume_up.xml new file mode 100644 index 000000000..04cb9e1bc --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_volume_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_warning.xml b/core/resources/src/commonMain/composeResources/drawable/ic_warning.xml new file mode 100644 index 000000000..56625f1ea --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_water_drop.xml b/core/resources/src/commonMain/composeResources/drawable/ic_water_drop.xml new file mode 100644 index 000000000..4b4df67d8 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_water_drop.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_waving_hand.xml b/core/resources/src/commonMain/composeResources/drawable/ic_waving_hand.xml new file mode 100644 index 000000000..9e82f596d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_waving_hand.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_wifi.xml b/core/resources/src/commonMain/composeResources/drawable/ic_wifi.xml new file mode 100644 index 000000000..af3ab82d3 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_wifi.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_wifi_channel.xml b/core/resources/src/commonMain/composeResources/drawable/ic_wifi_channel.xml new file mode 100644 index 000000000..2bd6d8f17 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_wifi_channel.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_work.xml b/core/resources/src/commonMain/composeResources/drawable/ic_work.xml new file mode 100644 index 000000000..c4aa6ac2d --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_work.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index 55427884a..2e4eaf53c 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -20,7 +20,6 @@ عربي عربي عربي - المزيد عربي عربي عربي @@ -49,30 +48,22 @@ المفتاح العام غير معروف المفتاح المؤقت غير جيد المفتاح العام غير مسموح - لا يوجد اسم القناة رمز الاستجابة السريع اسم المستخدم غير معروف ارسل - لم تقم بعد بإقران راديو متوافق مع Meshtastic مع هذا الهاتف. الرجاء إقران جهاز وتعيين اسم المستخدم الخاص بك.\n\nهذا التطبيق مفتوح المصدر قيد التطوير، إذا وجدت مشاكل يرجى الاتصال معنا على هذا الموقع: https://github.com/orgs/meshtastic/discussions\n\nلمزيد من المعلومات راجع صفحة الويب الخاصة بنا - www.Meshtastic.org. أنت قبول إلغاء حفظ تم تلقي رابط القناة الجديدة - الإبلاغ عن الخطأ - الإبلاغ عن خطأ - هل أنت متأكد من أنك تريد الإبلاغ عن خطأ؟ بعد الإبلاغ، يرجى النشر في https://github.com/orgs/meshtastic/discussions حتى نتمكن من مطابقة التقرير مع ما وجدته. إبلاغ - اكتملت عملية الربط، سيتم بدء الخدمة - فشل عملية الربط، الرجاء الاختيار مرة أخرى تم إيقاف الوصول إلى الموقع، لا يمكن تحديد موقع للشبكة. مشاركة انقطع الاتصال الجهاز في وضعية السكون عنوان الـ IP: - متصل بالراديو (%1$s) غير متصل تم الاتصال بالراديو، إلا أن الجهاز في وضعية السكون مطلوب تحديث التطبيق @@ -121,7 +112,6 @@ تشفير المفتاح العام المفتاح العام غير متطابق إشعارات العقدة الجديدة - المزيد من المعلومات مؤشر القوة النسبية الإدارة سيئ @@ -133,10 +123,8 @@ جودة الإشارة مباشره 24 ساعة - 48 ساعة أسبوع أسبوعين - اربع أسابيع الأعلى عمر غير معروف نسخ @@ -164,10 +152,9 @@ رقم التسلسلي إعدادات الصوت الرسائل - الجهاز إعدادات لورا الجهة - إعدادات الحماية + انقطع الاتصال استغرق وقت طويل المسافة الإعدادات @@ -187,4 +174,5 @@ إعدادات بلوتوث + عربي diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 154c8a0ff..cb615de37 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -26,7 +26,6 @@ Схаваць вузлы па-за сеткай Паказваць толькі прамыя вузлы Вы праглядаеце ігнараваныя вузлы,\nНацісніце, каб вярнуцца да спісу вузлоў. - Паказаць падрабязнасці Сартаваць па Параметры сартавання вузлоў Па алфавіце @@ -55,30 +54,12 @@ Невядомы адкрыты ключ Няправільны ключ сесіі Адкрыты ключ не аўтарызаваны - CLIENT Прылада для паведамленняў, што працуе з прыкладаннем або самастойна. - CLIENT MUTE Прылада, якая не перасылае пакеты ад іншых прылад. - ROUTER Інфраструктурны вузел для пашырэння пакрыцця сеткі праз перасылку паведамленняў. Бачны ў спісе вузлоў. - ROUTER CLIENT - REPEATER Інфраструктурны вузел для пашырэння пакрыцця сеткі праз перасылку паведамленняў з мінімальнымі накладнымі выдаткамі. Не бачны ў спісе вузлоў. - TRACKER Транслюе пакеты з GPS-каардынатамі з высокім прыярытэтам. - SENSOR - TAK Аптымізавана для сувязі з сістэмай ATAK, змяншае руцінныя трансляцыі. - CLIENT HIDDEN - LOST AND FOUND - TAK TRACKER - ROUTER LATE - Усе - Усе, і не разбіраць - Толькі мясцовыя - Толькі знаёмыя - Нічога - Толькі асноўныя нумары партоў Адсылае месцазнаходжанне на асноўным канале калі націснуць кнопку тройчы. Зрабіць як на тэлефоне @@ -94,8 +75,6 @@ Скасаваць Скасаваць змены Запісаць - Паведаміць пра памылку - Паведаміць пра памылку Справаздача Падзяліцца Убачылі новы вузел: %1$s @@ -140,7 +119,6 @@ Дадаць Змяніць Прыбраць - 1 гадзіна 8 гадзін 1 тыдзень Назаўсёды @@ -150,17 +128,14 @@ Журнал Звесткі Якасць паветра - Больш звестак сігнал-шум адносная магутнасць Месцазнаходжанне Нічога Якасць сігнала 24г - 48г 1тыд 2тыд - 4тыд Канал 1 Канал 2 Канал 3 @@ -188,19 +163,17 @@ Зялёны Сіні Паведамленні - Прылада Тып OLED Граць LoRa Рэгіён + Адлучана + Злучаны Імя карыстальніка Пароль - Сетка Уключана SSID IP - Месцазнаходжанне - Бяспека Прыватны ключ Скончыўся час чакання Сервер @@ -248,4 +221,6 @@ Чырвоны Сіні Зялёны + Meshtastic + Фільтраваць diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index d93f9b5dc..f69e137d9 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -27,7 +27,6 @@ Скриване на офлайн възлите Показване само на директни възли Преглеждате игнорирани възли.\nНатиснете, за да се върнете към списъка с възли. - Показване на детайли Сортиране по Опции за сортиране на възлите А-Я @@ -45,6 +44,8 @@ Неразпознат Изчакване за потвърждение Наредено на опашка за изпращане + Доставено до mesh + Неизвестно Признато Няма маршрут @@ -61,32 +62,19 @@ Неизвестен публичен ключ Невалиден ключ за сесия Публичният ключ е неоторизиран - Клиент Свързано с приложение или самостоятелно устройство за съобщения. Устройство, което не препредава пакети от други устройства.фигурир Третира пакетите от или до предпочитани възли като ROUTER_LATE, а всички останали пакети като CLIENT. - Рутер Инфраструктурен възел за разширяване на мрежовото покритие чрез препредаване на съобщения. Вижда се в списъка с възли. - Рутер клиент Комбинация от РУТЕР и КЛИЕНТ. Не е за мобилни устройства. - Ретранслатор Инфраструктурен възел за разширяване на мрежовото покритие чрез препредаване на съобщения с минимални разходи. Не се вижда в списъка с възли. - Тракер Излъчва приоритетно пакети за GPS позиция - Сензор Излъчва приоритетно телеметрични пакети. - TAK Оптимизирано за комуникация със системата ATAK, намалява рутинните излъчвания. - Скрит клиент Устройство, което излъчва само при необходимост за скритост или пестене на енергия. - Загубено и намерено Редовно излъчва местоположението като съобщение до канала по подразбиране, за да подпомогне възстановяването на устройството. Инфраструктурен възел, който винаги препредава пакети веднъж, но само след всички останали режими, осигурявайки допълнително покритие за локалните клъстери. Вижда се в списъка с възли. - Всички Препредава всяко наблюдавано съобщение, ако е било на нашия частен канал или от друга мрежа със същите параметри на lora. - Само локално - Само известни - Няма Изпраща позиция в основния канал, когато потребителският бутон бъде щракнат три пъти. Часова зона за дати на екрана на устройството и в дневника. Използване на часовата зона на телефона @@ -122,7 +110,6 @@ QR код Неизвестен потребител Изпрати - Все още не сте сдвоили радио, съвместимо с Meshtastic, с този телефон. Моля, сдвоете устройство и задайте вашето потребителско име.\n\nТова приложение с отворен код е в процес на разработка, ако откриете проблеми, моля, публикувайте в нашия форум: https://github.com/orgs/meshtastic/discussions\n\nЗа повече информация вижте нашата уеб страница на адрес www.meshtastic.org. Вие Разрешаване на анализи и докладване за сривове. Приеми @@ -130,23 +117,15 @@ Отхвърляне Запис Получен е URL адрес на нов канал - Meshtastic се нуждае от активирани разрешения за местоположение, за да намира нови устройства чрез Bluetooth. Можете да ги деактивирате, когато не се използват. - Докладване за грешка - Докладвайте грешка - Сигурни ли сте, че искате да докладвате за грешка? След като докладвате, моля, публикувайте в https://github.com/orgs/meshtastic/discussions, за да можем да сравним доклада с това, което сте открили. Докладвай - Сдвояването е завършено, услугата се стартира… - Сдвояването не бе успешно, моля, опитайте отново Достъпът до местоположението е изключен, не може да предостави позиция на мрежата. Сподели Видян нов възел: %1$s Прекъсната връзка Устройството спи - Свързани: %1$s онлайн IP адрес: Порт: Свързано - Свързан с радио (%1$s) Текущи връзки: Wifi IP: Ethernet IP: @@ -168,13 +147,10 @@ Meshtastic е изграден със следните библиотеки с отворен код. Докоснете която и да е библиотека, за да видите нейния лиценз. %1$d библиотеки URL адресът на този канал е невалиден и не може да се използва - Този контакт е невалиден и не може да бъде добавен Панел за отстраняване на грешки Експортиране на журнали - Експортирането е отменено Експортирани са %1$d журнала Неуспешен запис на регистрационен файл: %1$s - Няма журнали за експортиране %1$d час %1$d часа @@ -194,7 +170,6 @@ Изчистване на всички филтри Добавяне на персонализиран филтър Предварително зададени филтри - Показване само на игнорираните възли Съхраняване на mesh мрежови журнали Деактивирайте, за да пропуснете записването на журналите на mesh мрежата на диска Изчистване на журналите @@ -226,10 +201,15 @@ Възстановяване на настройките по подразбиране Приложи Тема + Контраст Светла Тъмна По подразбиране на системата Избор на тема + Ниво на контраста + Стандартен + Среден + Висок Изпращане на местоположение в мрежата Компактно кодиране за Кирилица @@ -254,9 +234,7 @@ Изключване Изключването не се поддържа на това устройство ⚠️ Това ще ИЗКЛЮЧИ възела. Ще е необходимо физическо взаимодействие, за да се включи отново. - ⚠️ Това е възел от критична инфраструктура. Въведете името на възела, за да потвърдите: Възел: %1$s - Тип: %1$s Рестартиране Трасиране на маршрут Показване на въведение @@ -268,9 +246,7 @@ Незабавно изпращане Показване на менюто за бърз чат Скриване на менюто за бърз чат - Показване на бърз чат Фабрично нулиране - Bluetooth е дезактивиран. Моля, активирайте го в настройките на устройството си. Отваряне на настройките Версия на фърмуера: %1$s Meshtastic се нуждае от активирани разрешения за \"Устройства наблизо\", за да намира и да се свързва с устройства чрез Bluetooth. Можете да ги дезактивирате, когато не се използват. @@ -279,6 +255,7 @@ Съобщението е доставено Устройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките. Грешка + Неизвестна грешка Игнорирай Премахване от игнорирани Добави '%1$s' към списъка с игнорирани? @@ -313,7 +290,6 @@ Изтрий Този възел ще бъде премахнат от вашия списък, докато вашият възел не получи данни от него отново. Заглуши нотификациите - 1 час 8 часа 1 седмица Винаги @@ -330,13 +306,12 @@ Батерия Използване на канала Използване на ефира - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s записа Брой отскоци - Брой отскоци: %1$d Информация Използване на текущия канал, включително добре формулиан TX, RX и деформиран RX (така наречен шум). Процент от ефирното време за предаване, използвано през последния час. @@ -348,14 +323,10 @@ Несъответствие на публичния ключ Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие. Известия за нови възли - Повече подробности SNR - Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни. RSSI - Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка. (Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500. Метрики на устройството - Карта на възела Позиция Последна актуализация на позицията Показатели на околната среда @@ -380,15 +351,23 @@ Вижте на картата Показване на %1$d/%2$d възела Продължителност: %1$s s - %1$s - %2$s + Няма отговор + Натоварване 1m + Натоварване 5m + Натоварване 15m + Средно натоварване на системата за една минута + Средно натоварване на системата за пет минути + Средно натоварване на системата за петнадесет минути + Налична системна памет в байтове 24Ч - 48Ч - Макс + Мин + Разгъване на диаграмата + Свиване на диаграмата Неизвестна възраст Копиране Критичен сигнал! @@ -401,6 +380,11 @@ Канал 1 Канал 2 Канал 3 + Канал 4 + Канал 5 + Канал 6 + Канал 7 + Канал 8 Текущ Напрежение Сигурни ли сте? @@ -410,8 +394,8 @@ Известия за изтощена батерия Батерията е изтощена: %1$s Известия за изтощена батерия (любими възли) + Баро Активиран - Конфигуриране на UDP Последно чут: %2$s
Последна позиция: %3$s
Батерия: %4$s]]>
Потребител Канали @@ -466,7 +450,6 @@ Никога да не се изтриват журналите Приятелско име Използване на режим INPUT_PULLUP - Устройство Роля на устройството GPIO за бутон GPIO за зумер @@ -487,13 +470,15 @@ Известия при получаване на сигнал/позвъняване Използване на PWM зумер Тон на звънене + Импортирана мелодия + Файлът е празен + Грешка при импортиране: %1$s LoRa Опции Разширени Използване на предварително зададени настройки Предварително зададени Широчина на честотната лента - Отместване на честотата (MHz) Регион Брой отскоци Предаването е активирано @@ -501,6 +486,17 @@ Честотен слот Игнориране на MQTT Конфигуриране на MQTT + Неактивен + Прекъсната връзка + Свързване… + Свързано + Повторно свързване… + Повторно свързване (опит %1$d) — %2$s + Тестване на връзката + Достъпен. Брокерът е приел идентификационните данни. + Достъпен (%1$s) + Хостът не е намерен + Връзката е неуспешна MQTT е активиран Адрес Потребителско име @@ -510,7 +506,6 @@ Прокси към клиент е активиран Интервал на актуализиране (секунди) Предаване през LoRa - Мрежа Опции за Wi-Fi Активиран Wi-Fi е активиран @@ -523,28 +518,19 @@ Режим на IPv4 IP Шлюз + DNS Конфигуриране на Paxcounter Paxcounter е активиран Праг на WiFi RSSI (по подразбиране -80) Праг на BLE RSSI (по подразбиране -80) - Позиция - Интервал на излъчване на позицията (секунди) - Използване на фиксирана позиция Географска ширина Географска дължина - Надморска височина (метри) Зададено от текущото местоположение на телефона Режим на GPS (физически хардуер) - Интервал на актуализиране на GPS (секунди) - Предефиниране на GPS_RX_PIN - Предефиниране на GPS_TX_PIN - Предефиниране на PIN_GPS_EN Конфигуриране на захранването Активиране на енергоспестяващ режим Изключване при загуба на захранване - Забавяне при изключване при изтощаване на батерията (секунди) Продължителност на супер дълбок сън - Продължителност на лек сън Минимално време за събуждане I2C адрес на батерията INA_2XX Конфигуриране на Тест на обхвата @@ -553,7 +539,6 @@ Конфигуриране на отдалечения хардуер Отдалечен хардуер е активиран Налични пинове - Сигурност Администраторски ключове Публичен ключ Частен ключ @@ -564,6 +549,8 @@ Серийната връзка е активирана Echo е активирано Серийна скорост на предаване + RX + TX Сериен режим Брой записи @@ -588,6 +575,11 @@ Налягане Разстояние Вятър + Скорост на вятъра + Порив на вятъра + Посока на вятъра + Дъжд (1ч) + Дъжд (24 ч) Тегло Радиация @@ -618,7 +610,6 @@ Натиснете и плъзнете, за да пренаредите Включване на звука Динамична - Сканиране на QR кода Споделяне на контакт Бележки Добавяне на лична бележка... @@ -642,7 +633,6 @@ Когато е активирано, устройството ще показва времето на екрана в 12-часов формат. Хост Свободна памет - Свободен диск Потребителски низ Свързване Карта на Mesh @@ -665,6 +655,11 @@ Филтър на картата\n Само любими Показване на пътни точки + Проверка на ключ + Заявка за проверка на ключ + Проверката на ключа е завършена + Открит е дублиран публичен ключ + Открит е слаб ключ за криптиране Открити са компрометирани ключове, изберете OK за регенериране. Регенериране на частния ключ Сигурни ли сте, че искате да генерирате отново своя частен ключ?\n\nВъзлите, които може да са обменяли преди това ключове с възела, ще трябва да го премахнат и да обменят отново ключове, за да възобновят защитената комуникация. @@ -675,8 +670,6 @@ Отдалечен (%1$d онлайн / %2$d показани / %3$d общо) Прекъсване на връзката - Няма открити мрежови устройства. - Няма открити USB серийни устройства. Превъртане до края Meshtastic Състояние на сигурността @@ -690,8 +683,6 @@ Почистване на базата данни с възлите Почистване на възлите, последно видяни преди повече от %1$d дни Почистване само на неизвестните възли - Почистване на възлите с ниско/никакво взаимодействие - Почистване на игнорираните възли Почистете сега Това ще премахне %1$d възела от вашата база данни. Това действие не може да бъде отменено. Зеленият катинар означава, че каналът е сигурно криптиран със 128 или 256-битов AES ключ. @@ -709,17 +700,20 @@ Показване на всички значения Показване на текущия статус Отхвърляне - Сигурни ли сте, че искате да изтриете този възел? - Забравяне на връзката - Сигурни ли сте, че искате да забравите тази връзка? Отговор на %1$s Да се изтрият ли съобщенията? Изчистване на избора Съобщение Въведете съобщение PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s + Осигуряване на Wi-Fi за mPWRD-OS Bluetooth устройства - Сдвоени устройства Свързано устройство Преглед на изданието Изтегляне @@ -763,16 +757,13 @@ Meshtastic използва известия, за да ви държи в течение за нови съобщения и други важни събития. Можете да актуализирате разрешенията си за известия по всяко време от настройките. Напред %1$d възела са на опашка за изтриване: - Свързване с устройство Нормален Сателит Терен Хибриден Управление на слоевете на картата Слоевете на картата поддържат формати .kml, .kmz или GeoJSON. - Слоеве на картата Няма заредени слоеве на картата. - Добавяне на слой Скриване на слоя Показване на слой Премахване на слой @@ -800,19 +791,16 @@ 48 часа Филтриране по време на последното чуване: %1$s %1$d dBm - Няма налично приложение за обработка на връзката. Системни настройки Няма налична статистика Анализите се събират, за да ни помогнат да подобрим приложението за Android (благодарим ви). Ще получаваме анонимизирана информация за поведението на потребителите. Това включва отчети за сривове, екрани, използвани в приложението и др. Аналитични платформи: За повече информация вижте нашата политика за поверителност. Не е зададен - 0 - Препредадено от: %1$s %1$s обикновено се доставя с буутлоудър, който не поддържа OTA актуализации. Може да се наложи да флашнете OTA - съвместим буутлоудър през USB, преди да флашнете OTA. Научете повече За RAK WisBlock RAK4631, използвайте серийния DFU инструмент на производителя (например, adafruit-nrfutil dfu serial с предоставения .zip файл с буутлоудъра). Копирането само на файла .uf2 няма да актуализира буутлоудъра. Да не се показва отново за това устройство - USB устройства Актуализация на фърмуера Проверка за актуализации... @@ -828,16 +816,12 @@ Актуализацията е успешна! Готово Стартиране на DFU... - Актуализиране... %1$s Активиране на режим DFU... Валидиране на фърмуера... - Прекъсване... Неизвестен модел хардуер: %1$d - Свързаното устройство не е валидно BLE устройство или адресът е неизвестен (%1$s). Няма свързано устройство Не е намерен фърмуер за %1$s в изданието. Извличане на фърмуера... - Изключване за стартиране на услугата DFU... Неуспешна актуализация Дръжте устройството близо до телефона си. Не затваряйте приложението. @@ -851,7 +835,6 @@ Чирпи казва, \"Keep your ladder handy!\" Чирпи Рестартиране в DFU... - Изчакване за DFU устройство... Програмиране на устройството, моля изчакайте... Прехвърляне на файл през USB BLE OTA @@ -864,20 +847,14 @@ Цел: %1$s Бележки за изданието Неизвестна грешка - Локалната актуализация не е успешна - DFU грешка: %1$s Липсва информация за потребителя на възела. + Батерията е твърде изтощена (%1$d%). Моля, заредете устройството си преди актуализиране. Актуализацията през USB не е успешна OTA актуализацията не е успешна: %1$s - Зареждане на фърмуера... Изчаква се устройството да се рестартира в режим OTA... Свързване с устройството (опит %1$d/%2$d)... - Проверка на версията на устройството... Стартиране на OTA актуализация... Качване на фърмуера... - Рестартиране на устройството... - Актуализация на фърмуера - Състояние на актуализацията на фърмуера Изтриване... Назад Не е зададен @@ -908,15 +885,12 @@ Приблизителна площ: неизвестна точност Маркиране като прочетено Сега - Добавяне на канали Следните канали бяха открити в QR кода. Изберете канала, който искате да добавите към устройството си. Съществуващите канали ще бъдат запазени. - Замяна на канали & настройки Този QR код съдържа пълна конфигурация. Той ще ЗАМЕНИ съществуващите ви канали и настройки на радиото. Всички съществуващи канали ще бъдат премахнати. Зареждане Активиране на филтрирането Съобщенията, съдържащи тези думи, ще бъдат скрити - %1$d филтрирани Показване на %1$d филтрирани Скриване на %1$d филтрирани Филтрирани @@ -933,6 +907,7 @@ Конфигурация Управлявайте безжично настройките и каналите на вашето устройство. Избор на стил на картата + Батерия: %1$d% Възли: %1$d онлайн / %2$d общо Време на работа: %1$s Трафик: TX %1$d / RX %2$d (D: %3$d) @@ -940,17 +915,16 @@ Шум %1$d dBm %1$d / %2$d %1$s - Статистика на Meshtastic Опресняване Актуализирано Добавяне на мрежов слой - Опресняване на слоя Локален MBTiles файл Добавяне на локален MBTiles файл - Копирането на MBTiles файла във вътрешната памет не е успешно. TAK (ATAK) Конфигурация на TAK + Активиране на локален TAK сървър + Стартира TCP сървър на порт 8089 за ATAK връзки Цвят на екипа Роля на члена Неопределен @@ -978,19 +952,31 @@ Управление на трафика Модулът е активиран Максимален брой отскоци за директен отговор - Все още няма съобщения - %1$d непрочетени - Поддръжката на карти скоро ще бъде налична и за настолни компютри - Няма свързано устройство - Готово за актуализация на фърмуера - Проверка за актуализации - Изтегляне на фърмуера - Актуализиране на устройството Забележка - Уверете се, че устройството ви е напълно заредено, преди да започнете актуализация на фърмуера. Не изключвайте устройството от контакта или захранването по време на процеса на актуализация. Тема: %1$s, Език: %2$s Налични файлове (%1$d): + - %1$s (%2$d байта) Свързване Готово - Опресняване + Осигуряване на Wi-Fi за mPWRD-OS + Научете повече за проекта mPWRD-OS \nhttps://github.com/mPWRD-OS + Търси се устройство… + Готово за сканиране за WiFi мрежи. + Сканиране за мрежи + Сканиране… + Прилагане на конфигурацията на WiFi… + Няма намерени мрежи + Не можа да се свърже: %1$s + Неуспешно сканиране за WiFi мрежи: %1$s + %1$d% + Налични мрежи + Име на мрежата (SSID) + Въведете или изберете мрежа + 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 f7cde238d..22b52e28e 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -24,7 +24,6 @@ Oculta nodes offline Només veure nodes directes Estàs veient nodes ignorats, \n Prem per tornar al llistat de nodes - Veure detalls Opcions per ordenar nodes A-Z Canal @@ -76,24 +75,17 @@ Codi QR Nom d'usuari desconegut Enviar - Encara no has emparellat una ràdio compatible amb Meshtastic amb aquest telèfon. Si us plau emparella un dispositiu i configura el teu nom d'usuari. \n\nAquesta aplicació de codi obert està en desenvolupament. Si hi trobes problemes publica-ho en el nostre fòrum https://github.com/orgs/meshtastic/discussions\n\nPer a més informació visita la nostra pàgina web - www.meshtastic.org. Tu Acceptar Cancel·lar Desar Nova URL de canal rebuda - Informar d'error - Informar d'un error - Estàs segur que vols informar d'un error? Després d'informar-ne, si us plau publica en https://github.com/orgs/meshtastic/discussions de tal manera que puguem emparellar l'informe amb allò que has trobat. Informe - Emparellament completat, iniciar servei - Emparellament fallit, si us plau selecciona un altre cop Accés al posicionament deshabilitat, no es pot proveir la posició a la xarxa. Compartir Desconnectat Dispositiu hivernant Adreça IP: - Connectat a ràdio (%1$s) No connectat Connectat a ràdio, però està hivernant Actualització de l'aplicació necessària @@ -190,6 +182,7 @@ Sempre Traçar ruta Regió + Desconnectat Temps esgotat Distància Meshtastic @@ -207,4 +200,6 @@ + Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index cc730e459..d3e0566ac 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -26,7 +26,6 @@ Skrýt offline uzly Zobrazit jen přímé uzly Prohlížíte ignorované uzly,\nStiskněte pro návrat do seznamu uzlů. - Zobrazit detaily Seřadit podle Možnosti řazení uzlů A-Z @@ -41,6 +40,7 @@ Neznámý Čeká na potvrzení Ve frontě k odeslání + Neznámé Potvrzený příjem Žádná trasa Obdrženo negativní potvrzení @@ -65,20 +65,16 @@ Kombinace ROUTER a CLIENT. Ne u mobilních zařízení. Uzel infrastruktury pro rozšíření pokrytí sítě přenosem zpráv s minimální režií. Není viditelné v seznamu uzlů. Prioritně vysílá pakety s pozicí GPS. - Senzor Prioritně vysílá pakety s telemetrií. - TAK Optimalizované pro systémy komunikace ATAK, snižuje rutinní vysílání. Zařízení, které vysílá pouze podle potřeby pro utajení nebo úsporu energie. Pravidelně vysílá polohu jako zprávu do výchozího kanálu a pomáhá tak při hledání ztraceného zařízení. Povolí automatické vysílání TAK PLI a snižuje běžné vysílání. Uzel infrastruktury, který vždy jednou zopakuje pakety, ale až po všech ostatních režimech, čímž zajišťuje lepší pokrytí místních clusterů. Je viditelný v seznamu uzlů. - Vše Znovu odeslat jakoukoli pozorovanou zprávu, pokud byla na našem soukromém kanálu nebo z jiné sítě se stejnými parametry lory. Stejné chování jako ALL, ale přeskočí dekódování paketů a jednoduše je znovu vysílá. Dostupné pouze v roli Repeater. Nastavení této možnosti pro jiné role povede k chování jako u ALL. Ignoruje přijaté zprávy z cizích mesh sítí, které jsou otevřené nebo které nelze dešifrovat. Opakuje pouze zprávy na primárních / sekundárních kanálech místního uzlu. Ignoruje přijaté zprávy z cizích mesh sítí, jako je LOCAL ONLY, ale jde ještě o krok dál tím, že také ignoruje zprávy od uzlů, které již nejsou v seznamu známých uzlů daného uzlu. - Žádný Povoleno pouze pro role SENSOR, TRACKER a TAK_TRACKER. Toto nastavení zabrání všem opakovaným vysíláním, podobně jako role CLIENT_MUTE. Ignoruje pakety z nestandardních portů, jako jsou: TAK, RangeTest, PaxCounter atd. Opakuje pouze pakety se standardními porty: NodeInfo, Text, Position, Telemetry a Routing. Zachází s dvojitým poklepáním na podporovaných akcelerometrech jako se stisknutím uživatelského tlačítka. @@ -131,7 +127,6 @@ QR kód Neznámé uživatelské jméno Odeslat - Ještě jste s tímto telefonem nespárovali rádio kompatibilní s Meshtastic. Spárujte prosím zařízení a nastavte své uživatelské jméno.\n\nTato open-source aplikace je ve vývoji, pokud narazíte na problémy, napište na naše fórum: https://github.com/orgs/meshtastic/discussions\n\nDalší informace naleznete na naší webové stránce - www. meshtastic.org. Vy Povolit analýzu a hlášení pádů. Přijmout @@ -139,23 +134,15 @@ Zrušit Uložit Nová URL kanálu přijata - Meshtastic potřebuje přístup k poloze pro vyhledávání zařízení přes Bluetooth. Povolení můžete kdykoli vypnout. - Nahlášení chyby - Nahlásit chybu - Jste si jistý, že chcete nahlásit chybu? Po odeslání prosím přidejte zprávu do https://github.com/orgs/meshtastic/discussions abychom mohli přiřadit Vaši nahlášenou chybu k příspěvku. Odeslat chybové hlášení - Párování bylo úspěšné, spouštím službu - Párování selhalo, prosím zkuste to znovu Přístup k poloze zařízení nebyl povolen, není možné poskytnout polohu zařízení do Mesh sítě. Sdílet Nově objevený uzel: %1$s Odpojeno Zařízení spí - Připojeno: %1$s online IP adresa: Port: Připojeno - Připojeno k vysílači (%1$s) Připojování Nepřipojeno Není vybráno žádné zařízení @@ -174,13 +161,10 @@ Meshtastic používá následující open-source knihovny. Klepnutím zobrazíte jejich licence. %1$d knihoven Tato adresa URL kanálu je neplatná a nelze ji použít - Tento kontakt je neplatný a nelze jej přidat Panel pro ladění Exportovat protokoly - Export byl zrušen %1$d exportováno Nepodařilo se zapsat soubor protokolu: %1$s - Žádné protokoly k exportu %1$d hodina %1$d hodin @@ -201,7 +185,6 @@ Vymazat všechny filtry Přidat vlastní filtr Přednastavené filtry - Zobrazit jen ignorované uzly Uložit protokoly sítě Vypněte, pokud nechcete ukládat mesh logy na disk Vymazat protokoly @@ -233,6 +216,7 @@ Tmavý Podle systému Vyberte vzhled + Vysoká Poskytnout polohu síti Úsporné kódování pro cyriliku @@ -259,9 +243,7 @@ Vypnout Vypnutí není na tomto zařízení podporováno ⚠️ Tímto dojde k VYPNUTÍ uzlu. K jeho opětovnému zapnutí bude nutný fyzický zásah. - ⚠️ Toto je kritický infrastrukturní uzel. Pro potvrzení zadejte název uzlu: Uzel: %1$s - Typ: %1$s Restartovat Traceroute Zobrazit úvod @@ -273,9 +255,7 @@ Okamžitě odesílat Zobrazit nabídku rychlého chatu Skrýt nabídku rychlého chatu - Zobrazit nabídku rychlého chatu Obnovení továrního nastavení - Bluetooth je zakázáno. Prosím povolte jej v nastavení zařízení. Otevřít nastavení Verze firmware: %1$s Meshtastic potřebuje mít povoleno oprávnění ‚Blízká zařízení‘, aby mohl vyhledávat a připojovat zařízení přes Bluetooth. Když jej nepoužíváte, můžete jej vypnout. @@ -284,6 +264,7 @@ Doručeno Vaše zařízení se může odpojit a restartovat při aplikaci nastavení. Chyba + Neznámá chyba Ignorovat Odstranit z ignorovaných Přidat '%1$s' do seznamu ignorovaných? @@ -318,13 +299,11 @@ Odstranit Tento uzel bude odstraněn z vašeho seznamu, dokud z něj váš uzel znovu neobdrží data. Ztlumit notifikace - 1 hodina 8 hodin 1 týden Vždy Trvale ztlumeno Neztlumeno - Stav ztlumení Ztlumit oznámení pro '%1$s'? Zrušit ztlumení oznámení pro '%1$s'? Nahradit @@ -334,12 +313,12 @@ Baterie ChUtil AirUtil + %1$s %1$s: %2$s Teplota Vlhkost Logy Počet skoků - Počet skoků: %1$d Informace Využití aktuálního kanálu, včetně dobře vytvořeného TX, RX a poškozeného RX (tzv. šumu). Procento vysílacího času použitého během poslední hodiny. @@ -351,14 +330,10 @@ Neshoda veřejného klíče Informace o uživateli Oznámení o nových uzlech - Více detailů 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í @@ -386,15 +361,12 @@ Zobrazit na mapě Zobrazuji %1$d/%2$d uzlů Doba trvání: %1$s s - %1$s - %2$s Trasa směrem k cíli:\n\n Trasa zpět k nám:\n\n 1H 24H - 48H 1T 2T - 4T 1M Max Neznámé stáří @@ -420,7 +392,6 @@ Upozornění na nízký stav baterie (oblíbené uzly) Tlak Povoleno - UDP Konfigurace Naposledy slyšen: %2$s
Poslední pozice: %3$s
Baterie: %4$s]]>
Zapnout/vypnout pozici Uživatel @@ -494,7 +465,6 @@ GPIO pin ke sledování Typ spouštění detekce Použít INPUT_PULLUP režim - Zařízení Role zařízení Tlačítko GPIO Bzučák GPIO @@ -540,7 +510,6 @@ Použít předvolbu Předvolby Šířka pásma - Posun frekvence (MHz) Region Počet skoků Vysílání povoleno @@ -553,6 +522,8 @@ Ignorovat MQTT OK do MQTT Nastavení MQTT + Odpojeno + Připojeno MQTT povoleno Adresa Uživatelské jméno @@ -568,7 +539,6 @@ Informace o sousedech povoleny Interval aktualizace (v sekundách) Přenos přes LoRa - Síť Povoleno WiFi povoleno SSID @@ -584,30 +554,17 @@ Aktuální stav Práh WiFi RSSI (výchozí hodnota -80) Práh BLE RSSI (výchozí hodnota -80) - Pozice - Interval vysílání pozice (v sekundách) - Chytrá pozice povolena - Minimální vzdálenost pro inteligentní vysílání (v metrech) - Minimální interval inteligentního vysílání (v sekundách) - Použít pevnou pozici Zeměpisná šířka Zeměpisná délka - Nadmořská výška (v metrech) Použít aktuální polohu telefonu Režim GPS (fyzický modul) - Interval aktualizace GPS (v sekundách) - Předefinovat GPS_RX_PIN - Předefinovat GPS_TX_PIN - Předefinovat PIN_GPS_EN Příznaky polohy Nastavení napájení Povolit úsporný režim Vypnutí při ztrátě napájení - Interval vypnutí při napájení z baterie (sekundy) Vlastní hodnota násobiče pro ADC Doba čekání na Bluetooth Doba super hlubokého spánku - Doba lehkého spánku Minimální doba probuzení Adresa INA_2XX I2C baterie Nastavení testu pokrytí @@ -618,7 +575,6 @@ Vzdálený modul povolen Povolit přiřazení nedefinovaného pinu Dostupné piny - Zabezpečení Klíč pro přímé zprávy Administrátorský klíč Veřejný klíč @@ -675,8 +631,6 @@ Číslo uzlu Identifikátor uživatele Doba provozu - Načítání kanálů %1$d/%2$d - Načítám %1$s Časová značka Směr Rychlost @@ -690,7 +644,6 @@ Stiskněte a přetáhněte pro změnu pořadí Zrušit ztlumení Dynamický - Naskenovat QR kód Sdílet kontakt Poznámka Přidat soukromou poznámku… @@ -707,7 +660,6 @@ Metriky prostředí Metriky kvality ovzduší Metriky napájení - Lokální statistiky Metadata Akce Firmware @@ -748,8 +700,6 @@ (%1$d online / %2$d zobrazeno / %3$d celkem) Odpovědět Odpojit - Nebyla nalezena žádná síťová zařízení. - Nebyla nalezena žádná sériová zařízení USB. Meshtastic Stav zabezpečení Bezpečný @@ -761,7 +711,6 @@ Vyčistit databázi uzlů Vyčistit uzly neaktivní déle než %1$d dnů Vyčistit pouze neznámé uzly - Vyčistit ignorované uzly Vyčistit Tímto odstraníte %1$d uzlů z databáze. Tuto akci nelze vrátit zpět. Zelený zámek znamená, že kanál je bezpečně šifrován buď pomocí AES klíče 128 nebo 256 bitů. @@ -780,7 +729,6 @@ Zobrazit všechny vysvětlivky Zobrazit aktuální stav Zavřít - Opravdu chcete tento uzel odstranit? Odpověď na %1$s Zrušit odpověď Smazat zprávu? @@ -788,7 +736,6 @@ Zpráva Napište zprávu Zařízení bluetooth - Spárovaná zařízení Připojená zařízení Zobrazit vydání Stáhnout @@ -815,7 +762,6 @@ Oznámení o nově nalezených uzlech. Nízký stav baterie Oznámení o nízké úrovni baterie připojeného zařízení. - Pakety označené jako kritické budou ignorovat přepínač ztlumení i nastavení režimu Nerušit v oznamovacím centru systému. Nastavit oprávnění oznámení Poloha telefonu Meshtastic využívá polohu telefonu pro některé funkce. Oprávnění k poloze si můžete kdykoli upravit v nastavení. @@ -835,7 +781,6 @@ Nastavit kritická upozornění Meshtastic vás pomocí oznámení upozorní na nové zprávy a důležité události. Nastavení oznámení si můžete kdykoli upravit. Další - Povolit oprávnění %1$d uzlů zařazeno k odstranění: Varování: Tímto odstraníte uzly z databází v aplikaci i v zařízení.\nVybrané položky se sčítají (kombinují). Normální @@ -844,9 +789,7 @@ Hybridní Správa vrstev mapy Mapové vrstvy podporují formáty .kml, .kmz nebo GeoJSON. - Mapové vrstvy Žádné vlastní vrstvy nenačteny. - Přidat vrstvu Skrýt vrstvu Zobrazit vrstvu Odebrat vrstvu @@ -880,13 +823,11 @@ Analytické nástroje: Další informace naleznete v našich zásadách ochrany osobních údajů. Nenastaveno – 0 - Přeposláno uzlem: %1$s %1$s je obvykle dodáván s bootloaderem, který nepodporuje OTA aktualizace. Před nahráváním přes OTA může být nutné nejprve přes USB nahrát bootloader s podporou OTA. Zjistit více Pro RAK WisBlock RAK4631 použijte výrobní nástroj pro sériové DFU (například adafruit-nrfutil dfu serial s poskytnutým .zip souborem bootloaderu). Pouhé zkopírování .uf2 souboru samo o sobě bootloader neaktualizuje. U tohoto zařízení již nezobrazovat Chcete zachovat oblíbené položky? - USB zařízení Aktualizace firmware Hledání aktualizací... @@ -902,16 +843,12 @@ Aktualizace byla úspěšná! Hotovo Spouštění DFU... - Aktualizuji... %1$s Povolení režimu DFU... Kontroluji firmware... - Odpojuji se... Neznámý hardwarový model: %1$d - Připojené zařízení není BLE zařízení nebo adresa je neznámá (%1$s). DFU vyžaduje BLE. Není připojeno žádné zařízení Firmware pro %1$s nebyl ve vydání nalezen. Extrahuji firmware... - Odpojuji zařízení pro spuštění DFU služby... Aktualizace selhala Chvilku strpení, pracujeme na tom... Udržujte své zařízení v blízkosti telefonu. @@ -926,7 +863,6 @@ Chystáte se nahrát do zařízení nový firmware. Tento proces s sebou nese určitá rizika.\n\n• Ujistěte se, že je zařízení nabité.\n• Udržujte zařízení blízko telefonu.\n• Během aktualizace neukončujte aplikaci.\n\nOvěřte, zda jste vybrali správný firmware pro váš hardware. Chirpy říká: \"Žebřík měj vždycky po ruce!\" Restartuji do DFU... - Čekám na DFU zařízení... Nahrajte soubor .uf2 na DFU jednotku zařízení. Probíhá instalace, čekejte prosím... Přenos souborů přes USB @@ -941,24 +877,15 @@ Cíl: %1$s Poznámky k vydání Neznámá chyba - Lokální aktualizace selhala - Chyba DFU: %1$s - DFU přerušena Chybí informace o uživateli uzlu. Nelze načíst soubor firmwaru. - Aktualizace Nordic DFU selhala Aktualizace přes USB selhala Odmítnutá hash firmwaru. Zařízení může vyžadovat nastavení hash nebo aktualizaci bootloaderu. Aktualizace OTA selhala: %1$s - Načítám firmware... Čekání na restart zařízení do OTA režimu... Připojování k zařízení (pokus %1$d/%2$d)... - Kontroluji verzi zařízení... Spouštění aktualizace OTA... Nahrávám firmware... - Restartuji zařízení... - Aktualizace firmware - Stav aktualizace firmware Mazání... Zpět Zrušit nastavení @@ -993,13 +920,11 @@ Čekám na GPS signál pro výpočet vzdálenosti a směru. Označit jako přečtené Nyní - Přidat kanály Vyberte kanály z QR kódu, které chcete přidat. Stávající kanály nebudou změněny. Tento QR kód obsahuje kompletní konfiguraci. Tímto se NAHRADÍ vaše stávající kanály a nastavení rádia. Všechny existující kanály budou odstraněny. Načítám Zapnout filtrování - %1$d filtrováno Zobrazit %1$d filtrované Skrýt %1$d filtrované Filtrované @@ -1039,12 +964,9 @@ Modrá Zelená Minimální interval pozice (v sekundách) - Zatím žádné zprávy - %1$d nepřečtených - Není připojeno žádné zařízení - Připraveno k aktualizaci firmware Poznámka - Ujistěte se, že je vaše zařízení plně nabito před spuštěním aktualizace firmware. Během aktualizace zařízení neodpojujte nebo nevypínejte. 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 a9b891325..4755515ad 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s Filter Knotenfilter löschen Filtern nach @@ -26,7 +27,6 @@ Offline Knoten ausblenden Nur direkte Knoten anzeigen Sie sehen ignorierte Knoten,\ndrücken um zur Knotenliste zurückzukehren. - Details anzeigen Sortieren nach Sortieroptionen A-Z @@ -45,6 +45,8 @@ Unbekannt Warte auf Bestätigung Zur Sende-Warteschlange hinzugefügt + Versand ins Netz + Unbekannt Routen über den SF++ Weg. Bestätigt auf dem SF++ Weg. Bestätigt @@ -64,43 +66,24 @@ Fehlerhafter Sitzungsschlüssel Öffentlicher Schlüssel nicht autorisiert PKI senden fehlgeschlagen, kein öffentlicher Schlüssel - Client Mit der App verbundenes oder eigenständiges Messaging-Gerät. - Client Mute Gerät, das keine Pakete von anderen Geräten weiterleitet. - Client Base Pakete von oder zu favorisierten Knoten werden als ROUTER_LATE weitergeleitet und alle anderen Pakete als CLIENT. - Router Knoten zur Erweiterung der Netzabdeckung durch Weiterleiten von Nachrichten. In Knotenliste sichtbar. - Router Client Kombination von ROUTER und CLIENT. Nicht für mobile Endgeräte. - Repeater Infrastrukturknoten zur Erweiterung der Netzabdeckung durch Weiterleitung von Nachrichten mit minimalem Overhead. In der Knotenliste nicht sichtbar. - Tracker GPS Standortnachricht mit Priorität gesendet. - Sensor Telemetrienachricht mit Priorität gesendet. - TAK Optimiert für ATAK-Systemkommunikation, verringert die Anzahl der Routineübertragungen. - Client - Versteckt Gerät, das nur bei Bedarf sendet, um nicht entdeckt zu werden oder Strom zu sparen. - Tracker Sendet den Standort regelmäßig als Nachricht an den Standardkanal, um das Gerät wiederzufinden. - TAK Tracker Aktiviert automatische TAK-PLI-Übertragungen und verringert die Anzahl der Routineübertragungen. - Router mit Verzögerung Infrastruktur-Node, der Pakete immer einmal erneut sendet, jedoch erst, nachdem alle anderen Modi durchlaufen wurden, um zusätzliche Abdeckung für lokale Cluster sicherzustellen. Sichtbar in der Node-Liste. - Alle Sende jede empfangene Nachricht erneut aus, egal ob sie auf einem privaten Kanal oder von einem anderen Mesh mit den gleichen LoRa Parametern stammt. - Alle, überspringe Dekodierung Das gleiche Verhalten wie ALLE aber überspringt die Paketdekodierung und sendet sie einfach erneut. Nur in Repeater Rolle verfügbar. Wenn Sie diese auf jede andere Rolle setzen, wird ALLE Verhaltensweisen folgen. - Nur lokal Ignoriert beobachtete Nachrichten aus fremden Netzen, die offen sind oder die, die nicht entschlüsselt werden können. Sendet nur die Nachricht auf den Knoten lokalen primären / sekundären Kanälen. - Nur Bekannte Ignoriert beobachtete Nachrichten von fremden Meshes wie bei LOCAL ONLY, geht jedoch einen Schritt weiter, indem auch Nachrichten von Nodes ignoriert werden, die nicht bereits in der bekannten Liste der Nodes enthalten sind. - Keins Nur für SENSOR, TRACKER und TAK_TRACKER zulässig. Verhindert alle Übertragungen, nicht anders als CLIENT_MUTE Rolle. - Nur Kernanschlussnummern Ignoriert Nachrichten von nicht standardmäßigen Anschlussnummern wie: TAK, Range Test, Besucherzähler, etc. Sendet nur Nachrichten wie: Knoteninfo, Text, Standort, Telemetrie und Weiterleitung erneut. Behandle doppeltes Antippen mit unterstützten Beschleunigungssensoren wie einen Benutzer-Tastendruck. Senden Sie den Standort auf dem primären Kanal, wenn dreimal auf die Benutzertaste gedrückt wird. @@ -167,7 +150,6 @@ QR-Code Unbekannter Nutzername Senden - Sie haben noch kein zu Meshtastic kompatibles Funkgerät mit diesem Telefon gekoppelt. Bitte koppeln Sie ein Gerät und legen Sie Ihren Benutzernamen fest.\n\nDiese quelloffene App befindet sich im Test. Wenn Sie Probleme finden, veröffentlichen Sie diese bitte auf unserer Website im Chat.\n\nWeitere Informationen finden Sie auf unserer Webseite - www.meshtastic.org. Du Analyse und Absturzberichterstattung erlauben. Akzeptieren @@ -175,23 +157,15 @@ Verwerfen Speichern Neue Kanal-URL empfangen - Meshtastic benötigt aktivierte Standortberechtigungen, um neue Geräte über Bluetooth zu finden. Sie können die Funktion deaktivieren, wenn sie nicht verwendet wird. - Fehler melden - Fehler melden - Sind Sie sicher, dass Sie einen Fehler melden möchten? Nach dem Melden bitte auf https://github.com/orgs/meshtastic/discussions eine Nachricht veröffentlichen, damit wir feststellen können ob die Fehlermeldung mit dem was Sie gefunden haben übereinstimmen. Melden - Kopplung erfolgreich, der Dienst wird gestartet - Kopplung fehlgeschlagen, bitte erneut auswählen Standortzugriff ist deaktiviert, es kann kein Standort zum Mesh bereitgestellt werden. Teilen Neuen Knoten gesehen: %1$s Verbindung getrennt Gerät schläft - Verbunden: %1$s online IP-Adresse: Port: Verbunden - Mit Funkgerät verbunden (%1$s) Aktuelle Verbindungen: WLAN IP: Ethernet IP: @@ -213,14 +187,11 @@ Meshtastic wurde mit den folgenden Quellen offenen Bibliotheken gebaut. Tippen Sie auf eine beliebige Bibliothek, um ihre Lizenz anzuzeigen. %1$d Bibliotheken Diese Kanal-URL ist ungültig und kann nicht verwendet werden - Dieser Kontakt ist ungültig und kann nicht hinzugefügt werden Debug-Ausgaben Dekodiertes Payload: Protokolle exportieren - Export abgebrochen %1$d Protokolle exportiert Fehler beim Scheiben der Protokolldatei: %1$s - Keine Logs zum Exportieren %1$d Stunde %1$d Stunden @@ -240,7 +211,6 @@ Alle Filter löschen Benutzerdefinierten Filter hinzufügen Voreingestellte Filter - Nur ignorierte Knoten anzeigen Netzprotokolle speichern Deaktivieren, um das Schreiben von Netzprotokollen auf die Festplatte zu überspringen Protokolle löschen @@ -283,10 +253,15 @@ Auf Standardeinstellungen zurücksetzen Anwenden Design + Kontrast Hell Dunkel System Design auswählen + Kontrast + Standard + Medium Fast + Hoch Standort zum Mesh angeben Kompakte Kodierung für Kyrillisch @@ -311,9 +286,7 @@ Herunterfahren Herunterfahren wird auf diesem Gerät nicht unterstützt ⚠️ Dies wird den Knoten ausschalten. Eine physische Interaktion ist nötig, um ihn wieder einzuschalten. - ⚠️ Dies ist ein kritischer Infrastruktur-Knoten. Geben Sie den Knotennamen zur Bestätigung ein: Knoten: %1$s - Typ: %1$s Neustarten Traceroute Einführung zeigen @@ -325,9 +298,7 @@ Sofort senden Schnell-Chat Menü anzeigen Schnell-Chat-Menü ausblenden - Schnellchat anzeigen Auf Werkseinstellungen zurücksetzen - Bluetooth ist deaktiviert. Bitte aktivieren Sie es in Ihren Geräteeinstellungen. Einstellungen öffnen Firmware Version: %1$s Meshtastic benötigt die Berechtigung „Geräte in der Nähe“, um Geräte über Bluetooth zu finden und eine Verbindung zu ihnen herzustellen. Sie können die Funktion deaktivieren, wenn sie nicht verwendet wird. @@ -336,6 +307,7 @@ Zustellung bestätigt Ihr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden. Fehler + Unbekannter Fehler Ignorieren Aus Ignorierliste entfernen '%1$s' zur Ignorieren-Liste hinzufügen? @@ -370,14 +342,14 @@ Entfernen Dieser Knoten wird aus der Liste entfernt, bis dein Knoten wieder Daten von ihm erhält. Benachrichtigungen stummschalten - 1 Stunde 8 Stunden Eine Woche Immer Aktuell: Immer stumm Nicht stumm - Stummschalten + Stumm für %1$d Tage, %2$s Stunden + Stumm für %1$s Stunden Benachrichtigungen für '%1$s ' stumm schalten? Benachrichtigungen für '%1$s ' einschalten? Ersetzen @@ -387,9 +359,9 @@ Akku Kanalauslastung Sendezeit - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s Temperatur Feuchtigkeit @@ -397,7 +369,6 @@ Bodenfeuchte Protokolle Zwischenschritte entfernt - Entfernung: %1$d Knoten Information Auslastung für den aktuellen Kanal, einschließlich fehlerfreier TX, RX und fehlerhaftem RX (Rauschen). Prozentuale Sendezeit für die Übertragung innerhalb der letzten Stunde. @@ -411,14 +382,10 @@ Der öffentliche Schlüssel stimmt nicht mit dem gespeicherten Schlüssel überein. Sie können den Knoten entfernen und den Schlüsselaustausch erneut durchführen lassen. Dies könnte jedoch auf ein schwerwiegenderes Sicherheitsproblem hindeuten. Kontaktieren Sie den Benutzer über einen anderen vertrauenswürdigen Kanal, um zu klären, ob die Schlüsseländerung auf ein Zurücksetzen auf Werkseinstellungen oder eine andere absichtliche Handlung zurückzuführen ist. Benutzerinfo Benachrichtigung neue Knoten - Mehr Details 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 @@ -445,17 +412,28 @@ Dieses Traceroute hat noch keine zuordnungsfähigen Knoten. Zeige %1$d/%2$d Knoten Dauer: %1$s s - %1$s - %2$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 - 48H 1 Woche 2 Wochen - 4W 1 Monat Maximal + Minimum + Diagramm einblenden + Diagramm ausblenden Alter unbekannt Kopie Warnklingelzeichen! @@ -469,18 +447,22 @@ Kanal 1 Kanal 2 Kanal 3 + Kanal 4 + Kanal 5 + Kanal 6 + Kanal 7 + Kanal 8 Strom Spannung Sind Sie sicher? Dokumentation der Geräterollen und den dazugehörigen Blogeintrag über die Auswahl der richtigen Geräterolle gelesen.]]> Ich weiß was ich tue. + Knoten %1$s hat einen niedrigen Ladezustand (%2$d%) Benachrichtigung leerer Akku Leerer Akku: %1$s Akkustands Warnung (für Favoriten) Luftdruck Aktiviert - UDP Aussendung - UDP Konfiguration Zuletzt gehört:%2$s
Letzte Position:%3$s
Akku:%4$s]]>
Standort einschalten Ausrichtung Nord @@ -559,11 +541,9 @@ Statusübertragung (Sekunden) Glocke mit Warnmeldung senden Anzeigename - Freundliche Adresse Zu überwachender GPIO-Pin Typ der Erkennungsauslösung Eingang PULLUP Einstellung - Gerät Geräterolle GPIO Taste GPIO Summer @@ -603,6 +583,9 @@ Ausgabedauer (GPIO) Nervige Verzögerung (Sekunden) Klingelton + Importierter Klingelton + Datei ist leer + Fehler beim Importieren: %1$s Wiedergabe I2S als Buzzer verwenden LoRa @@ -613,7 +596,6 @@ Bandbreite Spreizfaktor Fehlerkorrektur - Frequenzversatz (MHz) Region Anzahl der Weiterleitungen Senden aktiviert @@ -627,6 +609,23 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen + Inaktiv + Verbindung getrennt + Verbindung getrennt - %1$s + Wird verbunden + Verbunden + Erneut verbinden + Erneut verbinden (Versuch %1$d) - %2$s + Verbindung testen + Broker prüfen. + Erreichbar. Broker akzeptierte Anmeldedaten. + Erreichbar (%1$s) + Broker abgelehnt: %1$s + Host nicht gefunden + Broker (TCP) nicht erreichbar + TLS Handshake fehlgeschlagen + Zeitüberschreitung nach %1$d ms + Verbindung fehlgeschlagen MQTT aktiviert Adresse Benutzername @@ -642,13 +641,11 @@ Nachbarinformationen aktiviert Aktualisierungsintervall (Sekunden) Übertragen über LoRa - Netzwerk WiFi Optionen Aktiviert WiFi aktiviert SSID PSK - Dokument abrufen Ethernet Einstellungen Ethernet aktiviert NTP Server @@ -657,6 +654,7 @@ IP Gateway Subnet + DNS Einstellung Besucherzähler Besucherzähler aktiviert Statusmeldung @@ -664,31 +662,18 @@ Die aktuelle Statuszeichenkette WiFi RSSI Schwellenwert (Standard -80) BLE RSSI Schwellenwert (Standard -80) - Standort - Standort Übertragungsintervall (Sekunden) - Intelligenter Standort aktiviert - Intelligenter Standort Minimum Distanz (Meter) - Intelligenter Standort Minimum Intervall (Sekunden) - Fester Standort verwenden Breitengrad Längengrad - Höhenmeter (Meter) Vom aktuellen Telefonstandort festlegen GPS-Chip (Hardware) Modus - GPS Aktualisierungsintervall (Sekunden) - GPS RX PIN neu definieren - GPS TX PIN neu definieren - GPS EN PIN neu definieren Standort Optionen Energie Einstellungen Energiesparmodus aktivieren Herunterfahren bei Stromausfall - Verzögerung zum Herunterfahren bei Akkubetrieb (Sekunden) ADC Multiplikationsfaktor ADC Multiplikator Überschreibungsverhältnis Zeit für Warten auf Bluetooth Dauer Supertiefschlaf - Dauer leichter Schlafmodus Minimale Aufwachzeit Akku INA_2XX I2C Adresse Einstellungen Reichweitentest @@ -699,7 +684,6 @@ Einstellung entfernte Hardware Erlaube undefinierten Pin-Zugriff Verfügbare Pins - Sicherheit Schlüssel für direkte Nachrichten Administrativer Schlüssel Öffentlicher Schlüssel @@ -713,6 +697,8 @@ Serielle Schnittstelle aktiviert Echo aktiviert Serielle Baudrate + Empfang + Senden Zeitlimit erreicht Serieller Modus Seriellen Anschluss der Konsole überschreiben @@ -747,8 +733,15 @@ Distanz Lux Wind + Windgeschwindigkeit + Windböen + Windstille + Windrichtung + Regen (1 Std.) + Regen (24 Std.) Gewicht Strahlung + 1-Wire Temperature Luftqualität im Innenbereich (IAQ) URL @@ -761,8 +754,6 @@ Benutzer ID Laufzeit Last %1$d - Abrufen von Kanal %1$d/%2$d - %1$s Abrufen Laufwerkspeicher frei %1$d Zeitstempel Überschrift @@ -780,7 +771,6 @@ Drücken und ziehen, um neu zu sortieren Stummschaltung aufheben Dynamisch - QR Code scannen Kontakt teilen Knoten Persönliche Notiz hinzufügen. @@ -793,13 +783,11 @@ Anfordern %1$s von %2$s Anfordern Benutzerinfo - Nachbarinfo (2.7.15+) Telemetrie anfordern Gerätedaten Umweltdaten Luftqualität Energiedaten - Lokale Statistik Host Kennzahlen Benutzerzählerdaten Metadaten @@ -810,7 +798,6 @@ Host Kennzahlen Host Freier Speicher - Freier Speicher Last Benutzerzeichenkette Navigieren zu @@ -853,8 +840,6 @@ (%1$d online / %2$d angezeigt / %3$d gesamt) Reagieren Verbindung trennen - Keine Netzwerkgeräte gefunden. - Keine seriellen USB Geräte gefunden. Zum Ende springen Meshtastic Sicherheitsstatus @@ -870,8 +855,6 @@ Knotendatenbank leeren Knoten älter als %1$d Tage entfernen Nur unbekannte Knoten entfernen - Knoten mit niedriger / ohne Aktivität entfernen - Ignorierte Knoten entfernen Jetzt leeren Dies wird %1$d Knoten aus Ihrer Datenbank entfernen. Diese Aktion kann nicht rückgängig gemacht werden. Ein grünes Schloss bedeutet, dass der Kanal sicher mit einem 128 oder 256 Bit AES-Schlüssel verschlüsselt ist. @@ -890,9 +873,6 @@ Alle Bedeutungen anzeigen Aktuellen Status anzeigen Tastatur ausblenden - Möchten Sie diesen Knoten wirklich löschen? - Verbindung löschen - Sind Sie sicher, dass Sie diese Verbindung löschen möchten? Antworten auf %1$s Antwort abbrechen Nachricht löschen? @@ -901,9 +881,15 @@ 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 - Gekoppelte Geräte Verbundene Geräte Sendebegrenzung überschritten. Bitte versuchen Sie es später erneut. Version ansehen @@ -931,7 +917,6 @@ Benachrichtigungen für neu entdeckte Knoten. Niedriger Akkustand Benachrichtigungen für niedrige Akku-Warnungen des angeschlossenen Gerätes. - Pakete, die als kritisch gesendet werden, ignorieren den Lautlos und Ruhemodus in den Benachrichtigungseinstellungen. Benachrichtigungseinstellungen Telefonstandort Meshtastic nutzt den Standort Ihres Telefons, um einige Funktionen zu aktivieren. Sie können Ihre Standortberechtigungen jederzeit in den Einstellungen aktualisieren. @@ -954,19 +939,15 @@ Kritische Warnungen konfigurieren Meshtastic nutzt Benachrichtigungen, um Sie über neue Nachrichten und andere wichtige Ereignisse auf dem Laufenden zu halten. Sie können Ihre Benachrichtigungsrechte jederzeit in den Einstellungen aktualisieren. Weiter - Berechtigungen erteilen %1$d Knoten in der Warteschlange zum Löschen: Achtung: Dies entfernt Knoten aus der App und Gerätedatenbank.\nDie Auswahl ist kumulativ. - Verbinde mit Gerät Normal Satellit Gelände Hybrid Kartenebenen verwalten Kartenebenen unterstützen kml, kmz oder GeoJSON Format. - Kartenebenen Keine Kartenebenen geladen. - Ebene hinzufügen Ebene ausblenden Ebene anzeigen Ebene entfernen @@ -1004,14 +985,12 @@ 48 Stunden Filtern nach letztem Empfang: %1$s %1$d dBm - Keine Anwendung zum Bearbeiten des Links verfügbar. Systemeinstellungen Keine Statistiken verfügbar Die Analysedaten helfen uns, die Android-App zu verbessern (Danke). Wir erhalten anonymisierte Informationen zum Nutzerverhalten. Dazu gehören Absturzberichte, in der App verwendete Bildschirme usw. Analyse Plattformen: Weitere Informationen finden Sie in unserer Datenschutzrichtlinie. Nicht gesetzt - 0 - Weitergeleitet von: %1$s Höre %1$d Relais Höre %1$d Relais @@ -1021,7 +1000,6 @@ Für RAK WisBlock RAK4631 verwenden Sie die serielle DFU Software des Herstellers (z. B. adafruit-nrfutil dfu serial mit der mitgelieferten Bootloader-ZIP-Datei). Das alleinige Kopieren der .uf2 Datei aktualisiert den Bootloader nicht. Für dieses Gerät nicht erneut anzeigen Favoriten beibehalten? - USB Geräte Firmware Aktualisierung Auf Aktualisierungen überprüfen... @@ -1037,16 +1015,12 @@ Aktualisierung erfolgreich! Fertig DFU wird gestartet... - Aktualisierung... %1$s DFU Modus wird aktiviert... Firmware wird überprüft... - Verbindung wird getrennt... Unbekanntes Hardware Modell: %1$d - Das verbundene Gerät ist kein gültiges BLE Gerät oder die Adresse ist unbekannt (%1$s). Kein Gerät verbunden Firmware für %1$s in der Release Version nicht gefunden. Extrahiere Firmware... - Trennen um DFU Dienst zu starten... Aktualisierung fehlgeschlagen Bitte warten, wir arbeiten daran... Halten Sie Ihr Gerät in die Nähe Ihres Telefons. @@ -1062,7 +1036,6 @@ Chirpy sagt: „Halten Sie Ihre Leiter griffbereit!“ Chirpy Neustart in DFU Modus... - Warte auf DFU Gerät... Bitte warten, Firmware wird kopiert. Bitte speichern Sie die .uf2-Datei auf DFU Laufwerk Ihres Gerätes. Gerät wird programmiert, bitte warten... @@ -1078,24 +1051,16 @@ Zielversion: %1$s Versionshinweise Unbekannter Fehler - Lokale Aktualisierung fehlgeschlagen - DFU Fehler: %1$s - DFU abgebrochen Benutzerinformationen des Knotens fehlen. + Akku zu niedrig (%1$d%). Bitte laden Sie Ihr Gerät vor der Aktualisierung. Konnte Firmware Datei nicht abrufen. - Nordic DFU Aktualisierung fehlgeschlagen USB Aktualisierung fehlgeschlagen Firmware-Hash abgelehnt. Das Gerät benötigt ggf. eine Hash Bereitstellung oder Bootloader Aktualisierung. OTA Aktualisierung fehlgeschlagen: %1$s - Firmware aktualisieren... Warte auf den Neustart des Geräts in den OTA Modus... Verbinde mit Gerät (Versuch %1$d/%2$d) - Geräteversion wird geprüft... OTA Update wird gestartet... Firmware aktualisieren... - Gerät neu starten... - Firmware Aktualisierung - Status Firmware Aktualisierung Wird gelöscht... Zurück Nicht konfiguriert @@ -1126,9 +1091,7 @@ Geschätzte Fläche: unbekannte Genauigkeit Als gelesen markieren Jetzt - Kanäle hinzufügen Die folgenden Kanäle wurden im QR-Code gefunden. Wählen Sie, welche Sie Ihrem Gerät hinzufügen möchten. Vorhandene Kanäle werden beibehalten. - Kanaleinstellungen für & ersetzen Dieser QR-Code enthält eine komplette Konfiguration. Hierdurch werden Ihre bestehenden Kanäle und Funkeinstellungen ersetzt. Alle vorhandenen Kanäle werden entfernt. Wird geladen @@ -1141,7 +1104,6 @@ Keine Filterwörter konfiguriert Regex Muster Übereinstimmung ganzes Wort - %1$d gefiltert %1$d gefilterte anzeigen %1$d gefilterte ausblenden Gefiltert @@ -1162,17 +1124,15 @@ Alle Bluetooth Bluetooth Berechtigungen konfigurieren - Mit Funkgerät verbinden - Suchen und verbinden Sie sich mit Ihrem Meshtastic Funkgerät. Entdecken Suchen und identifizieren Sie Meshtastic Geräte in Ihrer Nähe. Einstellungen Verwalten Sie drahtlos Ihre Geräteeinstellungen und Kanäle. - Berechtigung gewährt - Berechtigung verweigert Auswahl Kartenstil + Akku: %1$d% Knoten: %1$d online / %2$d gesamt Laufzeit: %1$s + Kanalauslastung: %1$s% | Sendezeit: %2$s% Datenverkehr: TX %1$d / RX %2$d (Duplikate: %3$d) Weiterleitungen: %1$d (Abgebrochen: %2$d) Diagnose %1$s @@ -1183,17 +1143,12 @@ %1$d / %2$d %1$s Angeschaltet - Meshtastic Statistiken Aktualisieren Aktualisiert Netzwerkebene hinzufügen - Ebene aktualisieren Lokale MB Kacheldatei Lokale MB Kacheldatei hinzufügen - Ungültiger Name, URL oder lokale URI für benutzerdefinierten Kachelanbieter. - Ein benutzerdefinierter Kachelanbieter mit diesem Namen existiert bereits. - Fehler beim Kopieren der MB Kacheldatei in den internen Speicher. TAK (ATAK) TAK Konfiguration Lokalen TAK Server aktivieren @@ -1240,17 +1195,7 @@ Lokale Telemetrie (Relais) Lokaler Standort (Relais) Router Sprungweite erhalten - Noch keine Nachrichten - %1$d ungelesen - Karten werden bald auf dem Desktop verfügbar sein. - Kein Gerät verbunden - Status aktualisieren - Bereit für Firmware Aktualisierung - Auf Aktualisierungen überprüfen - Firmware herunterladen - Gerät aktualisieren Anmerkung - Stellen Sie sicher, dass Ihr Gerät vollständig geladen ist, bevor Sie eine Firmware Aktualisierung starten. Trennen Sie das Gerät nicht während der Aktualisierung. Gerätespeicher & UI (schreibgeschützt) Design %1$s, Sprache %2$s Verfügbare Dateien (%1$d): @@ -1258,5 +1203,39 @@ Keine Dateien vorhanden. Verbindung herstellen Fertig - Aktualisieren + WLAN Unterstützung für mPWRD-OS + Stellen Sie Ihrem mPWRD-OS Gerät WLAN Zugangsdaten über Bluetooth zur Verfügung. + Erfahren Sie mehr über das mPWRD-OS Projekt\nhttps://github.com/mPWRD-OS + Gerät wird gesucht... + Gerät gefunden + Bereit zur Suche nach WLAN Netzwerken. + Suche nach Netzwerken + Suche... + WLAN Konfiguration wird angewendet... + Keine Netzwerke gefunden + Verbindung fehlgeschlagen: %1$s + Suche nach WLAN Netzwerken fehlgeschlagen: %1$s + %1$d% + Verfügbare Netzwerke + Netzwerkname (SSID) + 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 8f504e55b..8386ac2ea 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -27,31 +27,23 @@ Λήξη χρονικού ορίου Εσφαλμένο Αίτημα Άγνωστο Δημόσιο Κλειδί - Πελάτης Βάση Όνομα Καναλιού Κώδικας QR Άγνωστο Όνομα Χρήστη Αποστολή - Δεν έχετε κάνει ακόμη pair μια συσκευή συμβατή με Meshtastic με το τηλέφωνο. Παρακαλώ κάντε pair μια συσκευή και ορίστε το όνομα χρήστη.\n\nΗ εφαρμογή ανοιχτού κώδικα βρίσκεται σε alpha-testing, αν εντοπίσετε προβλήματα παρακαλώ δημοσιεύστε τα στο forum: https://github.com/orgs/meshtastic/discussions\n\nΠερισσότερες πληροφορίες στην ιστοσελίδα - www.meshtastic.org. Εσύ Αποδοχή Ακύρωση Αποθήκευση Λήψη URL νέου καναλιού - Αναφορά Σφάλματος - Αναφέρετε ένα σφάλμα - Είστε σίγουροι ότι θέλετε να αναφέρετε ένα σφαλμα? Μετά την αναφορά δημοσιεύστε στο https://github.com/orgs/meshtastic/discussions ώστε να συνδέσουμε την αναφορά με το συμβάν. Αναφορά - Η διαδικασία pairing ολοκληρώθηκε, εκκίνηση υπηρεσίας - Η διαδικασία ζευγοποιησης απέτυχε, παρακαλώ επιλέξτε πάλι Η πρόσβαση στην τοποθεσία είναι απενεργοποιημένη, δεν μπορεί να παρέχει θέση στο πλέγμα. Κοινοποίηση Αποσυνδεδεμένο Συσκευή σε ύπνωση IP διεύθυνση: Θύρα: - Συνδεδεμένο στο radio (%1$s) Αποσυνδεδεμένο Συνδεδεμένο στο radio, αλλά βρίσκεται σε ύπνωση Εφαρμογή πολύ παλαιά @@ -170,21 +162,17 @@ Πράσινο Μπλε Μηνύματα - Συσκευή LoRa Περιφέρεια + Αποσυνδεδεμένο Διεύθυνση Όνομα χρήστη Κωδικός πρόσβασης - Δίκτυο SSID PSK IP - Τοποθεσία Γεωγραφικό Πλάτος Γεωγραφικό Μήκος - Υψόμετρο (μέτρα) - Ασφάλεια Δημόσιο Κλειδί Ιδιωτικό Κλειδί Λήξη χρονικού ορίου @@ -213,4 +201,5 @@ Κόκκινο Μπλε Πράσινο + Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 3444ac366..4c59aa547 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -26,7 +26,6 @@ Ocultar nodos desconectados Mostrar sólo nodos directos Estás viendo nodos ignorados, Prensa para volver a la lista de nodos. - Mostrar detalles Ordenar por Opciones de orden de Nodos A-Z @@ -41,6 +40,7 @@ No reconocido Esperando ser reconocido En cola para enviar + Desconocido Reconocido Sin ruta Recibido un reconocimiento negativo @@ -57,38 +57,22 @@ Clave pública desconocida Mala clave de sesión Clave pública no autorizada - Cliente Aplicación conectada o dispositivo de mensajería autónomo. - Cliente silenciado El dispositivo no reenvía mensajes de otros dispositivos. - Base cliente - Router Nodo de infraestructura para ampliar la cobertura de la red mediante la retransmisión de mensajes. Visible en la lista de nodos. - Cliente de router Combinación de ROUTER y CLIENTE. No para dispositivos móviles. - Repetidor Un nodo que es parte de infraestructura para extender el rango de esta misma, reemitiendo mensajes de nodos con poco alcance. No aparecerá en la lista de nodos visibles. - Rastreador Transmisión de paquetes de posición GPS como prioridad. - Sensor Transmite paquetes de telemetría como prioridad. - TAK Optimizado para el sistema de comunicación ATAK, reduciendo las transmisiones rutinarias. - Cliente oculto Dispositivo que solo emite según sea necesario por sigilo o para ahorrar energía. - Perdido y encontrado Transmite regularmente la ubicación como mensaje al canal predeterminado para asistir en la recuperación del dispositivo. - Rastreador TAK Permite la transmisión automática TAK PLI y reduce las transmisiones rutinarias. Nodo de infraestructura que permite la retransmisión de paquetes una vez posterior a los demás modos, asegurando cobertura adicional a los grupos locales. Es visible en la lista de nodos. - Todos Si está en nuestro canal privado o desde otra red con los mismos parámetros lora, retransmite cualquier mensaje observado. Igual al comportamiento que TODOS pero omite la decodificación de paquetes y simplemente los retransmite. Sólo disponible en el rol repetidor. Establecer esto en cualquier otro rol dará como resultado TODOS los comportamientos. - Solo locales Ignora mensajes observados desde mallas foráneas que están abiertas o que no pueden descifrar. Solo retransmite mensajes en los nodos locales principales / canales secundarios. - Solo conocido Ignora los mensajes recibidos de redes externas como LOCAL ONLY, pero ignora también mensajes de nodos que no están ya en la lista de nodos conocidos. - Ninguna Solo permitido para los roles SENSOR, TRACKER y TAK_TRACKER, esto inhibirá todas las retransmisiones, no a diferencia del rol de CLIENT_MUTE. Ignora paquetes de puertos no estándar, tales como los TAK, Test de Rango (Rangetest), Contador de paquetes (Pax), etc. Solo retransmite paquetes que vengan de puertos estándar como: Información de Nodo (NodeInfo), Mensajes de texto, Posición, telemetría y Routing. Trate un doble toque en acelerómetros soportados como una pulsación de botón de usuario. @@ -138,7 +122,6 @@ Código QR Nombre de usuario desconocido Enviar - Aún no ha emparejado una radio compatible con Meshtastic con este teléfono. Empareje un dispositivo y configure su nombre de usuario. \n\nEsta aplicación de código abierto es una prueba alfa; si encuentra un problema publiquelo en el foro: https://github.com/orgs/meshtastic/discussions\n\nPara obtener más información visite nuestra página web - www.meshtastic.org. Usted Permitir analíticas y reporte de errores. Aceptar @@ -146,23 +129,15 @@ Descartar Guardar Nueva URL de canal recibida - Meshtastic necesita permisos de ubicación habilitados para encontrar nuevos dispositivos mediante Bluetooth. Puede desactivar cuando no esté en uso. - Informar de un fallo - Informar de un fallo - ¿Está seguro de que quiere informar de un error? Después de informar por favor publique en https://github.com/orgs/meshtastic/discussions para que podamos comparar el informe con lo que encontró. Informar - Emparejamiento completado, iniciando el servicio - El emparejamiento ha fallado, por favor seleccione de nuevo El acceso a la localización está desactivado, no se puede proporcionar la posición a la malla. Compartir Visto nuevo nodo: %1$s Desconectado Dispositivo en reposo - Conectado: %1$s Encendido Dirección IP: Puerto: Conectado - Conectado a la radio (%1$s) Conexiones actuales: IP Wifi: IP Ethernet: @@ -175,14 +150,11 @@ Notificaciones de servicio Agradecimientos La URL de este canal no es válida y no puede utilizarse - Este contacto no es válido y no se puede agregar Panel de depuración Payload decodificado: Exportar registros - Exportación cancelada %1$d bitácoras exportadas Fallo al escribir a archivo de bitácora: %1$s - No hay bitácoras para exportar %1$d hora %1$d horas @@ -200,7 +172,6 @@ Añadir filtro Filtro incluido Borrar todos los filtros - Solo mostrar nodos ignorados Limpiar los registros Coincidir con cualquier | Todo Coincidir todo | Cualquiera @@ -252,9 +223,7 @@ Apagar Apagado no compatible con este dispositivo ⚠️ Esto APAGARÁ el nodo. Se necesitará interacción física para volver a encenderlo. - ⚠️ Este es un nodo crítico de infraestructura. Escriba el nombre del nodo para confirmar: Nodo: %1$s - Tipo: %1$s Reiniciar Traceroute Mostrar Introducción @@ -266,9 +235,7 @@ Envía instantáneo Mostrar menú rápido de chat Ocultar menú rápido de chat - Mostrar chat rápido Restablecer los valores de fábrica - Bluetooth está deshabilitado. Por favor, actívalo en la configuración de tu dispositivo. Abrir ajustes Versión del firmware: %1$s Meshtastic necesita activar los permisos \"Dispositivos cercanos\" para encontrar y conectarse a dispositivos mediante Bluetooth. Puede desactivar cuando no esté en uso. @@ -310,7 +277,6 @@ Quitar Este nodo será retirado de tu lista hasta que tu nodo reciba datos de él otra vez. Silenciar notificaciones - 1 hora 8 horas 1 semana Siempre @@ -324,7 +290,6 @@ Batería Registros Saltos de distancia - Número de saltos: %1$d Información Utilización del canal actual, incluyendo TX, RX bien formado y RX mal formado (ruido similar). Porcentaje de tiempo de transmisión utilizado en la última hora. @@ -334,15 +299,11 @@ Los mensajes directos están utilizando la nueva infraestructura de clave pública para el cifrado. Clave pública no coincide Notificaciones de nuevo nodo - Más detalles 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 @@ -367,10 +328,8 @@ Rango de Valores 0 - 500.
Ver en el mapa Mostrando %1$d/%2$d nodos 24H - 48H 1Semana 2Semanas - 4Semanas Máximo Edad desconocida Copiar @@ -394,8 +353,6 @@ Rango de Valores 0 - 500.
Batería baja: %1$s Notificaciones de batería baja (nodos favoritos) Habilitado - Transmisión UDP - Configuración UDP Última escucha: %2$s
Última posición: %3$s
Batería: %4$s]]>
Cambiar mi posición Orientación norte @@ -472,7 +429,6 @@ Rango de Valores 0 - 500.
Pin GPIO para monitorizar Tipo de detección para activar Utilizar el modo de entrada PULL_UP - Dispositivo Rol del dispositivo Botón GPIO Zumbador GPIO @@ -520,7 +476,6 @@ Rango de Valores 0 - 500.
Ancho de Banda Factor de dispersión Tasa de codificación - Desplazamiento de la Frecuencia (MHz) Región Número de saltos Transmisión Activa @@ -534,6 +489,8 @@ Rango de Valores 0 - 500.
Ignorar Paquetes MQTT Permitir MQTT Configuración MQTT + Desconectado + Conectado Activar el MQTT Dirección del Servidor MQTT Usuario @@ -549,7 +506,6 @@ Rango de Valores 0 - 500.
Información de Vecinos Intervalo de refresco (segundos) Transmitir en LoRa - Conexión Red Opciones WiFi Habilitado WiFi del Nodo Activada @@ -562,35 +518,23 @@ Rango de Valores 0 - 500.
Modo IPv4 IP Puerta enlace + DNS Configuración del Contador de Paquetes Activar el Contador de Paquetes Umbral mínimo de RSSI de WiFi (por defecto es -80) Umbral mínimo de RSSI de BLE (por defecto es -80) - Posición - Periodo (en segundos) entre las Transmisiones de Posición - Posición Inteligente Activada - Transmisión de Posición Inteligente cuando Cambie (en metros) - Periodo Mínimo enter Transmisiones de Posiciones Inteligentes (en segundos) - Posición Fija Latitud Longitud - Altitud (en metros) Definir desde la ubicación actual del teléfono Modo GPS (dispositivo físico) - Periodo entre Actualizaciones de Posición del GPS (en Segundos) - Redefinir el Pin de RX de GPS - Redefinir el Pin de TX de GPS - Redefinir pin GPS_EN Marcas de posición Configuración de elecenergía Activar el modo ahorro de energía Apagar al perder energía - Retraso del apagado con batería (segundos) Sobreescribir multiplicador ADC Sobreescribir relación del multiplicador ADC Esperar Bluetooth durante Duración del sueño súper profundo - Duración de sueño ligero Dirección I2C del INA_2xx para la batería Configuración del test de alcance Test de alcance activado @@ -600,7 +544,6 @@ Rango de Valores 0 - 500.
Hardware remoto activado Permitir el acceso sin un pin definido Pines disponibles - Seguridad Claves para mensaje directo Claves administración Clave Pública @@ -676,7 +619,6 @@ Rango de Valores 0 - 500.
Pulsar y arrastrar para reordenar Desilenciar Dinámico - Escanear el código QR Compartir contacto Notas Añadir una nota privada… @@ -691,7 +633,6 @@ Rango de Valores 0 - 500.
Métricas de Entorno Métricas de Calidad del Aire Métricas de Energía - Estadísticas Locales Métricas del anfitrión Metadatos Acciones @@ -701,7 +642,6 @@ Rango de Valores 0 - 500.
Métricas del anfitrión Anfitrión Memoria disponible - Disco libre Carga Cadena del usuario Navegar hacia @@ -739,8 +679,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m (%1$d en línea / %2$d mostrado / %3$d total) Reaccionar Desconectar - No se encontraron dispositivos de red. - No se encontraron dispositivos Serial USB. Desplazarse hacia abajo Meshtastic Estado de seguridad @@ -754,8 +692,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Limpiar nodos de la base de datos Limpiar nodos vistos por última vez más de %1$d días Limpiar sólo nodos desconocidos - Limpiar nodos con baja/ninguna interacción - Limpiar nodos ignorados Limpiar ahora Esto eliminará los nodos %1$d de su base de datos. Esta acción no se puede deshacer. Un candado verde significa que el canal está cifrado de forma segura con una clave AES de 128 o 256 bits. @@ -771,15 +707,12 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Seguridad del canal Mostrar estado actual Descartar - ¿Sguro que desea eliminar este nodo? - Olvidar conexión Respondiendo a %1$s Cancelar respuesta ¿Eliminar mensajes? Limpiar selección Mensaje Escribe un mensaje - Dispositivos emparejados Dispositivo conectado Límite de tasa excedido. Por favor intente de nuevo más tarde. Descarga @@ -818,17 +751,13 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Configurar alertas críticas Meshtastic utiliza las notificaciones para mantenerte actualizado sobre nuevos mensajes y otros eventos importantes. Puedes actualizar tus permisos de notificación en cualquier momento desde la configuración. Siguiente - Otorgar permisos %1$d nodos en cola para borrar: Precaución: Esto elimina los nodos de las bases de datos en la aplicación y en el dispositivo.\nLas selecciones son aditivas. - Conectándose al dispositivo Normal Satélite Terreno Híbrido Administrar capas de mapa - Capas del mapa - Añadir capa Ocultar capa Mostrar capa Eliminar capa @@ -858,7 +787,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m 48 Horas Filtrar por tiempo de la última escucha: %1$s %1$d dBm - Ninguna aplicación disponible para manejar enlace. Ajustes del sistema No hay estadísticas disponibles Se recopilan analíticas de uso para ayudarnos a mejorar la aplicación Android (¡gracias!), recibiremos información anónima sobre el comportamiento del usuario. Esto incluye reportes de fallos, pantallas utilizadas en la aplicación, etc. @@ -866,7 +794,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Sin establecer - 0 Saber más ¿Conservar favoritos? - Dispositivos USB Actualización de firmware Buscando actualizaciones... @@ -878,8 +805,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m ¡Actualización exitosa! Hecho Iniciando DFU... - Actualizando... %1$s - Desconectando... Modelo de hardware desconocido: %1$d No hay dispositivos conectados Actualización fallida @@ -887,7 +812,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m No cierres la aplicación. Reiniciando en DFU... Transferencia de archivo USB - Actualización de firmware Sin configurar Siempre encendido @@ -909,7 +833,8 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Rojo Azul Verde - No hay dispositivos conectados 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 885812a6e..c2e327629 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -27,7 +27,6 @@ Peida ühenduseta Kuva ainult otseühendusega Sa vaatad eiratud sõlmi,\nVajuta tagasi minekuks sõlmede nimekirja. - Kuva üksikasjad Sorteeri Sõlmede filter A-Z @@ -46,6 +45,8 @@ Tundmatu Ootab kinnitamist Saatmise järjekorras + Kärgvõrku kohale jõudnud + Tundmatu Marsruutimine SF++ ahela kaudu… Kinnitatud SF++ ahel Kinnitatud @@ -65,43 +66,24 @@ Vigane sessiooni võti Avalik võti autoriseerimata PKI saatmine ebaõnnestus, avalikku võtit pole - Klient Rakendusega ühendatud või iseseisev sõnumsideseade. - Vaikne klient Seade, mis ei edasta pakette teistelt seadmetelt. - Klient-baas Käsitleb lemmiksõlmedest tulevaid või neile saadetud pakette kui RUUTER_HILINE ja kõiki teisi pakette kui KLIENT. - Ruuter Infrastruktuuri sõlm võrgu leviala laiendamiseks sõnumite edastamise kaudu. Nähtav sõlmede loendis. - Ruuteri klient Ruuteri ja Kliendi kombinatsioon. Ei ole mõeldud mobiilseadmetele. - Repiiter Infrastruktuuri sõlm võrgu leviala laiendamiseks, edastades sõnumeid minimaalse üldkuluga. Pole sõlmede loendis nähtav. - Jälgitav Esmajärjekorras edastatakse GPS asukoha pakette. - Andur Esmalt edastatakse telemeetria pakette. - TAK Optimeeritud ATAK süsteemi side jaoks, vähendab rutiinseid saateid. - Peidetud klient Seade, mis edastab ülekandeid ainult siis, kui see on vajalik varjamiseks või energia säästmiseks. - Kaotud ja leitud Edastab asukohta regulaarselt vaikekanalile sõnumina, et aidata seadme leidmisel. - Jälgitav TAK Võimaldab automaatseid TAK PLI saateid ja vähendab rutiinseid saateid. - Hiline ruuter Infrastruktuurisõlm, mis saadab pakette ainult ühe korra, ning alles peale kõiki teisi sõlmi, tagades kohalikele klastritele täiendava katvuse. Nähtav sõlmede loendis. - Kõik Saada uuesti mis tahes jälgitav sõnum, kui see oli privaatkanali või teisest samade LoRa parameetritega kärgvõrgus. - Vahelejäetud kõik dekodeerimine Sama käitumine nagu KÕIK puhul, aga pakete ei dekodeerita vaid need lihtsalt edastatakse. Saadaval ainult Repiiteri rollis. Teise rolli puhul toob kaasa KÕIK käitumise. - Ainult kohalik Ignoreerib jälgitavaid sõnumeid välis kärgvõrkudest avatud või dekrüpteerida mittevõimalikke sõnumeid. Saadab sõnumeid ainult kohalikel primaarsetel/sekundaarsetel kanalitel. - Ainult teadaolevad Ignoreerib sõnumeid välistest kärgvõrkudest, nt AINULT KOHALIK, saadud sõnumeid, kuid läheb sammu edasi, ignoreerides ka sõnumeid sõlmedelt, mis pole veel tuntud sõlme loendis. - Puudub Lubatud ainult rollidele SENSOR, TRACKER ja TAK_TRACKER, see blokeerib kõik kordus edastused, vastupidiselt CLIENT_MUTE rollile. - Ainult põhipordi numbriga Ignoreerib pakette mittestandardsetest pordinumbritest, näiteks: TAK, RangeTest, PaxCounter jne. Edastatakse ainult standardsete pordinumbritega pakette: NodeInfo, Tekst, Asukoht, Telemeetia ja Routimine. Topelt puudutust toetatud kiirendusmõõturitel käsitletakse kasutaja nupuvajutusena. Saada asukoht põhikanalil, kui klõpsatakse kasutaja nuppu kolm korda. @@ -168,7 +150,6 @@ QR kood Tundmatu kasutajanimi Saada - Ei ole veel ühendanud Meshtastic -kokku sobivat raadiot telefoniga. Seo seade selle telefoniga ja määra kasutajanimi.\n\nSee avatud lähtekoodiga programm on alpha-testi staatuses. Kui märkad vigu, saada palun sõnum meie foorumisse: https://github.com/orgs/meshtastic/discussions\n\nLisateave kodulehel - www.meshtastic.org. Sina Luba analüüsi ja krahhi aruandlus. Nõustu @@ -176,23 +157,15 @@ Tühista Salvesta Uued kanalid vastu võetud - Meshtastic vajab sinihambaga kaudu seadmete leidmiseks asukohalubasid. Saate need keelata, kui neid ei kasutata. - Teata veast - Teata veast - Kas soovid kindlasti veast teatada? Saada hiljem selgitus aadressile https://github.com/orgs/meshtastic/discussions, et saaksime selgitust leituga sobitada. Raport - Seade on seotud, taaskäivitan - Sidumine ebaõnnestus, vali palun uuesti Juurdepääs asukohale on välja lülitatud, ei saa asukohta teistele jagada. Jaga Uus sõlm nähtud: %1$s Ühendus katkenud Seade on unerežiimis - Ühendatud: %1$s aktiivset IP-aadress: Port: Ühendatud - Ühendatud raadioga (%1$s) Praegused ühendused: Wifi IP-aadress: Etherneti IP-aadress: @@ -214,14 +187,11 @@ Meshtastic on loodud avatud lähtekoodiga teekidest. Litsentsi vaatamiseks valige teek. %1$d teek Kanali URL on kehtetu ja seda ei saa kasutada - See kontakt on sobimatu ja seda ei saa lisada Arendaja paneel Dekodeeritud andmed: Salvesta logi - Eksport katkestatud %1$d logi eksporditud Ebaõnnestus kirjutada logi faili: %1$s - Eksporditavaid logisid pole %1$d tund %1$d tundi @@ -241,7 +211,6 @@ Puhasta kõik filtrid Lisa kohandatud filter Eelseadistatud filtrid - Näita ainult ignoreeritud sõlmi Salvesta võrgusõlme logiid Keela võrgusõlme logide kettale kirjutamise vahele jätmine Puhasta logid @@ -284,10 +253,15 @@ Taasta vaikesätted Rakenda Teema + Kontrastsus Hele Tume Süsteemi vaikesäte Vali teema + Kontrastsuse tase + Standard + Keskmine + Kõrge Jaga telefoni asukohta mesh-võrku Kompaktne kodeering kirillitsa jaoks @@ -312,9 +286,7 @@ Lülita välja Seade ei toeta väljalülitamist ⚠️ See LÜLITAB sõlme välja. Uuesti sisselülitamiseks on vaja füüsilist sekkumist. - ⚠️ See on kriitilise infrastruktuuri sõlm. Kinnitamiseks sisestage sõlme nimi: Sõlm: %1$s - Tüüp: %1$s Taaskäivita Marsruudi Näita tutvustust @@ -326,9 +298,7 @@ Saada kohe Kuva kiirsõnumite valik Peida kiirsõnumite valik - Näita kiirvestlust Tehasesätted - Sinihammas keelatud. Luba see sätetes. Ava seaded Püsivara versioon: %1$s Meshtastic vajab seadmete leidmiseks ja nendega sinihamba ​​kaudu ühenduse loomiseks „Lähi-seadmed” luba. Saate selle keelata, kui seda ei kasutata. @@ -337,6 +307,7 @@ Kohale toimetatud Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda. Viga + Tundmatu viga Eira Eemalda ignoreeritute hulgast Lisa '%1$s' eiramis loendisse? @@ -371,7 +342,6 @@ Eemalda Antud sõlm eemaldatakse loendist kuniks sinu sõlm võtab sellelt vastu uuesti andmeid. Vaigista teatised - 1 tund 8 tundi 1 nädal Alati @@ -380,7 +350,6 @@ Mitte vaigistatud Vaigistatud %1$d päeva, %2$s tundi Vaigistatud %1$s tundi - Vaigistatud olek Vaigista kasutaja '%1$s' teated? Tühistada '%1$s' teadete vaigistus? Asenda @@ -390,9 +359,9 @@ Aku Kanali kasutus Saate kasutus - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s Temperatuur Niiskus @@ -400,7 +369,6 @@ Pinnase niiskus Logi kirjet Hüppe kaugusel - %1$d hüppe kaugusel Informatsioon Praeguse kanali kasutamine, sealhulgas korrektne TX, RX ja vigane RX (ehk müra). Viimase tunni jooksul kasutatud eetriaja protsent. @@ -414,14 +382,10 @@ Avalikvõti ei ühti salvestatud võtmele. Võid sõlme eemaldada ja lasta uuesti võtmeid vahetada, kuid see võib viidata tõsisemale turvaprobleemile. Võtke kasutajaga ühendust mõne muu usaldusväärse kanali kaudu, et teha kindlaks, kas võtmevahetus oli tingitud tehaseseadete taastamisest või muust tahtlikust toimingust. Kasutaja teave Uue sõlme teade - Rohkem üksikasju 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 @@ -448,17 +412,28 @@ Sellel marsruudil pole veel ühtegi kaardil nähtavat sõlme. Kuvatakse %1$d/%2$d sõlme Kestus: %1$s' s - %1$s - %2$s Marsruut sihtkohta:\n\n Marsruut meieni tagasi:\n\n + Edasi hüpped + Tagasi hüpped + Edasi-tagasi + Vastust pole + Lae 1 min + Lae 5 min + Lae 15 min + Keskmine süsteemi koormus ühe minuti jooksul + Keskmine süsteemi koormus viie minuti jooksul + Keskmine süsteemi koormus viieteist minuti jooksul + Saadaolev süsteemimälu baitides 1t 24T - 48T 1N 2N - 4N 1k Maksimaalselt + Min + Laienda diagrammi + Ahenda diagrammi Tundmatu vanus Kopeeri Häirekella sümbol! @@ -472,6 +447,11 @@ Kanal 1 Kanal 2 Kanal 3 + Kanal 4 + Kanal 5 + Kanal 6 + Kanal 7 + Kanal 8 Pinge Vool Oled kindel? @@ -483,8 +463,6 @@ Madala akupinge teated (lemmik sõlmed) Õhurõhk Lubatud - UDP edastus - UDP sätted Viimati kuuldud: %2$s
viimane asukoht: %3$s
Akupinge: %4$s]]>
Lülita asukoht sisse Põhja suund @@ -563,11 +541,9 @@ Oleku edastus (sekund) Saada kõll koos hoiatussõnumiga Kasutajasõbralik nimi - Sõbralik aadress GPIO klemmi jälgimine Identifitseerimistüüp Kasuta INPUT_PULLUP režiimi - Seade Seadme roll Nupu GPIO Summeri GPIO @@ -607,6 +583,9 @@ Väljundi kestvus (millisekundit) Häire ajalõpp (sekundit) Helin + Imporditud helin + Fail on tühi + Viga importimisel: %1$s Mängi ette Kasuta I2S summerina LoRa @@ -617,7 +596,6 @@ Ribalaius Levitustegur Kodeerimiskiirus - Sagedusnihe (MHz) Regioon Hüpete arv Edastus lubatud @@ -631,6 +609,23 @@ Keela MQTT Ok MQTTi MQTT sätted + Mitteaktiivne + Ühendus katkenud + Ühendus katkenud — %1$s + Ühendan… + Ühendatud + Taas ühendan… + Ühendan uuesti (katse %1$d) — %2$s + Test ühendus + Kontrollin vahendajat… + Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave. + Kättesaadav (%1$s) + Vahendaja lükkas tagasi: %1$s + Hosti ei leitud + Vahendajaga ei saa ühendust (TCP) + TLS ühendus ebaõnnestus + Ajaline katkestus peale %1$d ms + Ühendus ebaõnnestus MQTT lubatud Aadress Kasutajatunnus @@ -646,13 +641,11 @@ Naabruskonna teave lubatud Uuenduste sagedus (sekundit) Saada LoRa kaudu - Võrk WiFi valikud Lubatud Wifi lubatud SSID PSK - Lae dokument Etherneti valikud Ethernet lubatud NTP server @@ -661,6 +654,7 @@ IP Lüüs Alamvõrk + DNS Paxcounter sätted Paxcounter lubatud Oleku teavitus @@ -668,31 +662,18 @@ Tegeliku oleku string WiFi RSSI lävi (vaikeväärtus -80) BLE RSSI lävi (vaikeväärtus -80) - Asukoht - Asukoha saatmise sagedus (sekundit) - Nutikas asukoht kasutuses - Nutika asukoha minimaalne muutus (meeter) - Nutika asukoha minimaalne aeg (sekundit) - Kasuta käsitsi määratud asukohta Laiuskraad Pikkuskraad - Kõrgus (meetrites) Kasuta telefoni hetkelist asukohta GPS-režiim (riistvara) - GPS värskendamise sagedus (sekundit) - Määra GPS_RX_PIN - Määra GPS_TX_PIN - Määra PIN_GPS_EN Asukoha lipp Toite sätted Luba energiasäästurežiim Väljalülitamine voolukatkestuse korral - Aku viivitus väljalükkamisel (sekundit) ADC kordaja tühistamine Asenda ADC kordistaja suhe Oota Bluetoothi ​​kestust Super sügava une kestus - Kerge une kestus Minimaalne ärkveloleku aeg Aku INA_2XX I2C aadress Ulatustesti sätted @@ -703,7 +684,6 @@ Kaug riistvara lubatud Luba määratlemata klemmi juurdepääs Saadaval klemmid - Turvalisus Otsesõnumi võti Admin võtmed Avalik võti @@ -717,6 +697,8 @@ Jadaport lubatud Kaja lubatud Jadapordi kiirus + RX + TX Aegunud Jadapordi režiim Konsooli jadapordi alistamine @@ -751,8 +733,15 @@ Kaugus Luksi Tuul + Tuule kiirus + Tuuleiil + Tuulevaikus + Tuule suund + Vihm (1h) + Vihm (24h) Kaal Radiatsioon + 1-juhtmeline temperatuur Siseõhu kvaliteet (IAQ) URL @@ -765,8 +754,6 @@ Kasutaja ID Töötamise aeg Lae %1$d - Kanali %1$d/%2$d laadimine - Laen %1$s Vaba kettamaht %1$d Ajatempel Päis @@ -784,7 +771,6 @@ Järjestamiseks vajuta ja lohista Eemalda vaigistus Dünaamiline - Skaneeri QR kood Jaga kontakti Sõnumid Lisa privaatsõnum… @@ -797,13 +783,11 @@ Taotlus %1$s taotlemine kasutajalt %2$s Kasutaja teave - Naabriinfo (2.7.15+) Taotle telemeetriat Seadme mõõdikud Keskkonnamõõdikud Õhukvaliteedi mõõdikud Võimsusnäitajad - Kohalik statistika Hosti mõõdik Pax mõõdiku küsimine Metaandmed @@ -814,7 +798,6 @@ Hosti mõõdik Host Vaba mälumaht - Vaba kettamaht Lae Kasutaja string Mine asukohta @@ -857,8 +840,6 @@ (%1$d võrgus / %2$d näidatud / %3$d kokku) Reageeri Katkesta ühendus - Võrguseadmeid ei leitud. - USB seadmeid ei leita. Mine lõppu Kärgvõrgustik Turvalisuse olek @@ -874,8 +855,6 @@ Tühjenda sõlmede andmebaas Eemalda sõlmed mida pole nähtud rohkem kui %1$d päeva Eemalda tundmatud sõlmed - Eemalda vähe aktiivsed sõlmed - Eemalda ignooritud sõlmed Eemalda nüüd See eemaldab %1$d seadet andmebaasist. Toimingut ei saa tagasi võtta. Roheline lukk näitab, et kanal on turvaliselt krüpteeritud kas 128 või 256 bittise AES võtmega. @@ -894,9 +873,6 @@ Näita kõik tähendused Näita hetke olukord Loobu - Kas olete kindel, et soovite selle sõlme kustutada? - Unusta ühendus - Kas oled kindel, et tahad selle ühenduse unustada? Vasta kasutajale %1$s Tühista vastus Kustuta sõnum? @@ -905,10 +881,15 @@ 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 - Seotud seadmed Ühendatud seadmed Limiit ületatud. Proovi hiljem uuesti. Näita versioon @@ -936,7 +917,6 @@ Uue avastatud sõlme märguanded. Madal akutase Ühendatud seadme madala akutaseme märguanded. - Kriitilisena saadetud paketid kuvatakse ka telefoni „Ära sega” reziimis. Määra märguannete load Telefoni asukoht Meshtastic kasutab teie telefoni asukohta mitmete funktsioonide lubamiseks. Saate oma asukohalubasid igal ajal seadetes muuta. @@ -959,19 +939,15 @@ Kriitiliste hoiatuste seadistamine Meshtastic kasutab märguandeid, et hoida teid kursis uute sõnumite ja muude oluliste sündmustega. Saate oma märguannete õigusi igal ajal seadetes muuta. Järgmine - Anna luba %1$d eemaldatavat sõlme nimekirjas: Hoiatus: See eemaldab sõlmed rakendusest, kui ka seadmest.\nValikud on lisaks eelnevale. - Ühendan seadet Normaalne Sateliit Maastik Hübriid Halda kaardikihte Kaardikihid toetavad .kml, .kmz või GeoJSON vorminguid. - Kaardikihid Kaardikihte pole laetud. - Lisa kiht Peida kiht Näita kiht Eemalda kiht @@ -1009,14 +985,12 @@ 48 tundi Filtreeri viimase kuulmise aja järgi: %1$s %1$d dBm - Lingi haldamiseks pole rakendust saadaval. Süsteemi sätted Statistikat pole saadaval Analüüsiandmeid kogutakse Androidi rakenduse täiustamiseks (tänan). Me saame anonüümset teavet kasutajate käitumise kohta. See hõlmab krahhiaruandeid, rakenduse ekraanipilte jms. Analüütikaplatvormid: Lisateabe saamiseks vaata privaatsuspoliitikat. Tühistatud - 0 - Vahendab: %1$s Kuuldud vahendaja %1$d Kuuldud %1$d vahendajat @@ -1026,7 +1000,6 @@ RAK WisBlock RAK4631 puhul kasuta tootja' seerianumbri DFU tööriista (näiteks adafruit-nrfutil dfu koos kaasasoleva alglaaduri jadapordi .zip-failiga). Ainult .uf2-faili kopeerimine ei värskenda alglaadurit. Ära selle ' seadme puhul enam kuva Säilita lemmikud? - USB seadmed Püsivara uuendus Otsin uuendusi... @@ -1042,16 +1015,12 @@ Värskendus õnnestus! Valmis DFU käivitamine... - Uuendan... %1$s DFU režiimi lubamine... Valideerin püsivara... - Ühenduse katkestamine... Tundmatu riistvaramudel: %1$d - Ühendatud seade ei ole kehtiv BLE seade või aadress on teadmata (%1$s). Ühtegi seadet pole ühendatud Selles versioonis ei leitud püsivara %1$s'le. Püsivara lahtipakkimine... - DFU teenuse käivitamiseks katkestamine ühenduse... Värskendus ebaõnnestus Pea vastu, me töötame selle kallal... Hoia seade telefoni lähedal. @@ -1067,7 +1036,6 @@ Chirpy ütleb: \"Hoia oma redel käepärast!\" Chirpy Taaskäivitamine DFU reziimi... - Ootan DFU seadet... Löö patsu! Oota veidi, püsivara laetakse... Palun salvesta .uf2-fail oma seadme' DFU kettale. Seadme värskendamine, palun oota... @@ -1083,26 +1051,16 @@ Sihtkoht: %1$s Väljalaske märkmed Tundmatu viga - Lokaalne värskendus nurjus - DFU viga: %1$s - DFU katkestatud Sõlmel puudub kasutajateave. Aku liiga tühi (%1$d%). Palun lae seade enne uuendamist. Püsivara faili ei õnnestunud hankida. - Nordic DFU värskendus nurjus USB-värskendus ebaõnnestus Püsivara räsi tagasi lükatud. Seade võib vajada räsi kontrollimist või alglaaduri värskendamist. Üle-õhu värskendus ebaõnnestus: %1$s - Laen püsivara... Ootan seadme taaskäivitumist üle-õhu režiimis... Seadmega ühenduse loomine (katse %1$d/%2$d)... - Seadme versiooni kontrollimine... Alustan üle-õhu värskendust... Laen püsivara... - Uuendan püsivara... %1$d% (%2$s) - Seadme taaskäivitamine... - Püsivara uuendus - Püsivara värskenduse olek Kustutamine... Tagasi Määramatta @@ -1133,9 +1091,7 @@ Hinnanguline piirkond: täpsus teadmata Märgi loetuks Praegu - Lisa kanaleid QR-koodist leiti järgmised kanalid. Vali millised soovid oma seadmesse lisada. Olemasolevad kanalid säilivad. - Kanali & sätete asendamine See QR-kood sisaldab täielikku konfiguratsiooni. See ASENDAB olemasolevad kanalid ja raadioseaded. Kõik olemasolevad kanalid eemaldatakse. Laen @@ -1148,7 +1104,6 @@ Filtrisõnu pole konfigureeritud Regulaaravaldise muster Terve sõna vaste - %1$d filtreeritud Näita %1$d filtreeritud Peida %1$d filtreeritud Filtreeritud @@ -1169,14 +1124,10 @@ Kõik Sinihammas Sinihamba ​​õiguste sätted - Ühenda raadioga - Otsi ja loo ühendus Meshtastic võrgusõlmega. Avastamine Leia ja tuvasta lähedal asuvad Meshtastic seadmed. Sätted Halda juhtmevabalt seadme sätteid ja kanaleid. - Luba antud - Luba mitte antud Kaardi stiilis valik Aku: %1$d% Sõlmed: %1$d võrgus / %2$d kokku @@ -1192,17 +1143,12 @@ %1$d / %2$d %1$s Toitega - Meshtasticu statistika Värskenda Uuendatud Lisa kaardikiht - Värskenda kihti Kohalik MB-paani fail Lisa kohalik MB-paani fail - Kohandatud paanipakkuja nimi, URL-i mall või kohalik URL on sobimatu. - Selle nimega kohandatud paanipakkuja on juba olemas. - MB-paanifaili kopeerimine sisemällu ebaõnnestus. TAK (ATAK) TAK-i sätted Kohaliku TAK-serveri lubamine @@ -1249,17 +1195,7 @@ Ainult kohalik telemeetria (vahendajad) Ainult kohalik asukoht (vahendajad) Säilita ruuteri hüpped - Sõnumeid veel ei ole - %1$d lugemata - Kaardi tugi lisandub peagi ka lauaarvutile - Ühtegi seadet pole ühendatud - Oleku värskendamine - Valmis püsivara värskendamiseks - Kontrolli värskendusi - Lae püsivara - Uuenda seade Märkus - Enne püsivara värskendamist veendu, et seade on täielikult laetud. Ära värskendamise ajal seadet lahti ühenda ega välja lülita. Seadme salvestusruum & UI (kirjutuskaitstud) Teema: %1$s, Keel: %2$s Saadaval failid (%1$d): @@ -1276,17 +1212,30 @@ Võrkude otsimine Otsin… WiFi sätete rakendamine… - WiFi edukalt seadistatud! - WiFi mandaadid rakendatud. Seade loob peagi võrguühenduse. Võrke ei leitud - Veenduge, et seade on sisse lülitatud ja levialas. Ühenduse loomine ebaõnnestus: %1$s WiFi võrkude leidmine ebaõnnestus %1$s - Värskenda %1$d% Saada olevad võrgud Võrgu nimi (SSID) 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 b17f0644b..f9da71dea 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -27,7 +27,6 @@ Piilota ei yhteydessä olevat laitteet Näytä vain suorat yhteydet Katselet tällä hetkellä huomioimattomia laitteita,\nPaina palataksesi laitelistaan. - Näytä lisätiedot Lajittele otsikon mukaan Lajitteluvaihtoehdot A-Ö @@ -46,6 +45,8 @@ Tuntematon Odottaa vahvistusta Jonossa lähetettäväksi + Toimitettu mesh-verkkoon + Tuntematon Reititetään SF++ ketjun kautta… Vahvistettu SF++-ketjussa Vahvistettu @@ -65,43 +66,24 @@ Virheellinen istuntoavain Julkinen avain ei ole valtuutettu PKI-lähetys epäonnistui, julkinen avain puuttu - Client Yhdistetty sovellukseen tai itsenäinen viestintälaite. - Client Mute Laite, joka ei välitä paketteja muilta laitteilta. - Client Base Suosikkiradioihin liittyvät paketit käsitellään ROUTER_LATE-tilassa, muut paketit CLIENT-tilassa. - Router Laite, joka laajentaa verkon infrastruktuuria viestejä välittämällä. Näkyy laitelistauksessa. - Router Client Yhdistelmä ROUTER sekä CLIENT roolista. Ei mobiililaitteille. - Repeater Laite, joka laajentaa verkon kattavuutta välittämällä viestejä verkkoa kuormittamatta. Ei näy laitelistauksessa. - Tracker Lähettää GPS-sijaintitiedot ensisijaisesti. - Sensor Lähettää telemetriatiedot ensisijaisesti. - TAK Optimoitu ATAK-järjestelmän viestintään, joka vähentää tavanomaisia lähetyksiä. - Client Hidden Laite, joka lähettää vain tarvittaessa tai virransäästotilassa. - Lost and Found Lähettää laitteen sijainnin viestillä oletuskanavalle sen löytämisen helpottamiseksi. - TAK Tracker Ottaa käyttöön automaattisen TAK PLI -lähetyksen vähentäen tavanomaisia lähetyksiä. - Router Late Muuten samanlainen kuin ROUTER rooli, mutta se uudelleen lähettää paketteja vasta kaikkien muiden tilojen jälkeen, varmistaen paremman peittoalueen muille laitteille. Laite näkyy mesh-verkon laiteluettelossa muille käyttäjille. - Kaikki Uudelleenlähettää kaikki havaitut viestit, jos ne ovat olleet omalla yksityisellä kanavalla tai toisessa mesh-verkosta, jossa on samat LoRa-parametrit. - Ohita kaikki dekoodaukset Käyttäytyy samalla tavalla kuin ALL, mutta jättää pakettien purkamisen väliin ja lähettää niitä vain uudelleen. Mahdollista käyttää vain Repeater-roolissa. Tämän asettaminen muille rooleille johtaa ALL-käyttäytymiseen. - Vain paikallinen Ei ota huomioon havaittuja viestejä ulkomaisista verkoista, jotka ovat avoimia tai joita se ei voi purkaa. Lähettää uudelleen viestin vain laitteen paikallisilla ensisijaisilla / toissijaisilla kanavilla. - Vain tunnetut Ei ota huomioon havaittuja viestejä ulkomaisista verkoista kuten LOCAL ONLY, mutta menee askeleen pidemmälle myös jättämällä huomiotta viestit laitteista, joita ei ole jo laitteen tuntemassa listassa. - ei mitään Sallittu vain SENSOR-, TRACKER- ja TAK_TRACKER -rooleille. Tämä estää kaikki uudelleenlähetykset, toisin kuin CLIENT_MUTE -roolissa. - Ainoastaan ytimen porttinumerot Ei ota huomioon paketteja, jotka tulevat ei-standardeista porttinumeroista, kuten: TAK, RangeTest, PaxCounter jne. Lähettää uudelleen vain paketteja, jotka käyttävät standardeja porttinumeroita: NodeInfo, Text, Position, Telemetry ja Routing. Käsittele tuetun kiihtyvyysanturin kaksoisnapautusta käyttäjäpainikkeella. Lähetä sijainti ensisijaisella kanavalla, kun käyttäjäpainiketta painetaan kolme kertaa. @@ -168,7 +150,6 @@ QR-koodi Tuntematon käyttäjänimi Lähetä - Et ole vielä yhdistänyt Meshtastic -yhteensopivaa radiota tähän puhelimeen. Muodosta laitepari puhelimen kanssa ja aseta käyttäjänimesi.\n\nTämä avoimen lähdekoodin sovellus on vielä kehitysvaiheessa. Jos löydät virheen, lähetä siitä viesti foorumillemme: https://github.com/orgs/meshtastic/discussions\n\nLisätietoja saat verkkosivuiltamme - www.meshtastic.org. Sinä Salli analytiikka ja virheraportit. Hyväksy @@ -176,23 +157,15 @@ Hylkää Tallenna Uusi kanavan URL-osoite vastaanotettu - Meshtastic tarvitsee sijaintioikeudet, jotta se voi löytää uusia laitteita Bluetoothin kautta. Voit poistaa oikeuden käytöstä, kun et käytä sovellusta. - Ilmoita virheestä - Ilmoita virheestä - Oletko varma, että haluat raportoida virheestä? Tee tämän jälkeen julkaisu https://github.com/orgs/meshtastic/discussions osoitteessa, jotta voimme yhdistää löytämäsi virheen raporttiin. Raportti - Laitepari on muodostettu, käynnistettään palvelua - Laiteparin muodostaminen epäonnistui, valitse uudelleen Sijainnin käyttöoikeus on poistettu käytöstä, joten emme voi tarjota sijaintia mesh-verkkoon. Jaa Uusi laite nähty: %1$s Ei yhdistetty Laite on lepotilassa - Yhdistetty: %1$s verkossa IP-osoite: Portti: Yhdistetty - Yhdistetty radioon (%1$s) Aktiiviset yhteydet: WiFi-verkon IP: Ethernet-verkon IP: @@ -214,14 +187,11 @@ Meshtastic on rakennettu seuraavilla avoimen lähdekoodin kirjastoilla. Napauta mitä tahansa kirjastoa nähdäksesi sen lisenssin. %1$d kirjastot Kanavan URL-osoite on virheellinen, eikä sitä voida käyttää - Tämä yhteystieto on virheellinen eikä sitä voi lisätä Vianetsintäpaneeli Dekoodattu data: Vie lokitiedot - Vienti peruutettu %1$d lokitietoa viety Lokitiedoston kirjoittaminen epäonnistui: %1$s - Ei lokitietoja vietäväksi %1$d tunti %1$d tuntia @@ -241,7 +211,6 @@ Tyhjennä kaikki suodattimet Lisää mukautettu suodatin Oletussuodattimet - Näytä vain huomioimattomat laitteet Tallenna mesh-verkon lokitiedot Poista käytöstä, jos et halua kirjoittaa mesh-lokitietoja levylle Tyhjennä lokitiedot @@ -284,10 +253,15 @@ Palauta oletusasetukset Hyväksy Teema + Kontrasti Vaalea Tumma Järjestelmän oletus Valitse teema + Kontrastin taso + Normaali + Keskitaso + Korkea Jaa puhelimen sijaintitietoa mesh-verkkoon Kyrillisten merkkien tiivis koodaus @@ -312,9 +286,7 @@ Sammuta Sammutusta ei tueta tällä laitteella ⚠️ Tämä SAMMUTTAA laitteen. Saat laitteen takaisin toimintaan kytkemällä virran päälle. - ⚠️ Tämä on kriittinen infrastruktuurilaite. Kirjoita laitteen nimi vahvistaaksesi: Laite: %1$s - Tyyppi: %1$s Käynnistä uudelleen Reitinselvitys Näytä esittely @@ -326,9 +298,7 @@ Lähetä välittömästi Näytä pikaviestivalikko Piilota pikaviestivalikko - Näytä pikaviesti Palauta tehdasasetukset - Bluetooth on pois käytöstä. Ota se käyttöön laitteen asetuksista. Avaa asetukset Firmwaren versio: %1$s Meshtastic tarvitsee \"lähistön laitteet\" -oikeudet, jotta se voi löytää ja yhdistää laitteisiin Bluetoothin kautta. Voit poistaa oikeuden käytöstä, kun et käytä sovellusta. @@ -337,6 +307,7 @@ Toimitus vahvistettu Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön. Virhe + Tuntematon virhe Jätä huomiotta Poista huomioimattomista Lisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen. @@ -371,7 +342,6 @@ Poista Tämä laite poistetaan luettelosta siihen saakka, kunnes sen tiedot vastaanotetaan uudelleen. Mykistä ilmoitukset - 1 tunti 8 tuntia 1 viikko Aina @@ -380,7 +350,6 @@ Ei mykistetty Mykistetty %1$d päiväksi, %2$s tunniksi Mykistetty %1$s tunniksi - Mykistä tilaviestit Mykistetäänkö ‘%1$s’ ilmoitukset? Poistetaanko ‘%1$s’ mykistys? Korvaa @@ -390,9 +359,9 @@ Akku Kanavan käyttöaste Lähetysajan käyttöaste - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s Lämpötila Kosteus @@ -400,7 +369,6 @@ Maaperän kosteus Lokitiedot Hyppyjä - Hyppyjä: %1$d Tiedot Nykyisen kanavan lähetyksen (TX) ja vastaanoton (RX) käyttöaste ja virheelliset lähetykset, eli häiriöt. Viimeisen tunnin aikana käytetyn lähetyksen prosenttiosuus. @@ -414,14 +382,10 @@ Julkinen avain ei vastaa tallennettua avainta. Voit poistaa laitteen ja antaa sen vaihtaa avaimet uudelleen, mutta tämä saattaa viitata vakavampaan tietoturvaongelmaan. Ota yhteyttä käyttäjään toista luotettua kanavaa pitkin selvittääksesi, johtuuko avaimen vaihtuminen tehdasasetusten palautuksesta tai muusta tarkoituksellisesta toimenpiteestä. Käyttäjätiedot Uuden laitteen ilmoitukset - Lisätietoja 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 @@ -448,17 +412,28 @@ Tässä traceroutessa ei ole vielä yhtään kartalle sijoitettavaa laitetta. Näytetään %1$d/%2$d laitetta Kesto: %1$s s - %1$s - %2$s Reitti jäljitetty kohti määränpäätä:\n\n Reitti jäljitetty takaisin tähän laitteeseen:\n\n + Välityshyppyjen määrä + Paluuhyppyjen määrä + Edestakainen reitti + Ei vastausta + Kuormitus (1 min) + Kuormitus (5 min) + Kuormitus (15 min) + Järjestelmän kuormituksen keskiarvo (1 min) + Järjestelmän kuormituksen keskiarvo (5 min) + Järjestelmän kuormituksen keskiarvo (15 min) + Käytettävissä oleva järjestelmämuisti tavuina 1 t 24t - 48t 1vko 2vko - 4vko 1 kk Kaikki + Minimi + Laajenna kaavio + Pienennä kaavio Tuntematon ikä Kopioi Hälytysääni! @@ -472,6 +447,11 @@ Kanava 1 Kanava 2 Kanava 3 + Kanava 4 + Kanava 5 + Kanava 6 + Kanava 7 + Kanava 8 Virta Jännite Oletko varma? @@ -483,8 +463,6 @@ Akun vähäisen varauksen ilmoitukset (suosikkilaitteet) Barometri Käytössä - UDP-lähetys - UDP asetukset Viimeksi kuultu: %2$s
Viimeisin sijainti: %3$s
Akku: %4$s]]>
Kytke sijainti päälle Aseta kompassi pohjoiseen @@ -563,11 +541,9 @@ Tilatiedon lähetys (sekuntia) Lähetä äänimerkki hälytyssanoman kanssa Käyttäjäystävällinen nimi - Helppolukuinen osoite GPIO-pinni valvontaa varten Tunnistuksen tyyppi Käytä INPUT_PULLUP tilaa - Laite Laitteen rooli Painikkeen GPIO-pinni Summerin GPIO-pinni @@ -607,6 +583,9 @@ Ulostulon kesto (millisekuntia) Hälytysaikakatkaisu (sekuntia) Soittoääni + Tuotu soittoääni + Tiedosto on tyhjä + Virhe tuotaessa: %1$s Aloita Käytä I2S protokollaa äänimerkille LoRa @@ -617,7 +596,6 @@ Kaistanleveys Levennyskerroin (Spread Factor) Koodausnopeus - Taajuuspoikkeama (MHz) Alue Hyppyjen määrä Lähetys käytössä @@ -631,6 +609,23 @@ Ohita MQTT MQTT päällä MQTT asetukset + Passiivinen + Ei yhdistetty + Yhteys katkaistu — %1$s + Yhdistetään… + Yhdistetty + Yhdistetään uudelleen… + Yhdistetään uudelleen (yritys %1$d) — %2$s + Testaa yhteys + Tarkistetaan välityspalvelinta… + Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot. + Yhteys onnistui (%1$s) + Välityspalvelin ei hyväksynyt: %1$s + Palvelinta ei löytynyt + Yhteyttä välityspalvelimeen ei saada (TCP) + TLS-yhteyden muodostus epäonnistui + Aikakatkaistu %1$d ms jälkeen + Yhdistäminen epäonnistui MQTT käytössä Osoite Käyttäjänimi @@ -646,13 +641,11 @@ Naapuritiedot käytössä Päivityksen aikaväli (sekuntia) Lähetä LoRa:n kautta - Verkko WiFi:n asetukset Käytössä WiFi käytössä SSID PSK - Hae asiakirja Verkon asetukset Ethernet käytössä NTP palvelin @@ -661,6 +654,7 @@ IP Yhdyskäytävä Aliverkko + DNS PAX-laskurin asetukset PAX-laskuri käytössä Tilaviesti @@ -668,31 +662,18 @@ Käytössä oleva tilaviesti WiFi-signaalin RSSI-kynnysarvo (oletus -80) BLE-signaalin RSSI-kynnysarvo (oletus -80) - Sijainti - Sijainnin lähetyksen väli (sekuntia) - Älykäs sijainti käytössä - Älykkään sijainnin etäisyys (metriä) - Älykkään sijainnin pienin päivitysväli (sekuntia) - Käytä kiinteää sijaintia Leveyspiiri Pituuspiiri - Korkeus (metriä) Aseta nykyisestä puhelimen sijainnista GPS-tila (fyysinen laitteisto) - GPS päivitysväli (sekuntia) - Määritä uudelleen GPS_RX_PIN - Uudelleenmääritä GPS_TX_PIN - Uudelleenmääritä PIN_GPS_EN Sijaintimerkinnät Virran asetukset Ota virransäästötila käyttöön Sammuta virran katketessa - Akun viivästetty sammutus (sekuntia) ADC-kertoimen ohitus Korvaava AD-muuntimen kerroin Bluetoothin odotusaika Super-syväunen kesto - Kevytunen kestoaika Vähimmäisherätyksen kesto INA_2XX-akun valvontapiirin I2C-osoite Kuuluvuustestin asetukset @@ -703,7 +684,6 @@ Etälaitteen ohjaus käytössä Salli määrittämättömän pinnin käyttö Käytettävissä olevat pinnit - Turvallisuus Suoran viestin avain Ylläpitäjän avaimet Julkinen avain @@ -717,6 +697,8 @@ Sarjaportti käytössä Palautus päällä Sarjaportin nopeus + RX + TX Aikakatkaisu Sarjaportin tila Korvaa konsolin sarjaportti @@ -751,8 +733,15 @@ Etäisyys Luksi Tuuli + Tuulen nopeus + Tuulen puuska + Alin tuulen nopeus + Tuulen suunta + Sademäärä (1 tunti) + Sademäärä (24 h) Paino Säteily + Lämpötila (1-Wire) Sisäilmanlaatu (IAQ) URL-osoite @@ -765,8 +754,6 @@ Käyttäjän ID Käyttöaika Lataa %1$d - Haetaan kanavaa %1$d/%2$d - Haetaan %1$s Vapaa levytila %1$d Aikaleima Suunta @@ -784,7 +771,6 @@ Paina ja raahaa järjestääksesi uudelleen Poista mykistys Dynaaminen - Skannaa QR-koodi Jaa yhteystieto Viestit Lisää yksityinen viesti… @@ -797,13 +783,11 @@ Pyyntö Pyydetään %1$s kohteelta %2$s Käyttäjätiedot - Naapuritieto (2.7.15+) Pyydä telemetriatiedot Laitteen mittausloki Ympäristöarvot Ilmanlaatuarvot Virranhallinnan arvot - Paikalliset tilastot Isäntälaitteen mittausarvot Pax mittarit Metatiedot @@ -814,7 +798,6 @@ Isäntälaitteen mittausarvot Isäntälaite Vapaa muisti - Vapaa levytila Lataa Käyttäjän syöte Siirry kohtaan @@ -857,8 +840,6 @@ (%1$d yhdistetty / %2$d nähty / %3$d yhteensä) Reagoi Katkaise yhteys - Verkkolaitteita ei löytynyt. - USB-sarjalaitteita ei löytynyt. Siirry loppuun Meshtastic Turvallisuustila @@ -874,8 +855,6 @@ Tyhjennä NodeDB-tietokanta Poista laitteet, joita ei ole nähty yli %1$d päivään Poista vain tuntemattomat laitteet - Poista laitteet, joilla on vähän tai ei yhtään yhteyksiä - Poista huomioimatta olevat laitteet Poista nyt Tämä poistaa %1$d laitetta tietokannasta. Toimintoa ei voi peruuttaa. Vihreä lukko tarkoittaa, että kanava on suojattu salauksella käyttäen joko 128- tai 256-bittistä AES-avainta. @@ -894,9 +873,6 @@ Näytä kaikki merkitykset Näytä nykyinen tila Hylkää - Oletko varma, että haluat poistaa tämän laitteen? - Älä muista tätä yhteyttä - Haluatko varmasti unohtaa tämän yhteyden? Vastataan käyttäjälle %1$s Peruuta vastaus Poistetaanko viestit? @@ -905,10 +881,15 @@ 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 - Paritetut laitteet Yhdistetty laite Käyttöraja ylitetty. Yritä myöhemmin uudelleen. Näytä versio @@ -936,7 +917,6 @@ Ilmoitukset uusista löydetyistä laitteista. Akku lähes tyhjä Ilmoitukset yhdistetyn laitteen vähäisestä akun varauksesta. - Kriittiset paketit toimitetaan ilmoituksina, vaikka puhelin olisi Älä häiritse -tilassa. Määritä ilmoitusten käyttöoikeudet Puhelimen sijainti Meshtastic hyödyntää puhelimen sijaintia tarjotakseen erilaisia toimintoja. Voit muuttaa sijaintioikeuksia koska tahansa asetuksista. @@ -959,19 +939,15 @@ Määritä kriittiset hälytykset Meshtastic käyttää ilmoituksia tiedottaakseen uusista viesteistä ja muista tärkeistä tapahtumista. Voit muuttaa ilmoitusasetuksia milloin tahansa. Seuraava - Myönnä oikeudet %1$d laitetta jonossa poistettavaksi: Varoitus: Tämä poistaa laitteet sovelluksen sekä laitteen tietokannoista.\nValinnat lisätään aiempiin. - Yhdistetään laitteeseen Normaali Satelliitti Maasto Hybridi Hallitse Karttatasoja Karttatasot tukevat .kml-, .kmz- tai GeoJSON-tiedostomuotoja. - Karttatasot Karttatasoja ei ole ladattu. - Lisää taso Piilota taso Näytä taso Poista taso @@ -1009,14 +985,12 @@ 48 tuntia Suodata viimeksi kuullun ajan mukaan: %1$s %1$d dBm - Ei sovellusta linkin avaamiseen. Järjestelmäasetukset Tilastoja ei ole saatavilla Analytiikkatietoja kerätään auttamaan meitä parantamaan Android-sovellusta (kiitos siitä). Saamme anonymisoitua tietoa käyttäjien toiminnasta, kuten kaatumisraportteja ja tietoa sovelluksessa käytetyistä näkymistä jne. Analytiikkapalvelut Lisätietoja saat tietosuojakäytännöstämme. Ei asetettu – 0 - Välittänyt: %1$s Kuultu %1$d radion kautta Kuultu %1$d radion kautta @@ -1026,7 +1000,6 @@ Käytä RAK WisBlock RAK4631 -moduulille valmistajan DFU-työkalua (esimerkiksi adafruit-nrfutil dfu serial -komentoa yhdessä annetun bootloaderin .zip-tiedoston kanssa). Pelkän .uf2-tiedoston kopioiminen ei päivitä bootloaderia. Älä näytä enää tälle laitteelle Säilytä suosikit? - USB-laitteet Laiteohjelmiston päivitys Tarkistetaan päivityksiä... @@ -1042,16 +1015,12 @@ Päivitys onnistui! Valmis Käynnistetään DFU... - Päivitetään… %1$s Otetaan DFU-tila käyttöön... Tarkistetaan laiteohjelmistoa... - Katkaistaan yhteyttä... Tuntematon laitemalli: %1$d - Yhdistetty laite ei ole kelvollinen BLE-laite tai osoite on tuntematon (%1$s). Ei laitetta kytkettynä Ei löytynyt firmwarea kohteelle %1$s julkaisusta. Puretaan laiteohjainta... - Katkaistaan yhteys DFU-palvelun käynnistämistä varten... Päivitys epäonnistui Odota, prosessi on käynnissä... Pidä laitteesi lähellä puhelinta. @@ -1067,7 +1036,6 @@ Chirpy sanoo: ”Pidä tikkaat valmiina – koskaan ei tiedä milloin tarvitset niitä! Chirpy Käynnistetään DFU-tilaan... - Odottaa DFU-laitetta... Ylävitonen! Odota, laiteohjelmistoa kopioidaan… Tallenna .uf2-tiedosto laitteesi DFU-asemaan. Ohjelmoidaan laitetta. Odota... @@ -1084,26 +1052,16 @@ Kohde: %1$s Julkaisutiedot Tuntematon virhe - Paikallinen päivitys epäonnistui - DFU virhe: %1$s - DFU-tila keskeytetty Laitteen käyttäjätiedot puuttuvat. Akun varaus liian alhainen (%1$d%). Lataa laite ennen päivitystä. Laiteohjelmistotiedostoa ei voitu noutaa. - Nordic DFU-laiteohjelmistopäivitys epäonnistui USB-päivitys epäonnistui Laiteohjelmiston tarkistussumma hylättiin. Laite saattaa vaatia tiivisteen alustamisen tai käynnistyslataimen päivityksen. OTA-päivitys epäonnistui: %1$s - Ladataan laiteohjelmistoa... Odotetaan, että laite käynnistyy uudelleen OTA-tilassa... Yhdistetään laitteeseen (yritys %1$d/%2$d)... - Tarkistetaan laitteen versiota... Käynnistetään OTA-päivitys... Lähetetään laiteohjelmistostoa... - Ladataan laiteohjelmistoa... %1$d% (%2$s) - Käynnistetään laitetta uudelleen... - Laiteohjelmiston päivitys - Laiteohjelmiston päivityksen tila Poistetaan... Edellinen Ei yhdistetty @@ -1134,9 +1092,7 @@ Arvioitu alue: tarkkuus tuntematon Merkitse luetuksi Nyt - Lisää kanavia QR-koodista löydettiin seuraavat kanavat. Valitse ne, jotka haluat lisätä laitteeseesi. Olemassa olevat kanavat säilytetään. - Korvaa kanavan & asetukset Tämä QR-koodi sisältää täydellisen määrityksen. Se KORVAA nykyiset kanava- ja radioasetuksesi. Kaikki olemassa olevat kanavat poistetaan. Ladataan @@ -1149,7 +1105,6 @@ Suodatussanoja ei ole asetettu Regex-sääntö Koko sanan täsmäys - %1$d suodatettu Näytä %1$d suodatettu Piilota %1$d suodatettu Suodatettu @@ -1170,14 +1125,10 @@ Kaikki Bluetooth Määritä Bluetooth-oikeudet - Yhdistä radioon - Etsi Meshtastic-laitteita ja yhdistä niihin. Haku Etsi ja tunnista lähelläsi olevia Meshtastic-laitteita. Asetukset Hallitse laitteesi asetuksia ja kanavia langattomasti. - Lupa myönnetty - Lupa evätty Karttatyylin valinta Akku: %1$d% Laitteet: %1$d verkossa / %2$d yhteensä @@ -1193,17 +1144,12 @@ %1$d / %2$d %1$s Powered - Meshtastic tilastot Päivitä Päivitetty Lisää verkkokarttataso - Päivitä karttataso Paikallinen MBTiles-karttatiedosto Lisää paikallinen MBTiles-karttatiedosto - Virheellinen nimi, URL-malli tai paikallinen URI mukautetulle karttalähteelle. - Mukautettu karttalähde tällä nimellä on jo olemassa. - MBTiles-tiedoston kopiointi sisäiseen tallennustilaan epäonnistui. TAK (ATAK) TAK-asetukset Ota paikallinen TAK-palvelin käyttöön @@ -1250,17 +1196,7 @@ Telemetria vain paikallisesti (välittäjät) Sijainti vain paikallisesti (välittäjät) Säilytä välittäjien hypyt - Ei vielä viestejä - %1$d lukematonta - Karttatuki on tulossa pian työpöytäversioon - Ei laitetta kytkettynä - Päivityksen Tila - Valmis laiteohjelmiston päivitykseen - Tarkista päivitykset - Lataa Laiteohjelmisto - Päivitä laite Merkintä - Varmista ennen firmware-päivityksen aloittamista, että laite on täysin ladattu. Älä irrota laitetta tai katkaise virtaa päivityksen aikana. Laitteen tallennustila & käyttöliittymä (vain luku) Teema: %1$s, Kieli: %2$s Saatavilla olevat tiedostot (%1$d): @@ -1277,17 +1213,30 @@ Etsi verkkoja Etsitään… Otetaan WiFi-asetukset käyttöön… - WiFi määritetty onnistuneesti! - WiFi-tunnukset otettu käyttöön. Laite yhdistyy verkkoon pian. Verkkoja ei löytynyt - Varmista, että laite on päällä ja kantaman sisällä. Yhteyden muodostaminen epäonnistui: %1$s WiFi-verkkojen haku epäonnistui: %1$s - Päivitä %1$d% Saatavilla olevat verkot Verkon nimi (SSID) 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 d1d993f90..f4afeef5c 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s Filtre Effacer le filtre de nœud Filtrer par @@ -26,7 +27,6 @@ Masquer les nœuds hors ligne Afficher uniquement les nœuds directs Vous visualisez les nœuds ignorés,\nAppuyez pour retourner à la liste des nœuds. - Afficher les détails Trier Options de tri des nœuds A-Z @@ -41,9 +41,12 @@ 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++ Entendu par un autre nœud (mais dans le cas d'un message direct, nous n'avons pas reçu la confirmation de réception par le destinataire : soit il n'a pas reçu le message, soit sa confirmation ne nous est pas parvenue) @@ -63,43 +66,24 @@ Mauvaise clé de session Clé publique non autorisée Échec de l'envoi de clé privée, pas de clé publique - Client Dispositif de messagerie autonome ou connecté à l'application. - Client muet Appareil ne transmettant pas les paquets provenant d'autres appareils. - Base Client Traite les paquets depuis ou vers les nœuds favoris comme Routeur avec retard (ROUTER_LATE), et tous les autres paquets comme CLIENT. - Routeur Nœud d'infrastructure pour étendre la couverture réseau en relayant les messages. Visible dans la liste des nœuds. - Routeur Client Combinaison à la fois du ROUTER et du CLIENT. Pas pour les appareils mobiles. - Répéteur Nœud d'infrastructure pour étendre la couverture réseau en relayant les messages avec une surcharge minimale. Non visible dans la liste des nœuds. - Traqueur Transmet les paquets de positions GPS en priorité. - Capteur Transmet les paquets de télémétrie en priorité. - TAK Optimisé pour le système de communication ATAK, diminue les émissions de routine. - Client masqué Appareil ne diffusant que si nécessaire pour la discrétion et l'économie d'énergie. - Objets trouvés Transmet régulièrement la position par message dans le canal par défaut pour vous aider à retrouver l'appareil. - Traqueur TAK Active les diffusions automatiques de TAK PLI et réduit les diffusions de routine. - Routeur avec retard Nœud d'infrastructure qui retransmet toujours les paquets une fois mais seulement après tous les autres modes, assurant une couverture supplémentaire pour les clusters locaux. Visible dans la liste des nœuds. - Tout Rediffuser tout message observé, s'il était sur notre canal privé ou à partir d'un autre maillage avec les mêmes paramètres LoRa. - Tout, saute le décodage Identique au comportement de TOUS mais ignore le décodage des paquets et les rediffuse simplement. Uniquement disponible pour le rôle Répéteur. Définir cela sur tout autre rôle entraînera le comportement de TOUS. - Local uniquement Ignore les messages observés à partir de maillages étrangers qui sont ouverts ou ceux qu'il ne peut pas déchiffrer. Ne diffuse que le message sur les nœuds des canaux primaires / secondaires. - Connus seulement Ignore les messages observés depuis des maillages distants comme LOCAL SEULEMENT, mais va plus loin en ignorant également les messages des nœuds qui ne sont pas déjà dans la liste connue du nœud. - Aucun Seulement autorisé pour les rôles SENSOR, TRACKER et TAK_TRACKER, cela empêchera toutes les rediffusions, contrairement au rôle CLIENT_MUTE. - Seulement les ports noyau Ignore les paquets de portnums non standards tels que : TAK, RangeTest, PaxCounter, etc. Retransmet seulement les paquets avec des portnums standard : NodeInfo, Text, Position, Télémétrie et Routing. Traiter un double appui sur les accéléromètres compatibles comme une pression de bouton utilisateur. Envoyer une position sur le canal principal lorsque le bouton utilisateur est triple-cliqué. @@ -138,7 +122,8 @@ Distance minimale en mètres pour considérer une diffusion de position intelligente. À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé). Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte. - Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée. Utilisée pour créer une clé partagée avec un appareil distant. Clé publique autorisée à envoyer des messages d’administration à ce nœud. L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil. @@ -165,7 +150,6 @@ Code QR Nom d'Utilisateur inconnu Envoyer - Aucune radio Meshtastic compatible n'a été jumelée à ce téléphone. Jumelez un appareil et spécifiez votre nom d'utilisateur.\n\nL'application open-source est en test alpha, si vous rencontrez des problèmes postez au chat sur notre site web.\n\nPour plus d'information visitez notre site web - www.meshtastic.org. Vous Autoriser les statistiques et les rapports de plantage. Accepter @@ -173,45 +157,41 @@ Ignorer Sauvegarder Réception de l'URL d'un nouveau cana - Meshtastic a besoin d'autorisations de localisation activées pour trouver de nouveaux appareils via Bluetooth. Vous pouvez désactiver lorsque la localisation n'est pas utilisée. - Rapporter Bogue - Rapporter un Bogue - Êtes-vous certain de vouloir rapporter un bogue ? Après l'envoi, veuillez poster dans https://github.com/orgs/meshtastic/discussions afin que nous puissions examiner ce que vous avez trouvé. Rapport - Jumelage terminé, démarrage du service - Le jumelage a échoué, veuillez sélectionner à nouveau L'accès à la localisation est désactivé, impossible de fournir la position du maillage. Partager Nouveau nœud vu : %1$s Déconnecté Appareil en veille - Connectés : %1$s sur en ligne Adresse IP: Port : Connecté - Connecté à la radio (%1$s) Connexions actuelles : - IP WiFi : + IP du Wifi : IP Ethernet : Connexion en cours Non connecté Aucun appareil sélectionné Périphérique inconnu + Aucun périphérique réseau trouvé + Pas de périphérique USB trouvé + USB + Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet. Aucun (désactivé) Notifications de service Remerciements + Bibliothèques Open Source + Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence. + %1$d Bibliothèques Cette URL de canal est invalide et ne peut pas être utilisée - Ce contact est invalide et ne peut pas être ajouté Panneau de débogage Contenu décodé : Exporter les logs - Exportation annulée Journaux %1$d exportés Impossible d'écrire le fichier journal : %1$s - Aucun journal à exporter %1$d heure %1$d heures @@ -231,7 +211,6 @@ Supprimer tous les filtres Ajouter un filtre personnalisé Filtres prédéfinis - Afficher uniquement les nœuds ignorés Stocker les journaux de maillage Désactiver pour passer l'écriture des journaux de maillage sur le disque Effacer le journal @@ -239,7 +218,21 @@ Correspondre à tout | N'importe quel Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent. Effacer + Rechercher des émojis... + Plus d'actions Canal + %1$s: %2$s + Message de %1$s: %2$s + Entête + Élément %1$d + Pied de page + Exporter le paquet de données TAK + Point + Texte + Jauge + Dégradé + Ceci est un composable personnalisé + Avec plusieurs lignes et styles Statut d'envoi du message Nouveaux messages au-dessous Notifications de message @@ -260,10 +253,15 @@ Rétablir les valeurs par défaut Appliquer Thème + Contraste Clair Sombre Valeur par défaut du système Choisir un thème + Niveau de contraste + Standard + Milieu + Haut Fournir l'emplacement au maillage Encodage compact pour Cyrillique @@ -288,9 +286,7 @@ Éteindre Arrêt non pris en charge sur cet appareil ⚠️ Vous allez ETEINDRE le nœud. Une interaction physique sera requise pour le rallumer. - ⚠️ Il s'agit d'un nœud infrastructure important. Tapez le nom du nœud pour valider son extinction : Nœud : %1$s - Type : %1$s Redémarrer Traceroute Afficher l'introduction @@ -302,16 +298,16 @@ Envoi instantané Afficher le menu de discussion rapide Masquer le menu de discussion rapide - Afficher la discussion rapide Réinitialisation d'usine - Le Bluetooth est désactivé. Veuillez l'activer dans les paramètres de votre appareil. Ouvrir les paramètres Version du firmware : %1$s Meshtastic a besoin des autorisations \"Périphériques à proximité\" activées pour trouver et se connecter à des appareils via Bluetooth. Vous pouvez désactiver la lorsque la localisation n'est pas utilisée. 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. @@ -346,14 +342,14 @@ Supprimer Ce nœud sera supprimé de votre liste jusqu'à ce que votre nœud reçoive à nouveau des données. Désactiver les notifications - 1 heure 8 heures 1 semaine Toujours Actuellement : Toujours muet Non muet - Statut 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 @@ -363,13 +359,16 @@ Batterie UtilCanal UtilAir + %1$s / %2$s%% + %1$s: %2$s V + %1$s + %1$s: %2$s Temp Hum Temp sol Hum sol Journaux Sauts - Sauts : %1$d Information Utilisation pour le canal actuel, y compris TX bien formé, RX et RX mal formé (AKA bruit). Pourcentage de temps d'antenne pour la transmission utilisée au cours de la dernière heure. @@ -383,14 +382,10 @@ La clé publique ne correspond pas à la clé enregistrée. Vous pouvez supprimer le nœud et le laisser à nouveau échanger les clés, mais cela peut indiquer un problème de sécurité plus grave. Contactez l'utilisateur à travers un autre canal de confiance, pour déterminer si le changement de clé est dû à une réinitialisation d'usine ou à une autre action intentionnelle. Infos utilisateur Notifikasyon nouvo nœud - Plus de détails 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 @@ -417,17 +412,28 @@ Ce traceroute n'a pas encore de nœuds cartographiables. Affichage des nœuds %1$d/%2$d Durée : %1$s s - %1$s - %2$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 - 48H 1S 2S - 4S 1M Max + Min + Agrandir le graphique + Réduire le graphique Age inconnu Copier Caractère d'appel ! @@ -441,18 +447,22 @@ 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) Baro Activé - Diffusion UDP - Configuration UDP Dernière écoute : %2$s
Dernière position : %3$s
Batterie : %4$s]]>
Basculer ma position Orienter vers le nord @@ -531,11 +541,9 @@ Diffusion de l'État (secondes) Envoyer une sonnerie avec un message d'alerte Nom convivial - Adresse conviviale Broche GPIO à surveiller Type du déclencheur de détection Utiliser le mode INPUT_PULLUP - Appareil Rôle de l'appareil GPIO du bouton GPIO du buzzer @@ -575,6 +583,9 @@ Durée de sortie (en millisecondes) Durée de répétition de la sortie (secondes) Sonnerie + Sonnerie importée + Le fichier est vide + Erreur d'importation : %1$s Lancer Utiliser l'I2S comme buzzer LoRa @@ -585,7 +596,6 @@ Bande Passante Facteur de propagation Taux de codage - Décalage de fréquence (MHz) Région Nombre de sauts Transmission activée @@ -599,6 +609,13 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Inactif + Déconnecté + Connexion… + Connecté + Reconnexion… + Test de la connexion + Échec de la connexion MQTT activé Adresse Nom d'utilisateur @@ -614,13 +631,11 @@ Infos de voisinage activées Intervalle de mise à jour (secondes) Transmettre par LoRa - Réseau Options WiFi Activé WiFi activé SSID PSK (clé) - Obtenir le document Options Ethernet Ethernet activé Serveur NTP @@ -629,6 +644,7 @@ IP Passerelle Subred + DNS Configuration du Paxcounter Paxcounter activé Statut du message @@ -636,31 +652,18 @@ La chaîne de statut actuelle Seuil RSSI WiFi (par défaut -80) Seuil BLE RSSI (par défaut -80) - Position - Intervalle de diffusion de la position (secondes) - Position intelligente activée - Distance minimale de diffusion intelligente (mètres) - Intervalle minimum de diffusion intelligente (secondes) - Utiliser une position fixe Latitude Longitude - Altitude (mètres) Définir à partir de l'emplacement actuel du téléphone Mode GPS (matériel physique) - Intervalle de mise-à-jour GPS (secondes) - Redéfinir GPS_RX_PIN - Redéfinir GPS_TX_PIN - Redéfinir le code PIN_GPS_EN Champs de position Configuration de l'alimentation Activer le mode économie d'énergie Arrêt en cas de perte d'alimentation - Délai d’extinction sur batterie (secondes) Remplacer le multiplicateur ADC Facteur de remplacement du multiplicateur ADC Durée d'attente max du Bluetooth Durée du sommeil extra profond - Durée du sommeil léger Durée minimale de réveil Adresse I2C de la batterie INA_2XX Configuration des tests de portée @@ -671,7 +674,6 @@ Matériel distant activé Autoriser l'accès non défini aux broches Broches disponibles - Sécurité Clé de message direct Clés admin Clé publique @@ -685,6 +687,8 @@ Série activée Écho activé Vitesse de transmission série + RX + Tx Délai d'expiration Mode série Outrepasser le port série de la console @@ -719,8 +723,15 @@ Distance Lux Vent + Vitesse du vent + Rafales de vent + Vent à la traîne + Direction du vent + Pluie (1h) + Pluie (24h) Poids Radiation + Températeur 1-Wire Qualité de l'air intérieur (IAQ) URL @@ -733,12 +744,11 @@ ID utilisateur Durée de fonctionnement Charge %1$d - Récupération du canal %1$d/%2$d - Récupération de %1$s Disque libre %1$d Horodatage En-tête Vitesse + %1$d Km/h Sats Alt Fréq @@ -751,7 +761,6 @@ Appuyez et faites glisser pour réorganiser Désactiver Muet Dynamique - Scanner le code QR Partager le contact Notes Ajouter une note privée… @@ -764,13 +773,11 @@ Demander : Requête %1$s de %2$s Infos utilisateur - Informations de voisinage (2.7.15+) Demander la télémétrie Métriques de l’appareil Métriques d'environnement Métriques de qualité de l'air Métriques d'alimentation - Statistiques locales Métriques de l’hôte Métriques de Pax Métadonnées @@ -781,7 +788,6 @@ Métriques de l’hôte Hôte Mémoire libre - Espace disque libre Charge Texte utilisateur Naviguer vers @@ -808,6 +814,11 @@ Afficher les points de repère Afficher les cercles de précision Notification client + Vérification de la clé + Requête de vérification de clé + Vérification de la clé terminée + Clé publique dupliquée détectée + Clé de chiffrement faible détectée Clés compromises détectées, sélectionnez OK pour régénérer. Régénérer la clé privée Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée. @@ -819,8 +830,6 @@ (%1$d en ligne / %2$d affichés / %3$d total) Réagir Déconnecter - Aucun périphérique réseau trouvé. - Aucun périphérique série USB détecté. Défiler vers le bas Meshtastic Statut de sécurité @@ -836,8 +845,6 @@ Nettoyer la base de données des nœuds Nettoyer les nœuds vus pour la dernière fois depuis %1$d jours Nettoyer uniquement les nœuds inconnus - Nettoyer les nœuds avec une interaction faible/sans interaction - Nettoyer les nœuds ignorés Nettoyer maintenant Cela supprimera les %1$d nœuds de votre base de données. Cette action ne peut pas être annulée. Un cadenas vert signifie que le canal est chiffré de façon sécurisée avec une clé AES 128 ou 256 bits. @@ -856,9 +863,6 @@ Afficher toutes les significations Afficher l'état actuel Annuler - Êtes-vous sûr de vouloir supprimer ce nœud ? - Oublier la connexion - Êtes-vous sûr de vouloir oublier cette connexion ? Répondre à %1$s Annuler la réponse Supprimer les messages ? @@ -867,9 +871,15 @@ 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ériques appairés Périphérique connecté Limite de débit dépassée. Veuillez réessayer plus tard. Voir la version @@ -897,7 +907,6 @@ Notifications pour les nouveaux nœuds découverts. Batterie faible Notifications d'alertes de batterie faible pour l'appareil connecté. - Sélectionnez les paquets envoyés comme critiques ignorera le commutateur muet et les paramètres Ne pas déranger dans le centre de notification du système d'exploitation. Configurer les autorisations de notification Localisation du téléphone Meshtastic utilise la localisation de votre téléphone pour activer un certain nombre de fonctionnalités. Vous pouvez mettre à jour vos autorisations de localisation à tout moment à partir des paramètres. @@ -917,17 +926,15 @@ Configurer les alertes critiques Meshtastic utilise les notifications pour vous tenir à jour sur les nouveaux messages et autres événements importants. Vous pouvez mettre à jour vos autorisations de notification à tout moment à partir des paramètres. Suivant - Accorder les autorisations %1$d nœuds en attente de suppression : Attention : Ceci supprime les nœuds des bases de données de l'application et sur le nœud.\nLes sélections sont additionnelles. - Connexion à l'appareil Normal Satellite Terrain Hybride Gérer les calques de la carte - Couches cartographiques - Ajouter un calque + 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 @@ -935,6 +942,10 @@ Nœuds à cet emplacement Type de carte sélectionné Gérer les sources de tuiles personnalisées + Ajouter un réseau de tuile personnalisée + Aucune source de tuiles personnalisées trouvée. + Modifier le réseau de tuile personnalisée + Supprimer le réseau de tuile personnalisée Le nom ne peut pas être vide. Le nom du fournisseur existe déjà. URL ne peut être vide. @@ -961,14 +972,12 @@ 48 Heures Filtrer par la dernière écoute : %1$s %1$d dBm - Aucune application disponible pour gérer ce lien. Paramètres système Pas de stats disponibles Les statistiques sont collectées pour nous aider à améliorer l'application Android (merci), nous recevrons des informations anonymes sur le comportement de l'utilisateur. Cela inclut les rapports de plantage, les écrans utilisés dans l'application, etc. Plateformes d'analyse : Pour plus d'informations, consultez notre politique de confidentialité. Non défini - 0 - Relayé par : %1$s Entendu par %1$d relai Entendu par %1$d relais @@ -978,7 +987,6 @@ Pour le RAK WisBlock RAK4631, utilisez l'outil DFU série du fournisseur (par exemple, adafruit-nrfutil dfu serial avec le fichier .zip du bootloader fourni). La copie du fichier .uf2 seul ne permettra pas de mettre à jour le bootloader. Ne plus afficher pour cet appareil Conserver les favoris ? - Appareils USB Mise à jour du firmware Vérification des mises à jour... @@ -994,16 +1002,12 @@ Mise à jour réussie ! Terminé Démarrage du mode DFU... - Mise à jour... %1$s Activation du mode DFU... Validation du firmware... - Déconnexion... Modèle de matériel inconnu : %1$d - Le périphérique connecté n'est pas un périphérique BLE valide ou l'adresse est inconnue (%1$s). Aucun appareil connecté Impossible de trouver le firmware pour %1$s dans cette version. Extraction du firmware... - Déconnexion pour démarrer le service DFU... Échec de la mise à jour Accrochez-vous, nous travaillons dessus... Conservez votre appareil près de votre smartphone. @@ -1019,7 +1023,6 @@ Gardez votre échelle à portée de main ! Chirpy Redémarrage en mode DFU... - Attente du périphérique en mode DFU... Yeah ! Attendez, copie du firmware... Veuillez enregistrer le fichier .uf2 sur le lecteur DFU de votre appareil. Flash de l'appareil, veuillez patienter... @@ -1035,24 +1038,16 @@ Destination : %1$s Notes de Version Une erreur inconnue s'est produite - Échec de la mise à jour locale - Erreur DFU : %1$s - DFU interrompue 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 Nordic DFU Échec de la mise à jour USB Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB. Échec de la mise à jour de l'OTA : %1$s - Chargement du firmware... En attente du redémarrage de l'appareil en mode OTA... Connexion à l'appareil (tentative %1$d/%2$d)... - Vérification de la version de l'appareil... Démarrage de la mise à jour OTA... Transfert du Firmware... - Redémarrage de l'appareil... - Mise à jour du firmware - Statut de mise à jour du firmware Effacement... Retour Désactivé @@ -1083,9 +1078,7 @@ Surface estimée : précision inconnue Marquer comme lu Maintenant - Ajouter des canaux Les canaux suivants ont été trouvés dans le QR code. Sélectionnez ceux que vous souhaitez ajouter à votre appareil. Les canaux existants seront préservés. - Remplacer les canaux & les paramètres Ce code QR contient une configuration complète. Cela remplacera vos canaux et paramètres radio existants. Tous les canaux existants seront supprimés. Chargement @@ -1098,7 +1091,6 @@ Aucun filtre de mots configuré Modèle d'expression régulière Correspondance de mot entier - %1$d filtré Afficher %1$d filtré Masquer %1$d filtré Filtré @@ -1119,17 +1111,15 @@ Tout Bluetooth Configurer les autorisations Bluetooth - Se connecter à la radio - Recherchez et connectez-vous à votre périphérique radio maillage Meshtastic. Découverte Trouvez et identifiez les dispositifs Meshtastic autour de vous. Configuration Gérer à distance sans fil les paramètres et les canaux de votre appareil. - Autorisation accordée - Autorisation refusée 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 @@ -1140,16 +1130,97 @@ %1$d / %2$d %1$s Alimenté - Statistiques Meshtastiques 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é - Aucun appareil connecté + 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é - Actualiser + 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 3dac9e881..baabf41d0 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -20,7 +20,6 @@ Scagaire Cuir scagaire na nóid in áirithe Cuir Anaithnid san áireamh - Taispeáin sonraí Cainéal Sáth Cúlaithe @@ -60,7 +59,6 @@ Athsheoladh aon teachtaireacht i ndáiríre má bhí sí oiriúnach le do cheist go léannais foghlamhrúcháin. Ceim misniúla thosaí go lucht shnaithte! Cuireann sé bac ar theachtaireachtaí a fhaightear ó mhóilíní seachtracha cosúil le LOCAL ONLY, ach téann sé céim níos faide trí theachtaireachtaí ó nóid nach bhfuil sa liosta aitheanta ag an nóid a chosc freisin. - Ní dhéanfaidh sé Ceadaítear é seo ach amháin do na róil SENSOR, TRACKER agus TAK_TRACKER, agus cuirfidh sé bac ar gach athdháileadh, cosúil leis an róil CLIENT_MUTE. Cuireann sé bac ar phacáistí ó phortníomhaíochtaí neamhchaighdeánacha mar: TAK, RangeTest, PaxCounter, srl. Ní athdháileann ach pacáistí le portníomhaíochtaí caighdeánacha: NodeInfo, Text, Position, Telemetry, agus Routing. @@ -68,24 +66,17 @@ Cód QR Ainm Úsáideora Anaithnid Seol - Níl raidió comhoiriúnach Meshtastic péireáilte leis an bhfón seo agat fós. Péireáil gléas le do thoil agus socraigh d’ainm úsáideora.\n\nTá an feidhmchlár foinse oscailte seo faoi alfa-thástáil, má aimsíonn tú fadhbanna cuir iad ar ár bhfóram: https://github.com/orgs/meshtastic/discussions\n\nLe haghaidh tuilleadh faisnéise féach ar ár leathanach gréasáin - www.meshtastic.org. Glac Cealaigh Sábháil URL Cainéal nua faighte - Tuairiscigh fabht - Tuairiscigh fabht - An bhfuil tú cinnte gur mhaith leat fabht a thuairisciú? Tar éis tuairisciú a dhéanamh, cuir sa phost é le do thoil in https://github.com/orgs/meshtastic/discussions ionas gur féidir linn an tuarascáil a mheaitseáil leis an méid a d’aimsigh tú. Tuairiscigh - Péireáil críochnaithe, ag tosú seirbhís - Péireáil neadaithe, le do thoil roghnaigh arís Cead iontrála áit dúnta, ní féidir an suíomh a chur ar fáil chuig an mesh. Roinn Na ceangailte Gléas ina chodladh Seoladh IP: - Ceangailte le raidió (%1$s) Ní ceangailte Ceangailte le raidió, ach tá sé ina chodladh Nuashonrú feidhmchláir riachtanach @@ -199,11 +190,7 @@ Cóid Poiblí Eochair Mícomhoiriúnacht na heochrach phoiblí Fógartha faoi na nodes nua - Tuilleadh sonraí - 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 @@ -223,6 +210,7 @@
Céimeanna i dtreo %1$d Céimeanna ar ais %2$d Réigiún + Na ceangailte Am tráth Sáth @@ -239,4 +227,5 @@ + Scagaire diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index e8080d5ad..dc751d2e9 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -20,7 +20,6 @@ Filtro quitar filtro de nodo Incluír descoñecido - Amosar detalles A-Z Canle Distancia @@ -33,32 +32,24 @@ Non autorizado Fallou o envío cifrado Chave pública descoñecida - Cliente Aplicación conectada ou dispositivo de mensaxería autónomo. Nome de canle Código QR Nome de usuario descoñecido Enviar - Aínda non enlazaches unha radio compatible con Meshtástic neste teléfono. Por favor enlaza un dispositivo e coloca o teu nome de usuario. \n\n Esta aplicación de código aberto está en desenvolvemento. Se atopas problemas por favor publícaos no noso foro: https://github.com/orgs/meshtastic/discussions\n\nPara máis información visita a nosa páxina - www.meshtastic.org. Ti Aceptar Cancelar Gardar Novo enlace de canle recibida - Reportar erro - Reporta un erro - Seguro que queres reportar un erro? Despois de reportar, por favor publica en https://github.com/orgs/meshtastic/discussions para poder unir o reporte co que atopaches. Reportar - Enlazado completado, comezando servizo - Enlazado fallou, por favor seleccione de novo Acceso á úbicación está apagado, non se pode prover posición na rede. Compartir Desconectado Dispositivo durmindo Enderezo IP: Porto: - Conectado á radio (%1$s) Non conectado Conectado á radio, pero está durmindo Actualización da aplicación requerida @@ -158,6 +149,7 @@ Sempre Traza-ruta Rexión + Desconectado Distancia @@ -173,4 +165,5 @@ + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index f4952c897..502d64056 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -19,7 +19,6 @@ פילטר כלול לא ידועים - הצג פרטים א-ת ערוץ מרחק @@ -29,25 +28,18 @@ קוד QR שם המשתמש אינו מוכר שלח - עוד לא צימדת מכשיר תומך משטסטיק לטלפון זה. בבקשה צמד מכשיר והגדר שם משתמש.\n\nאפליקציית קוד פתוח זה נמצא בפיתוח, במקשר של בעיות בבקשה גש לפורום: https://github.com/orgs/meshtastic/discussions\n\n למידע נוסף בקרו באתר - www.meshtastic.org. אתה אישור בטל שמור התקבל כתובת ערוץ חדשה - דווח על באג - דווח על באג - בטוח שתרצה לדווח על באג? לאחר דיווח, בבקשה תעלה פוסט לפורום https://github.com/orgs/meshtastic/discussions כדי שנוכל לחבר בין חווייתך לדווח זה. דווח - צימוד הסתיים בהצלחה, מתחיל שירות - צימוד נכשל, בבקשה נסה שנית שירותי מיקום כבויים, לא ניתן לספק מיקום לרשת משטסטיק. שתף מנותק מכשיר במצב שינה ‏כתובת IP: פורט: - מחובר למכשיר (%1$s) לא מחובר מחובר למכשיר, אך הוא במצב שינה נדרש עדכון של האפליקציה @@ -141,6 +133,7 @@ בדיקת מסלול הודעות אזור + מנותק מרחק הגדרות @@ -155,4 +148,5 @@ + פילטר diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index fc8035129..114c3ed9a 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -36,24 +36,17 @@ QR kod Nepoznati korisnik Potvrdi - Još niste povezali Meshtastic radio uređaj s ovim telefonom. Povežite uređaj i postavite svoje korisničko ime.\n\nOva aplikacija otvorenog koda je u razvoju, ako naiđete na probleme, objavite na našem forumu: https://github.com/orgs/meshtastic/discussions\n\nZa više informacija pogledajte našu web stranicu - www.meshtastic.org. Vi Prihvati Odustani Spremi Primljen je URL novog kanala - Prijavi grešku - Prijavi grešku - Jeste li sigurni da želite prijaviti grešku? Nakon prijave, objavite poruku na https://github.com/orgs/meshtastic/discussions kako bismo mogli utvrditi dosljednost poruke o pogrešci i onoga što ste pronašli. Izvješće - Uparivanje uspješno, usluga je pokrenuta - Uparivanje nije uspjelo, molim odaberite ponovno Pristup lokaciji je isključen, Vaš Android ne može pružiti lokaciju mesh mreži. Podijeli Odspojeno Uređaj je u stanju mirovanja IP Adresa: - Spojen na radio (%1$s) Nije povezano Povezan na radio, ali je u stanju mirovanja Potrebna je nadogradnja aplikacije @@ -157,6 +150,7 @@ Detalji Crveno Regija + Odspojeno Udaljenost Meshtastic @@ -174,4 +168,6 @@ Crveno + Meshtastic + Filtriraj diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 47aa02972..60e00d491 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -20,7 +20,6 @@ Filtre klarifye filtè nœud Enkli enkoni - Montre detay kanal Distans Sote lwen @@ -61,7 +60,6 @@ Menm jan ak konpòtman kòm \"ALL\" men sote dekodaj pakè yo epi senpleman rebroadcast yo. Disponib sèlman nan wòl Repeater. Mete sa sou nenpòt lòt wòl ap bay konpòtman \"ALL\". Ignoré mesaj obsève soti nan meshes etranje ki louvri oswa sa yo li pa ka dekripte. Sèlman rebroadcast mesaj sou kanal prensipal / segondè lokal nœud. Ignoré mesaj obsève soti nan meshes etranje tankou \"LOCAL ONLY\", men ale yon etap pi lwen pa tou ignorer mesaj ki soti nan nœud ki poko nan lis konnen nœud la. - Pa gen Sèlman pèmèt pou wòl SENSOR, TRACKER ak TAK_TRACKER, sa a ap entèdi tout rebroadcasts, pa diferan de wòl CLIENT_MUTE. Ignoré pakè soti nan portnum ki pa estanda tankou: TAK, RangeTest, PaxCounter, elatriye. Sèlman rebroadcast pakè ak portnum estanda: NodeInfo, Tèks, Pozisyon, Telemetri, ak Routing. @@ -69,24 +67,17 @@ Kòd QR Non itilizatè enkoni Voye - Ou poko konekte ak yon radyo ki konpatib ak Meshtastic sou telefòn sa a. Tanpri konekte yon aparèy epi mete non itilizatè w lan.\n\nSa a se yon aplikasyon piblik ki nan tès Alpha. Si ou gen pwoblèm, tanpri pataje sou fowòm nou an: https://github.com/orgs/meshtastic/discussions\n\nPou plis enfòmasyon, vizite sit wèb nou an - www.meshtastic.org. Ou Aksepte Anile Sove Nouvo kanal URL resevwa - Rapòte yon pwoblèm - Rapòte pwoblèm - Èske ou sèten ou vle rapòte yon pwoblèm? Aprew fin rapòte, tanpri pataje sou https://github.com/orgs/meshtastic/discussions pou nou ka konpare rapò a ak sa ou jwenn nan. Rapò - Koneksyon konplè, sèvis kòmanse - Koneksyon echwe, tanpri chwazi ankò Aksè lokasyon enfim, pa ka bay pozisyon mesh la. Pataje Dekonekte Aparèy ap dòmi Adrès IP: - Konekte ak radyo (%1$s) Pa konekte Konekte ak radyo, men li ap dòmi Aplikasyon twò ansyen @@ -195,11 +186,7 @@ Chifreman Kle Piblik Pa matche kle piblik Notifikasyon nouvo nœud - Plis detay - 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 @@ -211,6 +198,7 @@ Direk Hops vèsus %1$d Hops tounen %2$d Rejyon + Dekonekte Tan pase Distans @@ -227,4 +215,5 @@ + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index d865dc7f0..33b795a7f 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -26,7 +26,6 @@ Offline csomópontok elrejtése Csak közvetlen csomópontok megjelenítése Figyelmen kívül hagyott csomópontokat nézed,\nnyomd meg a gombot a listához való visszatéréshez. - Részletek megjelenítése Rendezés Csomópont-rendezési beállítások A-Z @@ -41,6 +40,7 @@ Ismeretlen Visszajelzésre vár Elküldésre vár + Ismeretlen Visszaigazolva Nincs út Negatív visszaigazolás érkezett @@ -57,41 +57,23 @@ Nem Ismert Publikus Kulcs Hibás munkamenet kulcs Nem Engedélyezett Publikus Kulcs - Kliens Alkalmazáshoz csatlakoztatott vagy önálló üzenetküldő eszköz. - Néma Kliens Olyan eszköz, amely nem továbbít más eszközöktől érkező csomagokat. - Router Hálózati lefedettséget bővítő infrastruktúra-csomópont, amely továbbítja az üzeneteket. Látható a csomópont-listában. - Router Kliens ROUTER és CLIENT kombinációja. Nem hordozható eszközökhöz. - Jelismétlő Hálózati lefedettséget bővítő infrastruktúra-csomópont, amely minimális terheléssel továbbítja az üzeneteket. Nem látható a listában. - Tracker GPS-pozíció csomagok elsődleges sugárzása. - Szenzor Telemetriai csomagok elsődleges sugárzása. - TAK ATAK rendszerkommunikációra optimalizált, csökkenti a rutin-sugárzásokat. - Rejtett Kliens Eszköz, amely csak szükség esetén sugároz, rejtettség vagy energiatakarékosság miatt. - Elveszett és Megkerült Rendszeresen sugározza a helyzetet az alapértelmezett csatornára az eszköz visszakeresésének segítésére. - TAK Tracker Automatikus TAK PLI sugárzást engedélyez és csökkenti a rutin-sugárzásokat. - Késő Router Infrastruktúra-csomópont, amely minden csomagot egyszer újraküld, de csak az összes más mód után, extra lefedettséget biztosítva a helyi klasztereknek. Látható a listában. - Összes Újrasugároz minden észlelt üzenetet, ha az a privát csatornánkon volt, vagy más, azonos LoRa-paraméterű hálózatból származik. - Minden dekódolás kihagyása Ugyanaz, mint az „ALL” viselkedés, de kihagyja a csomag dekódolását és egyszerűen újrasugározza. Csak Ismétlő (Repeater) szerepkörben elérhető; más szerepkörben az „ALL” mód érvényesül. - Csak helyi Figyelmen kívül hagyja a nyílt vagy nem dekódolható idegen hálózatok üzeneteit. Csak a csomópont helyi elsődleges / másodlagos csatornáin sugároz újra. - Csak ismert Hasonló a „LOCAL ONLY”-hoz, de tovább megy: figyelmen kívül hagyja az olyan csomópontok üzeneteit is, amelyek nem szerepelnek az ismert listában. - Semmi Csak SENSOR, TRACKER és TAK_TRACKER szerepkörben engedélyezett; minden újraküldést letilt, hasonlóan a CLIENT_MUTE szerephez. - Csak alap portszámok Figyelmen kívül hagyja a nem szabványos portszámú csomagokat (pl. TAK, RangeTest, PaxCounter), és csak a szabványos portszámúakat sugározza újra: NodeInfo, Text, Position, Telemetry, Routing. A támogatott gyorsulásmérők dupla koppintását kezelje felhasználói gombnyomásként. Elsődleges csatornán pozíció küldése a gomb háromszori megnyomásakor. @@ -153,7 +135,6 @@ QR kód Ismeretlen felhasználónév Küldeni - Még nem párosított egyetlen Meshtastic rádiót sem ehhez a telefonhoz. Kérem pároztasson egyet és állítsa be a felhasználónevet.\n\nEz a szabad forráskódú alkalmazás fejlesztés alatt áll, ha hibát talál kérem írjon a projekt fórumába: https://github.com/orgs/meshtastic/discussions\n\nBővebb információért látogasson el a projekt weboldalára - www.meshtastic.org. Te Analitika és hibajelentések engedélyezése. Elfogadni @@ -161,23 +142,15 @@ Elvetés Mentés Új csatorna URL érkezett - A Meshtastic helyhozzáférést igényel az új eszközök Bluetooth-os kereséséhez. Használaton kívül kikapcsolható. - Hiba jelentése - Hiba jelentése - Biztosan jelenteni akarja a hibát? Bejelentés után kérem írjon a https://github.com/orgs/meshtastic/discussions fórumba, hogy így össze tudjuk hangolni a jelentést azzal, amit talált. Jelentés - Pároztatás befejeződött, a szolgáltatás indítása - Pároztatás sikertelen, kérem próbálja meg újra. A földrajzi helyhez való hozzáférés le van tiltva, nem lehet pozíciót közölni a mesh hálózattal. Megosztás Új csomópont észlelve: %1$s Szétkapcsolva Az eszköz alszik - Kapcsolódva: %1$s elérhető IP cím: Port: Kapcsolódva - Kapcsolódva a(z) %1$s rádióhoz Jelenlegi kapcsolatok: Wifi IP: Ethernet IP: @@ -190,14 +163,11 @@ Szolgáltatás értesítések Visszaigazolások (ACK-ek) Ez a csatorna URL érvénytelen, ezért nem használható. - Ez a névjegy érvénytelen, nem vehető fel Hibakereső panel Dekódolt adat: Naplók exportálása - Exportálás megszakítva %1$d napló exportálva Nem sikerült a naplófájl írása: %1$s - Nincs exportálható napló %1$d óra %1$d óra @@ -215,7 +185,6 @@ Szűrő hozzáadása Szűrő hozzáadva Összes szűrő törlése - Csak a mellőzött csomópontok megjelenítése Naplók törlése Bármelyik | Mind Mind | Bármelyik @@ -268,9 +237,7 @@ Leállítás Leállítás nem támogatott ezen az eszközön ⚠️ Ez LEÁLLÍTJA a csomópontot. Újraindításhoz fizikai beavatkozás szükséges. - ⚠️ Ez egy kritikus infrastruktúra-csomópont. Írd be a csomópont nevét a megerősítéshez: Csomópont: %1$s - Típus: %1$s Újraindítás Traceroute Bemutatkozás megjelenítése @@ -282,9 +249,7 @@ Azonnali küldés Gyors csevegés menü megjelenítése Gyors csevegés menü elrejtése - Gyors csevegés megjelenítése Gyári beállítások visszaállítása - A Bluetooth ki van kapcsolva. Engedélyezd az eszköz beállításaiban. Beállítások megnyitása Firmware-verzió: %1$s A Meshtastic-nek engedélyezni kell a „Közeli eszközök” hozzáférést, hogy Bluetooth-on keresztül eszközöket találjon és csatlakozzon. Használaton kívül kikapcsolható. @@ -326,14 +291,12 @@ Törlés Ez a csomópont kikerül a listádról, amíg az eszközöd újra nem kap adatot tőle. Értesítések némítása - 1 óra 8 óra 1 hét Mindig Jelenleg: Mindig némítva Nincs némítva - Némítás állapota Csere WiFi QR kód szkennelése Érvénytelen WiFi-hitelesítő QR-kód formátum @@ -341,7 +304,6 @@ Akkumulátor Naplók Ugrás Messzire - Ugrások száma: %1$d Információ A jelenlegi csatorna kihasználtsága, beleértve a megfelelő TX/RX és a hibás RX (zaj) csomagokat. Az elmúlt órában az adásra használt adásidő százaléka. @@ -353,14 +315,10 @@ A közvetlen üzenetek az új nyilvános kulcsú infrastruktúrát használják titkosításhoz. Publikus kulcs nem egyezik Új állomás értesítések - Több részlet 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 @@ -387,10 +345,8 @@ Ehhez a traceroute-hoz még nincs térképre tehető csomópont. Megjelenítve: %1$d/%2$d csomópont 24 óra - 48 óra 1 hét 2 hét - 4 hét Max Ismeretlen ideje Másolás @@ -414,8 +370,6 @@ Alacsony töltöttség: %1$s Alacsony töltöttségű értesítések (kedvenc csomópontok) Engedélyezve - UDP sugárzás - UDP-beállítások Utoljára hallva: %2$s
Utolsó pozíció: %3$s
Akkumulátor: %4$s]]>
Saját pozíció váltása Északra tájolás @@ -497,7 +451,6 @@ Figyelt GPIO láb Érzékelési ravasztípus INPUT_PULLUP mód használata - Eszköz Eszköz szerepköre Gomb GPIO Csipogó (buzzer) GPIO @@ -546,7 +499,6 @@ Sávszélesség Szórási Faktor Kódolási ráta - Frekvenciaeltolás (MHz) Régió Ugrások száma Adás engedélyezve @@ -560,6 +512,8 @@ MQTT figyelmen kívül hagyása MQTT-re továbbítható MQTT beállítások + Szétkapcsolva + Csatlakoztatva MQTT engedélyezve Cím Felhasználónév @@ -575,7 +529,6 @@ Szomszéd-információ engedélyezve Frissítési intervallum (másodperc) Továbbítás LoRa-n keresztül - Hálózat Wi-Fi beállítások Engedélyezve WiFi engedélyezve @@ -592,31 +545,18 @@ Paxcounter engedélyezve WiFi RSSI küszöbérték (alapértelmezés: -80) BLE RSSI küszöbérték (alapértelmezés: -80) - Pozíció - Pozíció-sugárzási intervallum (másodperc) - Intelligens pozíció engedélyezve - Intelligens sugárzás minimális távolság (méter) - Intelligens sugárzás minimális intervallum (másodperc) - Rögzített pozíció használata Szélesség Hosszúság - Magasság (méter) Beállítás a telefon jelenlegi helyzete alapján GPS mód (fizikai hardver) - GPS frissítési intervallum (másodperc) - GPS_RX_PIN újradefiniálása - GPS_TX_PIN újradefiniálása - PIN_GPS_EN újradefiniálása Pozíció jelzők (flags) Energia-beállítások Energiatakarékos mód engedélyezése Leállítás áramszünet esetén - Kikapcsolás akkumulátor késleltetéssel (másodperc) ADC szorzó felülbírálása ADC szorzó felülbírálási arány Bluetooth-várakozás időtartama Szuper mélyalvás időtartama - Enyhe alvás időtartama Minimális ébrenléti idő Akkumulátor INA_2XX I2C-cím Hatótáv-teszt beállításai @@ -627,7 +567,6 @@ Távoli hardver engedélyezve Nem definiált pinek elérésének engedélyezése Elérhető pinek - Biztonság Közvetlen üzenet kulcsa Admin kulcsok Nyilvános kulcs @@ -704,7 +643,6 @@ Nyomd meg és húzd az átrendezéshez Némítás feloldása Dinamikus - QR-kód beolvasása Kapcsolat megosztása Jegyzetek Privát jegyzet hozzáadása… @@ -715,13 +653,11 @@ Nyilvános kulcs megváltozott Importálás Kérés - NeighborInfo (2.7.15+) Telemetria kérése Eszközmetrikák Környezeti metrikák Levegőminőségi metrikák Tápellátási metrikák - Helyi statisztikák Metaadatok Műveletek Firmware @@ -729,7 +665,6 @@ Engedélyezéskor az eszköz 12 órás formátumban jeleníti meg az időt a kijelzőn. Gazdagép Szabad memória - Szabad lemezterület Terhelés Felhasználói szöveg Belépés @@ -767,8 +702,6 @@ (%1$d online / %2$d megjelenítve / %3$d összesen) Reagálás Leválasztás - Nem található hálózati eszköz. - Nem találhatók USB-soros eszközök. Görgetés az aljára Meshtastic Biztonsági állapot @@ -784,8 +717,6 @@ Csomópont-adatbázis tisztítása %1$d napnál régebben látott csomópontok törlése Csak ismeretlen csomópontok törlése - Kevés vagy nulla interakcióval rendelkező csomópontok törlése - Figyelmen kívül hagyott csomópontok törlése Azonnali tisztítás Ez %1$d csomópontot távolít el az adatbázisból. A művelet nem vonható vissza. A zöld lakat azt jelzi, hogy a csatorna biztonságosan titkosított 128 vagy 256 bites AES kulccsal. @@ -804,8 +735,6 @@ Összes jelentés megjelenítése Jelenlegi állapot megjelenítése Bezárás - Biztosan törlöd ezt a csomópontot? - Kapcsolat elfelejtése Válasz %1$s részére Válasz törlése Üzenetek törlése? @@ -813,7 +742,6 @@ Üzenet Írj üzenetet PAX - Párosított eszközök Csatlakoztatott eszköz Túllépted a sebességkorlátot. Próbáld újra később. Kiadás megtekintése @@ -859,17 +787,13 @@ Kritikus riasztások beállítása A Meshtastic értesítésekkel tájékoztat az új üzenetekről és más fontos eseményekről. Az értesítési engedélyeket bármikor módosíthatod a beállításokban. Tovább - Engedély megadása %1$d csomópont vár törlésre: Figyelem: Ez eltávolítja a csomópontokat az alkalmazás és az eszköz adatbázisából.\nA kijelölések összeadódnak. - Csatlakozás az eszközhöz Normál Műhold Domborzat Hibrid Térképrétegek kezelése - Térképrétegek - Réteg hozzáadása Réteg elrejtése Réteg megjelenítése Réteg eltávolítása @@ -902,7 +826,6 @@ 48 óra Szűrés az utolsó észlelés ideje szerint: %1$s %1$d dBm - Nincs alkalmazás a hivatkozás kezeléséhez. Rendszerbeállítások Nem állnak rendelkezésre statisztikák Analitikai adatokat gyűjtünk az Android alkalmazás fejlesztésének segítésére (köszönjük). Anonimizált információkat kapunk a felhasználói viselkedésről, beleértve a hibajelentéseket, a használt képernyőket stb. @@ -910,7 +833,6 @@ További információért lásd az adatvédelmi irányelveinket. Nincs beállítva – 0 - Leválasztás…... A frissítés sikertelen Nincs beállítva @@ -927,4 +849,6 @@ Kék Zöld Csatlakozás + Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml index daba83eb5..ce8853250 100644 --- a/core/resources/src/commonMain/composeResources/values-is/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml @@ -22,24 +22,17 @@ QR kóði Óþekkt notendanafn Senda - Þú hefur ekki parað Meshtastic radíó við þennan síma. Vinsamlegast paraðu búnað og veldu notendnafn.\n\nÞessi opni hugbúnaður er enn í þróun, finnir þú vandamál vinsamlegast búðu til þráð á spjallborðinu okkar: https://github.com/orgs/meshtastic/discussions\n\nFyrir frekari upplýsingar sjá vefsíðu - www.meshtastic.org. Þú Samþykkja Hætta við Vista Ný slóð fyrir rás móttekin - Tilkynna villu - Tilkynna villu - Er þú viss um að vilja tilkynna villu? Eftir tilkynningu, settu vinsamlega inn þráð á https://github.com/orgs/meshtastic/discussions svo við getum tengt saman tilkynninguna við villuna sem þú fannst. Tilkynna - Pörun lokið, ræsir þjónustu - Pörun mistókst, vinsamlegast veljið aftur Aðgangur að staðsetningu ekki leyfður, staðsetning ekki send út á mesh. Deila Aftengd Radíó er í svefnham IP Tala: - Tengdur við radíó (%1$s) Ekki tengdur Tengdur við radíó, en það er í svefnham Uppfærsla á smáforriti nauðsynleg @@ -126,6 +119,7 @@ Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar. Ferilkönnun Svæði + Aftengd diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 0e31f3b88..baa0e0947 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -26,7 +26,6 @@ Nascondi i nodi offline Mostra solamente i nodi diretti Stai visualizzando i nodi ignorati,\nPremi per tornare alla lista dei nodi - Mostra dettagli Ordina per Opzioni ordinamento nodi A-Z @@ -44,6 +43,7 @@ Non riconosciuto In attesa di conferma In coda per l'invio + Sconosciuto Percorso tramite catena SF++… Confermato sulla catena SF++ Confermato @@ -63,43 +63,24 @@ Chiave di sessione non valida Chiave Pubblica non autorizzata Invio PKI non riuscito, nessuna chiave pubblica - Client App collegata o dispositivo di messaggistica standalone. - Client Mute Dispositivo che non inoltra pacchetti da altri dispositivi. - Base Client Tratta i pacchetti da o verso i nodi preferiti come ROUTER_LATE, e tutti gli altri pacchetti come CLIENT. - Router Nodo d'infrastruttura per estendere la copertura di rete tramite inoltro dei messaggi. Visibile nell'elenco dei nodi. - Router Client Combinazione di ROUTER e CLIENT. Non per dispositivi mobili. - Repeater Nodo d'infrastruttura per estendere la copertura della rete tramite inoltro dei messaggi con overhead minimo. Non visibile nell'elenco dei nodi. - Tracker Dà priorità alla trasmissione di pacchetti di posizione GPS. - Sensore Dà priorità alla trasmissione di pacchetti di telemetria. - TAK Ottimizzato per la comunicazione del sistema ATAK, riduce le trasmissioni di routine. - Client Nascosto Dispositivo che trasmette solo quando necessario, per risparmiare energia o restare invisibile. - Oggetti Smarriti Trasmette a intervalli regolari la posizione come messaggio nel canale predefinito per aiutare il recupero del dispositivo. - TAK Tracker Abilita le trasmissioni automatiche TAK PLI e riduce le trasmissioni di routine. - Router Late Nodo dell'infrastruttura che ritrasmette sempre i pacchetti una volta ma solo dopo tutte le altre modalità, garantendo una copertura aggiuntiva per i cluster locali. Visibile nella lista dei nodi. - Tutti Ritrasmettere qualsiasi messaggio osservato, se era sul nostro canale privato o da un'altra mesh con gli stessi parametri lora. - Tutto ma Salta Decodifica Stesso comportamento di ALL ma salta la decodifica dei pacchetti e semplicemente li ritrasmette. Disponibile solo nel ruolo Repeater. Attivando questo su qualsiasi altro ruolo, si otterrà il comportamento di ALL. - Solo Locale Ignora i messaggi osservati da mesh esterne aperte o quelli che non possono essere decifrati. Ritrasmette il messaggio solo nei canali locali primario / secondario dei nodi. - Solo Conosciuti Ignora i messaggi osservati da mesh esterne come fa LOCAL ONLY, ma in più ignora i messaggi da nodi non presenti nella lista dei nodi conosciuti. - Nessuno Permesso solo per i ruoli SENSOR, TRACKER e TAK_TRACKER, questo inibirà tutte le ritrasmissioni, come il ruolo CLIENT_MUTE. - Solo Core Portnums Ignora pacchetti da numeri di porta non standard come: TAK, RangeTest, PaxCounter, ecc. Ritrasmette solo pacchetti con numeri di porta standard: NodeInfo, Testo, Posizione, Telemetria e Routing. Considera il doppio tocco sugli accelerometri supportati come la pressione di un pulsante utente. Invia la posizione sul canale principale quando il pulsante utente viene cliccato tre volte. @@ -165,7 +146,6 @@ Codice QR Nome Utente Sconosciuto Invia - Non è ancora stato abbinato un dispositivo radio compatibile Meshtastic a questo telefono. È necessario abbinare un dispositivo e impostare il nome utente.\n\nQuesta applicazione open-source è ancora in via di sviluppo, se si riscontrano problemi, rivolgersi al forum: https://github.com/orgs/meshtastic/discussions\n\nPer maggiori informazioni visitare la pagina web - www.meshtastic.org. Tu Consenti analisi e segnalazione di crash. Accetta @@ -173,23 +153,15 @@ Annulla Salva Ricevuta URL del Nuovo Canale - Meshtastic ha bisogno dei permessi di localizzazione abilitati per trovare nuovi dispositivi via Bluetooth. Puoi disabilitare quando non è in uso. - Segnala Bug - Segnalazione di bug - Procedere con la segnalazione di bug? Dopo averlo segnalato, si prega di postarlo in https://github.com/orgs/meshtastic/discussions in modo che possiamo associare la segnalazione al problema riscontrato. Invia Segnalazione - Abbinamento completato, attivazione in corso del servizio - Abbinamento fallito, effettuare una nuova selezione L'accesso alla posizione è disattivato, non è possibile fornire la posizione al mesh. Condividi Nuovo Nodo Ricevuto:%1$s Disconnesso Il dispositivo è inattivo - Connesso: %1$s online Indirizzo IP: Porta: Connesso - Connesso alla radio (%1$s) Connessioni attive: IP Wifi: IP Ethernet: @@ -211,14 +183,11 @@ Meshtastic è costruito con le seguenti librerie open source. Tocca una libreria per visualizzare la sua licenza. %1$d librerie L'URL di questo Canale non è valida e non può essere usata - Questo contatto non è valido e non può essere aggiunto Pannello Di Debug Payload decodificato: Esporta i logs - Esportazione annullata %1$d registri esportati Impossibile scrivere il file di log: %1$s - Nessun log da esportare %1$d ora %1$d ore @@ -238,7 +207,6 @@ Rimuovi tutti i filtri Aggiungi filtro personalizzato Filtri Preset - Visualizza solo i nodi ignorati Memorizza i log della mesh Disabilita per saltare la scrittura dei log di mesh sul disco Cancella i log @@ -271,6 +239,8 @@ Scuro Predefinito di sistema Scegli tema + Medium + Alto Fornire la posizione alla mesh Codifica compatta per cirillico @@ -295,9 +265,7 @@ Spegni Spegnimento non supportato su questo dispositivo ⚠️ Il nodo verrà SPENTO. Sarà necessario un intervento manuale per riaccenderlo. - ⚠️ Questo nodo è critico per l'infrastruttura. Digitare il nome del nodo per confermare: Nodo: %1$s - Tipo: %1$s Riavvia Traceroute Mostra Guida introduttiva @@ -309,9 +277,7 @@ Invio immediato Mostra menu della chat rapida Nascondi menu della chat rapida - Mostra chat rapida Ripristina impostazioni di fabbrica - Il Bluetooth è disabilitato. Si prega di attivarlo nelle impostazioni del dispositivo. Apri impostazioni Versione firmware:%1$s Meshtastic ha bisogno dei permessi \"Dispositivi nelle vicinanze\" abilitati per trovare e connettersi ai dispositivi tramite Bluetooth. È possibile disabilitare quando non è in uso. @@ -320,6 +286,7 @@ Consegna confermata Il dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni. Errore + Errore sconosciuto Ignora Rimuovi da ignorati Aggiungere '%1$s' alla lista degli ignorati? @@ -354,14 +321,12 @@ Elimina Questo nodo verrà rimosso dalla tua lista fino a quando il tuo nodo non riceverà di nuovo dei dati. Disattiva notifiche - 1 ora 8 ore 1 settimana Sempre Attualmente: Sempre mutato Non mutato - Stato silenziato Silenziare le notifiche per '%1$s'? Ripristinare le notifiche per '%1$s'? Sostituisci @@ -376,7 +341,6 @@ Umidità del Suolo Registri Distanza in Hop - Distanza in Hop: %1$d Informazioni Utilizzazione del canale attuale, compreso TX, RX ben formato e RX malformato (cioè rumore). Percentuale di tempo di trasmissione utilizzato nell’ultima ora. @@ -390,14 +354,10 @@ La chiave pubblica non corrisponde alla chiave salvata. È possibile rimuovere il nodo e lasciarlo scambiare le chiavi nuovamente, ma questo può indicare un problema di sicurezza più serio. Contattare l'utente attraverso un altro canale attendibile, per determinare se il cambiamento di chiave è dovuto a un ripristino di fabbrica o ad altre azioni intenzionali. Informazioni Utente Notifiche di nuovi nodi - Ulteriori informazioni 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 @@ -424,15 +384,12 @@ Questo traceroute non ha ancora nodi mappabili. %1$d/%2$d nodi visualizzati Durata: %1$s s - %1$s - %2$s Percorso verso la destinazione:\n\n Percorso verso di noi:\n\n 1H 24H - 48H 1S 2S - 4S 1M Max Età sconosciuta @@ -458,8 +415,6 @@ Notifiche batteria scarica (nodi preferiti) Pressione atmosferica Abilitato - Trasmissione UDP - Configurazione UDP Ricevuto l'ultima volta: %2$s
Posizione più recente: %3$s
Batteria: %4$s]]>
Attiva/disattiva posizione Orientamento nord @@ -537,11 +492,9 @@ Trasmissione stato (secondi) Invia campanella con messaggio di avviso Nome semplificato - Indirizzo semplificato Pin GPIO da monitorare Tipo di trigger di rilevamento Usa modalità INPUT_PULLUP - Dispositivo Ruolo Del Dispositivo GPIO del Pulsante GPIO del Buzzer @@ -591,7 +544,6 @@ Larghezza di banda Spread Factor Coding Rate - Offset di frequenza (MHz) Regione Numero di Hop Trasmissione Abilitata @@ -605,6 +557,8 @@ Ignora MQTT OK per MQTT Configurazione MQTT + Disconnesso + Connesso MQTT abilitato Indirizzo Username @@ -620,13 +574,11 @@ Info Nodi Vicini abilitato Intervallo di aggiornamento (secondi) Trasmettere su LoRa - Rete Opzioni WiFi Abilitato WiFi abilitato SSID PSK - Scarica Documento Opzioni Ethernet Ethernet abilitato Server NTP @@ -634,6 +586,7 @@ Modalità IPv4 IP Gateway + DNS Configurazione Paxcounter Paxcounter abilitato Messaggio di Stato @@ -641,31 +594,18 @@ La stringa di stato attuale Soglia RSSI WiFi (valore predefinito -80) Soglia RSSI BLE (valore predefinito -80) - Posizione - Intervallo trasmissione posizione (secondi) - Posizione smart abilitata - Distanza minima per trasmissione smart (metri) - Intervallo minimo per trasmissione smart (secondi) - Usa posizione fissa Latitudine Longitudine - Altitudine (metri) Imposta dalla posizione attuale del telefono Modalità GPS (Hardware Fisico) - Intervallo aggiornamento GPS (secondi) - Ridefinisci GPS_RX_PIN - Ridefinisci GPS_TX_PIN - Ridefinisci PIN_GPS_EN Flag Di Posizione Configurazione Alimentazione Abilita modalità risparmio energetico Spegnimento in mancanza di alimentazione - Ritardo spegnimento a batteria (secondi) Sovrascrivi moltiplicatore ADC Sovrascrivi rapporto moltiplicatore ADC Durata attesa Bluetooth Durata super deep sleep - Durata light sleep Tempo minimo di risveglio Indirizzo INA_2XX I2C della batteria Configurazione Test Distanza Massima @@ -676,7 +616,6 @@ Hardware Remoto abilitato Consenti accesso a pin non definiti Pin disponibili - Sicurezza Chiave per Messaggi Diretti Chiave Amministratore Chiave Pubblica @@ -738,8 +677,6 @@ ID utente Tempo di attività Utilizzo %1$d - Recupero Canale %1$d/%2$d - Recupero %1$s in corso Disco libero %1$d Data e ora Direzione @@ -756,7 +693,6 @@ Premi e trascina per riordinare Riattiva l'audio Dinamico - Scansiona codice QR Condividi contatto Note Aggiungi una nota privata... @@ -774,7 +710,6 @@ Metriche Ambientali Metriche Qualità Aria Metriche Alimentazione - Statistiche Locali Metriche Host Metriche Pax Metadati @@ -785,7 +720,6 @@ Metriche Host Host Memoria libera - Spazio disco libero Carico Stringa Utente Guidami Verso @@ -823,8 +757,6 @@ (%1$d online / %2$d visualizzati / %3$d in totale) Rispondi Disconnetti - Nessun dispositivo di rete trovato. - Nessun dispositivo trovato sulla seriale USB. Scorri fino in fondo Meshtastic Stato di sicurezza @@ -840,8 +772,6 @@ Azzera il database dei nodi Elimina i nodi visti per l'ultima volta più di %1$d giorni fa Elimina solo i nodi sconosciuti - Elimina i nodi con bassa/nessuna interazione - Elimina i nodi ignorati Elimina ora Questo rimuoverà %1$d nodi dal tuo database. Questa azione non può essere annullata. L'icona di un lucchetto verde chiuso indica che il canale è criptato in modo sicuro con una chiave AES a 128 o 256 bit @@ -860,9 +790,6 @@ Mostra tutti i significati Mostra lo stato attuale Annulla - Sei sicuro di voler eliminare questo nodo? - Elimina connessione - Sei sicuro di voler eliminare questa connessione? Rispondendo a %1$s Annulla risposta Eliminare messaggi? @@ -873,7 +800,6 @@ PAX Nessun log delle metriche PAX disponibile. Dispositivi Bluetooth - Dispositivi associati Dispositivo connesso Limite di trasmissione superato. Riprova più tardi Visualizza Release @@ -923,19 +849,15 @@ Configura avvisi critici Meshtastic utilizza le notifiche per tenerti aggiornato su nuovi messaggi e altri eventi importanti. È possibile aggiornare i permessi di notifica in qualsiasi momento dalle impostazioni. Avanti - Concedi permessi %1$d nodi in coda per l'eliminazione: Attenzione: questo rimuove i nodi dal database dell'app e sul dispositivo. Le selezioni\nsono additive. - Connessione al dispositivo in corso… Normale Satelliti Terreno Ibrido Gestisci livelli della mappa I livelli della mappa supportano i formati .kml, .kmz o GeoJSON. - Livelli della mappa Nessun livello di mappa caricato. - Aggiungi livello Nascondi livello Mostra livello Rimuovi livello @@ -969,20 +891,17 @@ 48 Ore Filtra per orario di ricezione più recente: %1$s %1$d dBm - Nessuna applicazione disponibile per gestire il link. Impostazioni di Sistema Statistiche Non Disponibili I dati di utilizzo sono raccolti per aiutarci a migliorare l'applicazione Android (grazie), riceveremo informazioni anonimizzate sul comportamento dell'utente. Queste includono rapporti di arresti anomali, schermi utilizzate nell'app, ecc. Piattaforme di analytics: Per ulteriori informazioni, consulta la nostra informativa sulla privacy. Disattiva - 0 - Ritrasmesso da: %1$s %1$s di solito viene fornito con un bootloader che non supporta gli aggiornamenti OTA. Potrebbe essere necessario flashare tramite USB un bootloader con funzione OTA prima di flashare tramite OTA. Maggiori informazioni Per RAK WisBlock RAK4631, utilizzare lo strumento seriale DFU fornito dal produttore (per esempio, adafruit-nrfutil dfu serial con il file .zip del bootloader fornito). La sola copia del file .uf2 non aggiornerà il bootloader. Non mostrare di nuovo per questo dispositivo Conservare I Preferiti? - Dispositivi USB Aggiornamento Firmware Verifica aggiornamenti in corso... @@ -997,14 +916,10 @@ Aggiornamento Riuscito! Fatto Avvio modalità DFU... - Aggiornamento in corso... %1$s - Disconnessione in corso... Modello hardware sconosciuto: %1$d - Il dispositivo connesso non è un dispositivo BLE valido oppure l'indirizzo è sconosciuto (%1$s). Nessun dispositivo connesso Impossibile trovare il firmware per %1$s nelle release. Estrazione firmware in corso... - Disconnessione in corso per avviare il servizio DFU... Aggiornamento non riuscito Un po' di pazienza, operazioni in corso... Mantieni il dispositivo vicino al telefono. @@ -1018,7 +933,6 @@ Chirpy dice: \"Tieni la tua scala a portata di mano!\" Chirpy Riavvio in DFU... - In attesa del dispositivo DFU... Salva il file .uf2 nell'unità DFU del dispositivo. Flash del dispositivo in corso, attendere... Trasferimento File via USB @@ -1027,13 +941,11 @@ Selezionare il Disco DFU USB Il dispositivo è stato riavviato in modalità DFU e dovrebbe apparire come un disco USB (ad es. RAK4631).\n\nQuando il selettore di file si apre, selezionare la cartella principale (root) dell'unità per salvare il file con il firmware. Errore sconosciuto - Aggiornamento Firmware Indietro Non impostato Sempre Attivo Adesso - Aggiungi canali Genera codice QR Tutti @@ -1044,9 +956,9 @@ Blu Verde Modulo abilitato - Nessun dispositivo connesso - Scarica Firmware 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 7504a5bf3..64aa0fe05 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -26,7 +26,6 @@ オフラインノードを非表示 ダイレクトノードのみ表示 無視されたノードを表示しています。\nノード一覧に戻るにはここを押してください。 - 詳細を表示 並べ替え ノードの並べ替えオプション A-Z @@ -62,42 +61,23 @@ セッションキーが不正です 許可されていない公開キー PKIの送信に失敗しました、公開鍵はありません - クライアント アプリに接続されているか、スタンドアロンのメッセージングデバイスです。 - クライアント・ミュート このデバイスは他のデバイスからのパケットを転送しません。 - クライアント・ベース - ルーター メッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストに表示されます。 - ルータークライアント ROUTERとCLIENTの組み合わせ。モバイルデバイス向けではありません。 - リピーター 最小限のオーバーヘッドでメッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストには表示されません。 - トラッカー GPSの位置情報パケットを優先してブロードキャストします。 - センサー テレメトリーパケットを優先してブロードキャストします。 - TAK ATAKシステムとの通信に最適化し、定期的なブロードキャストを削減します。 - クライアント・非表示 ステルスまたは電力節約のため、必要に応じてのみブロードキャストするデバイス。 - 紛失モード デバイスを見つけやすくするために、デバイス自身の位置情報をメッセージ形式で定期的にデフォルトのチャンネルにブロードキャストします。 - TAK Tracker TAK PLIの自動ブロードキャストを有効にし、ルーチンブロードキャストを削減します。 - ルーター・レイト 周辺クラスターの通信範囲を拡大させるインフラストラクチャノード。他のすべてのノードが通信し終わった後で、必ずパケットを1回だけ再ブロードキャストする。ノードリストに表示される。 - すべて 受信メッセージが、参加しているプライベートチャンネル上のもの、または同じLoRaパラメータを持つ別のメッシュからのものであれば再ブロードキャストします。 - すべてをスキップ ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。 リピーターロールでのみ使用できます。他のロールに設定すると、ALLの動作になります。 - ローカルのみ 開いている外部メッシュや復号できないメッシュからのメッセージを無視。 ノードのローカルプライマリー/セカンダリーチャンネルでのみメッセージを再ブロードキャスト。 - 既知のみ LOCAL ONLYのような外部メッシュからのメッセージを無視します。 さらに一歩進んで既知のノードリストにないノードからのメッセージを無視します。 - なし SENSOR、TRACKER、およびTAK_TRACKERロールでのみ許可。CLIENT_MUTEロールとは異なり、すべての再ブロードキャストを禁止。 - コアポート番号のみ TAK、RangeTest、PaxCounterなどの非標準ポート番号からのパケットを無視。NodeInfo、Text、Position、Telemetry、Routingなどの標準ポート番号を持つパケットのみを再ブロードキャスト。 加速度センサー搭載デバイスで本体をダブルタップすると、ボタンのプッシュと同じ動作として扱います。 ユーザーボタンがトリプルクリックされている場合、プライマリチャンネル上の位置を送信します。 @@ -137,7 +117,6 @@ QRコード ユーザー名不明 送信 - このスマートフォンはMeshtasticデバイスとペアリングされていません。デバイスとペアリングしてユーザー名を設定してください。\n\nこのオープンソースアプリケーションはアルファテスト中です。問題を発見した場合はBBSに書き込んでください。 https://github.com/orgs/meshtastic/discussions\n\n詳しくはWEBページをご覧ください。 www.meshtastic.org あなた 分析とクラッシュレポートを許可する。 同意 @@ -145,24 +124,15 @@ 破棄 保存 新しいチャンネルURLを受信しました - Meshtasticは、新規デバイスをBluetooth経由で検出するために位置情報の許可を有効にする必要があります。非使用時は無効にすることができます。 - バグを報告 - バグを報告 - 不具合報告として診断情報を送信しますか?送信した場合は https://github.com/orgs/meshtastic/discussions に検証できる報告を書き込んでください。 報告 - ペアリングが完了しました。サービスを開始します。 - ペアに設定できませんでした。もう一度選択してください。 位置情報が無効なため、メッシュネットワークに位置情報を提供できません。 シェア 新しいノードを見ました:%1$s 切断 デバイスはスリープ状態です - 接続済み: %1$s オンライン IPアドレス ポート: 接続済 - Meshtasticデバイスに接続しました -(%1$s) 現在の接続: Wi-Fi IP: イーサネット IP: @@ -176,11 +146,9 @@ 通知サービス 謝辞 このチャンネルURLは無効なため使用できません。 - この連絡先は無効なので追加できません デバッグ デコードされたペイロード: ログのエクスポート - エクスポートがキャンセルされました %1$d ログをエクスポートしました ログファイルの書き込みに失敗しました:%1$s @@ -200,7 +168,6 @@ すべてのフィルタをクリア カスタムフィルタを追加 プリセットフィルタ - 無視したノードのみを表示 メッシュログを保存 無効にすると、メッシュログをファイルに保存することがスキップされます ログをクリア @@ -288,7 +255,6 @@ 削除 このノードから再びデータを受信するまで、このノードはリストに表示されなくなります。 通知をミュート - 1時間 8時間 1週間 常時 @@ -297,6 +263,7 @@ WiFi認証のQRコードの形式が無効です 前に戻る バッテリー + %1$s ログ ホップ数 情報 @@ -307,13 +274,9 @@ 公開キー暗号化 公開キーが一致しません 新しいノードの通知 - 詳細を見る SN比 - 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。 RSSI - 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。 (屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。 - ノードマップ 位置 管理 リモート管理 @@ -331,10 +294,8 @@ ホップ数 行き %1$d 帰り %2$d 24時間 - 48時間 1週間 2週間 - 4週間 最大 年齢不明 コピー @@ -354,7 +315,6 @@ バッテリー残量低下通知 バッテリー低残量: %1$s バッテリー残量低下通知 (お気に入りノード) - UDP Config 最終受信: %2$s
最終位置: %3$s
バッテリー: %4$s]]>
自分の位置を切り替え ユーザー @@ -429,7 +389,6 @@ モニターのGPIOピン 検出トリガーの種類 INPUT_PULUP モードを使用 - 接続するデバイスを選択 ノースアップ表示 画面反転 表示単位 @@ -456,13 +415,14 @@ I2Sをブザーとして使用 LoRa 帯域 - 周波数オフセット (MHz) リージョン デューティサイクルを上書き 無視リスト (ノード番号を登録) PAファン無効 MQTT を無視 MQTT設定 + 切断 + 接続済 MQTTを有効化 アドレス ユーザー名 @@ -478,7 +438,6 @@ 近隣ノード情報を有効化 更新間隔 (秒) LoRaで送信 - ネットワーク Wi-Fiを有効化 SSID PSK @@ -492,22 +451,10 @@ Paxcounter を有効化 WiFi RSSI閾値(デフォルトは -80) BLE RSSI閾値(デフォルトは -80) - 位置 - 位置情報のブロードキャスト間隔 (秒) - スマートポジションを有効化 - スマートブロードキャストの最小距離(メートル) - スマートブロードキャストの最小間隔 (秒) - 固定された位置情報を使用 緯度 経度 - 高度(メートル) - GPS 更新間隔 (秒) - GPS_RX_PINを再定義 - GPS_TX_PINを再定義 - PIN_GPS_EN を再定義 電源設定 省電力モードを有効化 - 外部電源喪失後の自動シャットダウンまでの待機時間(秒) ADC乗算器のオーバーライド率 バッテリー INA_2XX I2C アドレス レンジテスト設定 @@ -518,7 +465,6 @@ リモートハードウェアを有効化 未定義のPINアクセスを許可 使用可能な端子 - セキュリティ 公開鍵 秘密鍵 管理者キー @@ -584,7 +530,6 @@ 長押しして並び替え ミュート解除 動的 - QRコードをスキャン 連絡先を共有 連絡先をインポート メッセージ不可 @@ -600,7 +545,6 @@ ホストのメトリック ホスト 空きメモリ - ディスクフリー ロード ユーザー文字列 ナビゲートする @@ -632,10 +576,8 @@ 48時間 最後に受信した時間でフィルター: %1$s %1$d dBm - リンクを処理できるアプリケーションがありません。 システム設定 - 切断中... 更新失敗 削除 @@ -656,14 +598,10 @@ すべて Bluetooth Configure Bluetooth Permissions - Meshtasticデバイスに接続しました - Meshtastic メッシュ無線デバイスをスキャンして接続します。 ディスカバリー あなたの近くにあるMeshtasticデバイスを見つけて識別します。 設定 デバイスの設定とチャンネルをワイヤレスで管理します。 - 許可が与えられました - 許可が拒否されました マップスタイルの選択 稼働時間: %1$s トラフィック: TX %1$d / RX %2$d (D: %3$d) @@ -675,17 +613,12 @@ %1$d / %2$d %1$s 給電 - Meshtastic 統計 更新 更新済み ネットレイヤーを追加 - レイヤーを更新 ローカル MBTiles ファイル ローカル MBTiles ファイルを追加する - カスタムタイルプロバイダーのファイル名、URLテンプレート、またはローカルURIが無効です。 - この名前のカスタムタイルプロバイダーが既に存在します。 - MBTilesファイルを内部ストレージにコピーできませんでした。 TAK (ATAK) TAK 設定 チームカラー @@ -718,5 +651,6 @@ トラフィック管理設定 モジュール有効 接続 - 更新 + Meshtastic + 絞り込み diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 7bbe44875..914446a60 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -24,7 +24,6 @@ 미확인 노드 포함 오프라인 노드 숨기기 직접 연결된 노드만 보기 - 자세히 보기 노드 정렬 A-Z 채널 @@ -37,6 +36,7 @@ 확인되지 않음 수락을 기다리는 중 전송 대기 열에 추가됨 + 알 수 없는 수락 됨 루트 없음 수락 거부됨 @@ -65,14 +65,11 @@ 분실 장치의 회수를 돕기 위해 기본 채널에 정기적으로 위치 정보를 전송. TAK PLI 전송을 자동화하고 정기적 전송을 최소화. 모든 다른 모드의 노드들이 패킷을 재전송한 후에만 항상 한 번씩 패킷을 재전송하여, 로컬 클러스터에 추가적인 커버리지를 보장하는 인프라스트럭처 노드입니다. 노드 목록에 표시. - All 관찰된 메시지가 우리 비공개 채널에 있거나, 동일한 LoRa 파라미터를 사용하는 다른 메쉬에서 온 경우 해당 메시지를 재전송합니다. ALL 역할과 동일하게 동작하지만, 패킷 디코딩을 건너뛰고 단순히 재전송만 수행합니다. Repeater 일때 설정가능. 다른 Role에서는 ALL로 동작. 오픈되어 있거나 해독할 수 없는 외부 메시에서 관찰된 메시지를 무시합니다. 로컬 주/보조 채널에서만 메시지를 재브로드캐스트. LOCAL_ONLY와 유사하게 외부 메쉬에서 관찰된 메시지를 무시하지만, 추가적으로 알려진 목록에 없는 노드의 메시지도 무시합니다. - 없음 SENSOR, TRACKER 및 TAK_TRACKER role에서만 허용되며 CLIENT_MUTE role과 마찬가지로 모든 재브로드캐스트를 금지합니다. - 핵심 포트 번호만 허용 TAK, RangeTest, PaxCounter 등과 같은 비표준 포트 번호의 패킷을 무시합니다. NodeInfo, Text, Position, Telemetry 및 Routing과 같은 표준 포트 번호가 있는 패킷만 재브로드캐스트. 가속도계가 있는 장치를 두 번 탭하여 사용자 버튼과 동일한 동작. 장치에서 깜빡이는 LED를 제어합니다. 대부분 장치의 경우 최대 4개의 LED 중 하나를 제어할 수 있지만 충전 상태 LED와 GPS 상태 LED는 제어할 수 없습니다. @@ -85,27 +82,19 @@ QR코드 미확인 유저 보내기 - 아직 스마트폰과 Meshtastic 장치와 연결하지 않았습니다. 장치와 연결하고 사용자 이름을 정하세요. \n\n이 오픈소스 응용 프로그램은 개발 중입니다. 문제가 발견되면 포럼: https://github.com/orgs/meshtastic/discussions 을 통해 알려주세요.\n\n 자세한 정보는 웹페이지 - www.meshtastic.org 를 참조하세요. 수락 취소 저장 새로운 채널 URL 수신 - 버그 보고 - 버그 보고 - 버그를 보고하시겠습니까? 보고 후 Meshtastic 포럼 https://github.com/orgs/meshtastic/discussions 에 당신이 발견한 내용을 게시해주시면 신고 내용과 귀하가 찾은 내용을 일치 시킬 수 있습니다. 보고 - 연결 완료, 서비스를 시작합니다. - 연결 실패, 다시 시도해주세요. 위치 접근 권한 해제, 메시에 위치를 제공할 수 없습니다. 공유 연결 끊김 절전모드 - 연결됨: 중 %1$s 온라인 IP 주소: 포트: 연결됨 - (%1$s)에 연결됨 연결 중 연결되지 않음 연결되었지만, 해당 장치는 절전모드입니다. @@ -215,7 +204,6 @@ 배터리 로그 Hops 수 - %1$d Hops 떨어짐 정보 현재 채널 사용, 올바르게 형성된 TX, RX, 잘못 형성된 RX(일명 노이즈)를 포함. 지난 1시간 동안 전송에 사용된 통신 시간의 백분율. @@ -224,13 +212,9 @@ 공개 키 암호화 공개 키가 일치하지 않습니다 새로운 노드 알림 - 자세히 보기 SNR - 통신에서 원하는 신호의 수준을 배경 잡음의 수준과 비교하여 정량화하는 데 사용되는 신호 대 잡음비 Signal-to-Noise Ratio, SNR는 Meshtastic와 같은 무선 시스템에서 SNR이 높을수록 더 선명한 신호를 나타내어 데이터 전송의 안정성과 품질을 향상시킬 수 있습니다. RSSI - 수신 신호 강도 지표 Received Signal Strength Indicator, RSSI는 안테나가 수신하는 신호의 전력 수준을 측정하는 데 사용되는 지표입니다. RSSI 값이 높을수록 일반적으로 더 강력하고 안정적인 연결을 나타냅니다. (실내공기질) Bosch BME680으로 측정한 상대적 척도 IAQ 값. 범위 0–500. - 노드 지도 위치 최근 위치 업데이트 관리 @@ -249,10 +233,8 @@
Hops towards %1$d Hops back %2$d 24시간 - 48시간 1주 2주 - 4주 최대 수명 확인 되지 않음 복사 @@ -273,7 +255,6 @@ 배터리 부족: %1$s 배터리 부족 알림 (즐겨찾기 노드) 활성화 - UDP 설정 최근 수신: %2$s
최근 위치: %3$s
배터리: %4$s]]>
내 위치 토글 사용자 @@ -348,7 +329,6 @@ 상태 모니터링 GPIO 핀 디텍션 트리거 타입 INPUT_PULLUP 모드 사용 - 장치 중계 모드 노드 정보 발송 주기 나침반 상단을 북쪽으로 고정 @@ -381,7 +361,6 @@ 프리셋 사용 대역폭 Coding rate - 주파수 오프셋 (MHz) 지역 전송 활성화 전송 출력 @@ -391,6 +370,8 @@ PA fan 비활성화됨 MQTT로 부터 수신 무시 MQTT 설정 + 연결 끊김 + 연결됨 MQTT 활성화 서버 주소 사용자명 @@ -406,7 +387,6 @@ 이웃 정보 활성화 업데이트 간격 (초) LoRa로 전송 - 네트워크 활성화 WiFi 활성화 SSID @@ -417,23 +397,13 @@ IPv4 모드 IP 게이트웨이 + DNS 팍스카운터 설정 팍스카운터 활성화 WiFi RSSI 임계값 (기본값 -80) BLE RSSI 임계값 (기본값 -80) - 위치 - 위치 송신 간격 (초) - 스마트 위치 활성화 - 스마트 위치 사용 최소 거리 간격 (m) - 스마트 위치 사용 최소 시간 간격 (초) - 고정 위치 사용 위도 경도 - 고도 (m) - GPS 업데이트 간격 (초) - GPS_RX_PIN 재정의 - GPS_TX_PIN 재정의 - PIN_GPS_EN 재정의 전원 설정 저전력 모드 설정 거리 테스트 설정 @@ -442,7 +412,6 @@ .CSV 파일 저장 (EPS32만 동작) 원격 하드웨어 설정 원격 하드웨어 활성화 - 보안 공개 키 개인 키 Admin 키 @@ -501,7 +470,6 @@ 수동 위치 요청 필요함 누르고 드래그해서 순서 변경 음소거 해제 - QR코드 스캔 연락처 공유 공유된 연락처를 내려받겠습니까? 메시지 제한 @@ -542,8 +510,6 @@ 원격 반응 연결 끊기 - 네트워크 장치를 찾을 수 없습니다. - USB 시리얼 장치를 찾을 수 없습니다. Meshtastic 알 수 없는 고급 @@ -559,7 +525,6 @@ 24 시간 48 시간 - 연결 끊는 중... 업데이트 실패 해제 @@ -573,4 +538,6 @@ 파랑 초록 연결 + Meshtastic + 필터 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 0645b40d7..33f5e4d59 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -20,7 +20,6 @@ Filtras išvalyti įtaisų filtrą Įtraukti nežinomus - Rodyti detales A-Z Kanalas Atstumas @@ -60,32 +59,23 @@ Įgalina automatines TAK PLI transliacijas ir sumažina rutininių transliacijų kiekį. Persiųsti visas žinutes, nesvarbu jos iš Jūsų privataus tinklo ar iš kito tinklo su analogiškais LoRa parametrais. Taip pat kaip ir VISI bet nebando dekoduoti paketų ir juos tiesiog persiunčia. Galima naudoti tik Repeater rolės įtaise. Įjungus bet kokiame kitame įtaise - veiks tiesiog kaip VISI. - Tik žinomi - Nėra Leidžiama tik SENSOR, TRACKER ar TAK_TRACKER rolių įtaisams. Tai užblokuos visas retransliacijas, ne taip kaip CLIENT_MUTE atveju. Kanalo pavadinimas QR kodas Nežinomas vartotojo vardas Siųsti - Su šiuo telefonu dar nėra susietas joks Meshtastic įtaisais. Prašome suporuoti įrenginį ir nustatyti savo vartotojo vardą.\n\nŠi atvirojo kodo programa yra kūrimo stadijoje, jei pastebėsite problemas, prašome pranešti mūsų forume: https://github.com/orgs/meshtastic/discussions\n\nDaugiau informacijos rasite mūsų interneto svetainėje - www.meshtastic.org. Tu Priimti Atšaukti Išsaugoti Gautas naujo kanalo URL - Pranešti apie klaidą - Pranešti apie klaidą - Ar tikrai norite pranešti apie klaidą? Po pranešimo prašome parašyti forume https://github.com/orgs/meshtastic/discussions, kad galėtume suderinti pranešimą su jūsų pastebėjimais. Raportuoti - Susiejimas užbaigtas, paslauga pradedama - Susiejimas nepavyko, prašome pasirinkti iš naujo Vietos prieigos funkcija išjungta, negalima pateikti pozicijos tinklui. Dalintis Atsijungta Įrenginys miega IP adresas: - Prisijungta prie radijo (%1$s) Neprijungtas Prisijungta prie radijo, bet jis yra miego režime Reikalingas programos atnaujinimas @@ -198,10 +188,8 @@ Viešojo rakto šifruotė Viešojo rakto neatitikimas Naujo įtaiso pranešimas - Daugiau info SNR RSSI - Įtaisų žemėlapis Administravimas Nuotolinis administravimas Silpnas @@ -221,15 +209,14 @@
Persiuntimų iki %1$d persiuntimų nuo %2$d 24 val - 48 val 1 sav 2 sav - 4 sav Max Kopijuoti Skambučio simbolis! Raudona Regionas + Atsijungta Viešasis raktas Privatus raktas Baigėsi laikas @@ -250,4 +237,5 @@ Raudona + Filtras diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index 078c0aab9..b6972b6ec 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -20,7 +20,6 @@ Filter wis node filter Include onbekend - Toon details Node sorteeropties A-Z Kanaal @@ -61,12 +60,10 @@ Zend locatie regelmatig als bericht via het standaard kanaal voor zoektocht apparaat. Activeer automatisch zenden TAK PLI en beperk routine zendingen. Infrastructuurknooppunt dat altijd pakketten één keer opnieuw uitzendt, maar pas nadat alle andere modi zijn voltooid, om extra dekking te bieden voor lokale clusters. Zichtbaar in de lijst met knooppunten. - Alles Herzend ontvangen berichten indien ontvangen op eigen privé kanaal of van een ander toestel met dezelfde lora instellingen. Hetzelfde gedrag als ALL maar sla pakketdecodering over en herzendt opnieuw. Alleen beschikbaar in Repeater rol. Het instellen van dit op andere rollen resulteert in ALL gedrag. Negeert waargenomen berichten van open vreemde mazen of die welke niet kunnen decoderen. Alleen heruitzenden bericht op de nodes lokale primaire / secundaire kanalen. Negeert alleen waargenomen berichten van vreemde meshes zoals LOCAL ONLY, maar gaat een stap verder door ook berichten van knooppunten te negeren die nog niet in de bekende lijst van knooppunten staan. - Geen Alleen toegestaan voor SENSOR, TRACKER en TAK_TRACKER rollen, dit zal alle heruitzendingen beperken, niet in tegenstelling tot CLIENT_MUTE rol. Negeert pakketten van niet-standaard portnums, zoals: TAK, RangeTest, PaxCounter, etc. Herzendt alleen pakketten met standaard portnummers: NodeInfo, Text, Positie, Telemetry, en Routing. Behandel een dubbele tik op ondersteunde versnellingsmeters als een knopindruk door de gebruiker. @@ -77,27 +74,19 @@ QR-code Onbekende Gebruikersnaam Verzend - Je hebt nog geen Meshtastic compatibele radio met deze telefoon gekoppeld. Paar alstublieft een apparaat en voer je gebruikersnaam in.\n\nDeze open-source applicatie is in alpha-test, indien je een probleem vaststelt, kan je het posten op onze forum: https://github.com/orgs/meshtastic/discussions\n\nVoor meer informatie bezoek onze web pagina - www.meshtastic.org. Jij Accepteer Annuleer Opslaan Nieuw kanaal URL ontvangen - Rapporteer bug - Rapporteer een bug - Ben je zeker dat je een bug wil rapporteren? Na het doorsturen, graag een post in https://github.com/orgs/meshtastic/discussions zodat we het rapport kunnen toetsen aan hetgeen je ondervond. Rapporteer - Koppeling geslaagd, start service - Koppeling mislukt, selecteer opnieuw Vrijgave positie niet actief, onmogelijk de positie aan het netwerk te geven. Deel Niet verbonden Apparaat in slaapstand - Verbonden: %1$s online IP-adres: Poort: Verbonden - Verbonden met radio (%1$s) Bezig met verbinden Niet verbonden Verbonden met radio in slaapstand @@ -211,13 +200,9 @@ Publieke sleutel encryptie Publieke sleutel komt niet overeen Nieuwe node meldingen - Meer details 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 @@ -236,10 +221,8 @@
Sprongen richting %1$d Springt terug %2$d 24U - 48U 1W 2W - 4W Maximum Onbekende Leeftijd Kopieer @@ -257,7 +240,6 @@ Ik weet waar ik mee bezig ben. Batterij bijna leeg Batterij bijna leeg: %1$s - UDP Configuratie Wissel mijn positie Gebruiker Kanalen @@ -311,7 +293,6 @@ Weergavenaam GPIO pin om te monitoren Detectie trigger type - Apparaat Kompas Noorden bovenaan Scherm omdraaien Geef eenheden weer @@ -323,12 +304,13 @@ Beltoon LoRa Bandbreedte - Frequentie offset (MHz) Regio Overschrijf Duty Cycle Inkomende negeren Negeer MQTT MQTT Configuratie + Niet verbonden + Verbonden MQTT ingeschakeld Adres Gebruikersnaam @@ -340,7 +322,6 @@ Kaartrapportage Update-interval (seconden) Zend over LoRa - Netwerk Wifi ingeschakeld SSID PSK @@ -354,19 +335,13 @@ Paxcounter ingeschakeld WiFi RSSI drempelwaarde (standaard -80) BLE RSSI drempelwaarde (standaard -80) - Positie - Slimme positie ingeschakeld - Gebruik vaste positie Breedtegraad Lengtegraad - Hoogte in meters - GPS update interval (seconden) Energie configuratie Energiebesparingsmodus inschakelen Externe hardwareconfiguratie Externe hardware ingeschakeld Beschikbare pinnen - Beveiliging Publieke sleutel Privésleutel Admin Sleutel @@ -410,7 +385,6 @@ Handmatige positieaanvraag vereist Dempen opheffen Dynamisch - Scan QR-code Contactpersoon delen Gedeelde contactpersoon importeren? Niet berichtbaar @@ -430,7 +404,6 @@ 24 Uur 48 Uur - Verbinding verbreken... Bijwerken mislukt Terugzetten @@ -442,4 +415,5 @@ Blauw Groen Verbinding maken + Filter diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index 5d48a951c..cd00c43e2 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -20,7 +20,6 @@ Filter tøm nodefilter Inkluder ukjent - Vis detaljer A-Å Kanal Distanse @@ -63,7 +62,6 @@ Samme atferd som alle andre, men hopper over pakkedekoding og sender dem ganske enkelt på nytt. Kun tilgjengelig i Repeater-rollen. Å sette dette på andre roller vil resultere i ALL oppførsel. Ignorerer observerte meldinger fra fremmede mesh'er som er åpne eller de som ikke kan dekrypteres. Sender kun meldingen på nytt på nodene lokale primære / sekundære kanaler. Ignorer observerte meldinger fra utenlandske mesher som KUN LOKALE men tar det steget videre, ved å også ignorere meldinger fra noder som ikke allerede er i nodens kjente liste. - Ingen Bare tillatt for SENSOR, TRACKER og TAK_TRACKER roller, så vil dette hindre alle rekringkastinger, ikke i motsetning til CLIENT_MUTE rollen. Ignorerer pakker fra ikke-standard portnumre som: TAK, RangeTest, PaxCounter, etc. Kringkaster kun pakker med standard portnum: NodeInfo, Text, Position, Telemetrær og Ruting. Behandle dobbeltrykk på støttede akselerometre som brukerknappetrykk. @@ -74,24 +72,17 @@ QR kode Ukjent Brukernavn Send - Du har ikke paret en Meshtastic kompatibel radio med denne telefonen. Vennligst parr en enhet, og sett ditt brukernavn.\n\nDenne åpen kildekode applikasjonen er i alfa-testing, Hvis du finner problemer, vennligst post på vårt forum: https://github.com/orgs/meshtastic/discussions\n\nFor mer informasjon, se vår nettside - www.meshtastic.org. Deg Godta Avbryt Lagre Ny kanal URL mottatt - Rapporter Feil - Rapporter en feil - Er du sikker på at du vil rapportere en feil? Etter rapportering, vennligst posti https://github.com/orgs/meshtastic/discussions så vi kan matche rapporten med hva du fant. Rapport - Paring fullført, starter tjeneste - Paring feilet, vennligst velg igjen Lokasjonstilgang er slått av,kan ikke gi posisjon til mesh. Del Frakoblet Enhet sover IP-adresse: - Tilkoblet til radio (%1$s) Ikke tilkoblet Tilkoblet radio, men den sover Applikasjon for gammel @@ -202,13 +193,9 @@ Offentlig-nøkkel kryptering Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere. Varsel om nye noder - Flere detaljer 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 @@ -226,14 +213,13 @@
Hopp mot %1$d Hopper tilbake %2$d 24t - 48t 1U 2U - 4U Maks Kopier Varsel, bjellekarakter! Region + Frakoblet Offentlig nøkkel Privat nøkkel Tidsavbrudd @@ -252,4 +238,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 14e05f0b2..7c9b3433b 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -26,7 +26,6 @@ Schowaj nieaktywne węzły Pokaż tylko bezpośrednie węzły Przeglądasz ignorowane węzły,\nNaciśnij aby powrócić do listy węzłów. - Pokaż szczegóły Sortuj według Opcje sortowania węzłów Nazwa @@ -41,6 +40,7 @@ Nierozpoznany Oczekiwanie na potwierdzenie Zakolejkowane do wysłania + Nieznany Potwierdzone Brak trasy Otrzymano negatywne potwierdzenie @@ -58,34 +58,22 @@ Nieprawidłowy klucz sesji Nieautoryzowany klucz publiczny Nie wysłano PKI, brak klucza publicznego - Klient Urządzenie samodzielne lub sparowane z aplikacją. - Klient pasywny Wyciszenie klienta - To samo, co klient, z wyjątkiem pakietów, które nie przeskakują przez ten węzeł, nie przyczynia się do routingu pakietów dla siatki. - Router Węzeł infrastruktury do rozszerzenia zasięgu sieci poprzez przekazywanie pakietów. Widoczny na liście węzłów. - Router Klienta Połączenie zarówno trybu ROUTER, jak i CLIENT. Nie dla urządzeń przenośnych. - Repeater Węzeł infrastruktury do rozszerzenia zasięgu sieci poprzez przekazywanie pakietów z minimalnym narzutem. Niewidoczny na liście węzłów. Tracker - Do użytku z urządzeniami przeznaczonymi jako śledzenie GPS. Pakiety pozycyjne wysyłane z tego urządzenia będą miały wyższy priorytet, z nadawaniem pozycji co dwie minuty. Inteligentna transmisja pozycji będzie domyślnie wyłączona. - Czujnik Nadaje priorytetowo pakiety telemetryczne. - TAK Zoptymalizowany pod kątem komunikacji systemowej ATAK, redukuje nadmiarowe transmisje. Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption. Nadaje regularnie lokalizację jako wiadomości do głównego kanału, aby pomóc w odzyskaniu urządzenia. Umożliwia automatyczne transmisje TAK PLI i zmniejsza liczbę nadmiarowych transmisji. Węzeł infrastruktury, który zawsze powtarza pakiety raz, ale tylko po wszystkich innych trybach, zapewniając dodatkowe pokrycie lokalnych klastrów. Widoczne na liście węzłów. - Wszystkie Przekazuje ponownie każdy odebrany pakiet, niezależnie od tego, czy został wysłany na nasz prywatny kanał, czy z innej sieci Mesh o tych samych parametrach radia. - Wszystkie, pomiń dekodowanie To samo zachowanie co ALL, ale pomija dekodowanie pakietów i po prostu je retransmituje. Dostępne tylko w roli REPEATER. Ustawienie tego w innych rolach spowoduje zachowanie jak ALL. - Tylko lokalne Ignoruje odebrane pakiety z obcych sieci Mesh, które są otwarte lub których nie można odszyfrować. Retransmituje wiadomość tylko na lokalnych kanałach primary / secondary. - Tylko znane Ignoruje odebrane pakiety z obcych sieci, podobnie jak LOCAL_ONLY, ale idzie o krok dalej, ignorując również pakiety z węzłów, które nie znajdują się jeszcze na liście znanych węzłów. - Brak Dozwolone wyłącznie dla ról SENSOR, TRACKER i TAK_TRACKER. Spowoduje to zablokowanie wszystkich retransmisji, podobnie jak rola CLIENT_MUTE. Ignoruje niestandardowe pakiety (non-standard portnums) takie jak: TAK, RangeTest, PaxCounter, itp. Przekazuje dalej jedynie standardowe pakiety (standard portnums): NodeInfo, Text, Position, Telemetry oraz Routing. Traktuj podwójne dotknięcie na obsługiwanych akcelerometrach jako naciśnięcie przycisku użytkownika. @@ -146,7 +134,6 @@ Kod QR Nieznana nazwa użytkownika Wyślij - Nie sparowałeś jeszcze urządzenia Meshtastic z tym telefonem. Proszę sparować urządzenie i ustawić swoją nazwę użytkownika.\n\nTa aplikacja open-source jest w fazie rozwoju, jeśli znajdziesz problemy, napisz na naszym forum: https://github.com/orgs/meshtastic/discussions\n\nWięcej informacji znajdziesz na naszej stronie internetowej - www.meshtastic.org. Ty Zezwalaj na analizę i raportowanie awarii. Akceptuj @@ -154,23 +141,15 @@ Odrzuć Zapisz Otrzymano nowy URL kanału - Meshtastic potrzebuje permisji na użycie lokalizacji w celu wykrywania nowych urządzeń poprzez Bluetooth. Możesz wyłączyć, gdy nie jest w użyciu. - Zgłoś błąd - Zgłoś błąd - Czy na pewno chcesz zgłosić błąd? Po zgłoszeniu opublikuj post na https://github.com/orgs/meshtastic/discussions, abyśmy mogli dopasować zgłoszenie do tego, co znalazłeś. Zgłoś - Parowanie zakończone, uruchamianie - Parowanie nie powiodło się, wybierz ponownie Brak dostępu do lokalizacji, nie można udostępnić pozycji w sieci mesh. Udostępnij Wykryto nowy węzeł: %1$s Rozłączono Urządzenie uśpione - Połączono: %1$s online Adres IP: Port: Połączony - Połączono z urządzeniem (%1$s) Bieżące połączenia: Wifi IP: Ethernet IP: @@ -184,14 +163,11 @@ Powiadomienia o usługach Potwierdzenia Ten adres URL kanału jest nieprawidłowy i nie można go użyć - Ten kontakt jest nieprawidłowy i nie można go dodać Panel debugowania Zdekodowana zawartość: Eksportuj logi - Eksportowanie anulowane %1$d Wyeksportowano logi Nie można zapisać pliku logów: %1$s - Brak logów do eksportu %1$d godzina %1$d godzin @@ -215,7 +191,6 @@ Wyczyść wszystkie filtry Dodaj niestandardowy filtr Wstępnie ustawione filtry - Pokaż tylko ignorowane węzły Przechowuj logi sieci Wyłącz, aby pominąć zapisywanie logów na dysku Wyczyść logi @@ -248,6 +223,7 @@ Ciemny Domyślne ustawienie systemowe Wybierz motyw + Standardowy Podaj lokalizację telefonu do sieci Usunąć wiadomość? @@ -273,9 +249,7 @@ Wyłącz Wyłączenie nie jest obsługiwane w tym urządzeniu ⚠️ Spowoduje to WYŁĄCZENIE węzła. Do ponownego włączenia węzła, konieczna będzie fizyczna interakcja. - ⚠️ Jest to węzeł infrastruktury krytycznej. Wpisz nazwę węzła, aby potwierdzić: Węzeł: %1$s - Typ: %1$s Restart Pokaż trasę Wprowadzenie @@ -287,9 +261,7 @@ Wyślij natychmiast Pokaż menu szybkiego wyboru Ukryj menu szybkiego wyboru - Pokaż szybki czat Ustawienia fabryczne - Bluetooth jest wyłączony. Proszę, włącz go w ustawieniach twojego urządzenia. Otwórz ustawienia Wersja oprogramowania: %1$s Meshtastic potrzebuje uprawnienia \"Urządzenia w pobliżu\" w celu znalezienia i połączenia się z urządzeniem poprzez Bluetooth. Możesz wyłączyć, gdy nie jest używane. @@ -297,6 +269,7 @@ Zresetuj NodeDB Dostarczono Błąd + Nieznany błąd Zignoruj Usuń z listy ignorowanych Dodać '%1$s' do listy ignorowanych? @@ -331,14 +304,12 @@ Usuń Węzeł będzie usunięty z listy dopóki nie otrzymasz ponownie danych od niego. Wycisz powiadomienia - 1 godzina 8 godzin 1 tydzień Na zawsze Obecnie: Zawsze wyciszony Nie wyciszony - Status wyciszenia Wyciszyć powiadomienia dla '%1$s'? Wyłączyć wyciszenie powiadomień dla '%1$s'? Zastąp @@ -348,7 +319,6 @@ Bateria Rejestry zdarzeń (logs) Skoków - Skoków: %1$d Informacja Wykorzystanie dla bieżącego kanału, w tym prawidłowego TX/RX oraz zniekształconego RX (czyli szumu). Procent czasu wykorzystanego do transmisji w ciągu ostatniej godziny. @@ -362,14 +332,10 @@ Klucz publiczny nie pasuje do zapisanego klucza. Możesz usunąć węzeł i pozwolić mu na ponowną wymianę kluczy, ale może to oznaczać poważniejszy problem z bezpieczeństwem. Skontaktuj się z użytkownikiem przez inny zaufany kanał, żeby sprawdzić, czy zmiana klucza była spowodowana przywróceniem ustawień fabrycznych lub innym celowym działaniem. Informacje o użytkowniku Powiadomienia o nowych węzłach - Więcej… 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 @@ -397,14 +363,12 @@ Pokaż na mapie Pokazywanie %1$d/%2$d węzłów Czas trwania: %1$s s - %1$s - %2$s Trasa do miejsca docelowego:\n\n Trasa do nas:\n\n + Brak odpowiedzi 24H - 48H 1W 2W - 4W Maks. Unknown Age Kopiuj @@ -428,8 +392,6 @@ Niski poziom baterii: %1$s Powiadomienia o niskim poziomie baterii (ulubione węzły) Włączony - Transmisja UDP - Ustawienia UDP Ostatnio słyszany: %2$s
Ostatnia pozycja: %3$s
Bateria: %4$s]]>
Pokaż moją pozycję Zorientuj na północ @@ -488,7 +450,6 @@ Przyjazna nazwa Pin GPIO do monitorowania Użyj trybu INPUT_PULLUP - Urządzenie Rola urządzenia Przycisk GPIO Buzzer GPIO @@ -535,6 +496,8 @@ Zignoruj MQTT Ok dla MQTT Konfiguracja MQTT + Rozłączono + Połączony Włącz MQTT Adres Nazwa użytkownika @@ -549,7 +512,6 @@ Włącz informacje o sąsiedzie Częstotliwość aktualizacji (w sekundach) Nadaj przez LoRa - Sieć Ustawienia WiFi Włączony WiFi włączone @@ -562,18 +524,14 @@ Tryb IPv4 IP Brama domyślna + DNS Próg WiFi RSSI (domyślnie: -80) - Pozycjonowanie - Sprytne pozycjonowanie - Użyj stałego położenia Szerokość geograficzna - Wysokość (metry) Flagi położenia Konfiguracja zarządzania energią Włącz tryb oszczędzania energii Konfiguracja testu zasięgu Dostępne piny - Bezpieczeństwo Klucze administratora Klucz publiczny Klucz prywatny @@ -618,19 +576,16 @@ Prędkość Podstawowy Wtórny - Skanuj kod QR Notatki Dodaj prywatną notatkę Nie przyjmuje wiadomości Niemonitorowany lub infrastruktura Import - Informacje o sąsiadach (2.7.15+) Żądanie telemetrii Metryka urządzenia Metryki środowiskowe Metryki jakości powietrza Metryki zasilania - Statystyki lokalne Statystyki hosta Metadane Oprogramowanie @@ -665,8 +620,6 @@ Wyczyść bazę węzłów Wyczyść węzły, które są starsze niż %1$d dni Wyczyść tylko nieznane węzły - Wyczyść węzły z małą ilością lub bez interakcji - Wyczyść ignorowane węzły Wyczyść teraz Usuniesz %1$d węzłów z bazy danych. Tej akcji nie można cofnąć. Zielona kłódka oznacza, że kanał jest bezpiecznie szyfrowany za pomocą klucza AES 128 lub 256 bitowego. @@ -682,11 +635,8 @@ Bezpieczeństwo kanału Znaczenie bezpieczeństwa kanałów Zamknij - Zapomnij połączenie - Czy na pewno zapomnieć to połączenie? Usunąć wiadomość? Wiadomość - Sparowane urządzenia Połączone urządzenia Pobierz Obecnie zainstalowana wersja @@ -704,15 +654,11 @@ ustawienia Alerty krytyczne Dalej - Przyznaj uprawnienia - Łączenie z urządzeniem Normalna Satelita Terenowa Hybrydowy Zarządzaj warstwami map - Warstwy map - Dodaj warstwę Ukryj warstwę Pokaż warstwę Usuń warstwę @@ -730,7 +676,6 @@ Ustawienia systemowe Statystyki niedostępne Dowiedz się więcej - Urządzenia USB Aktualizacja oprogramowania Sprawdzanie aktualizacji... @@ -743,7 +688,6 @@ Aktualizacja zakończona sukcesem! Wykonano Uruchamianie DFU... - Rozłączanie... Brak podłączonych urządzeń Aktualizacja nie udała się Nie zamykaj aplikacji. @@ -759,15 +703,10 @@ Nie można pobrać pliku oprogramowania. Aktualizacja przez USB nie powiodła się Aktualizacja OTA nie powiodła się: %1$s - Wgrywanie firmware... Oczekiwanie na ponowne uruchomienie urządzenia w trybie OTA... Łączenie z urządzeniem (próba %1$d/%2$d)... - Sprawdzanie wersji urządzenia... Uruchamianie aktualizacji OTA... Wgrywanie firmware... - Ponowne uruchamianie urządzenia... - Aktualizacja oprogramowania - Status aktualizacji oprogramowania Kasowanie... Wstecz Nieustawiony @@ -792,7 +731,6 @@ Szacowany obszar: nieznana dokładność Oznacz jako przeczytane Teraz - Dodaj kanały Ładowanie Filtry wiadomości @@ -808,7 +746,8 @@ Niebieski Zielony Moduł Włączony - Brak podłączonych urządzeń 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 48e2e75d8..ac97b091c 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -24,7 +24,6 @@ Ocultar nós offline Mostrar apenas nós diretos Você está vendo nós ignorados,\nPressione para retornar à lista de nós. - Mostrar detalhes Opções de ordenação do nó A-Z Canal @@ -37,6 +36,7 @@ Desconhecido Esperando para ser reconhecido Programado para envio + Desconhecido Reconhecido Sem rota Recebi uma negativa de reconhecimento @@ -69,7 +69,6 @@ O mesmo que o comportamento de TODOS, mas ignora a decodificação de pacotes e simplesmente os retransmite. Apenas disponível no papel de Repetidor. Configurar isso em qualquer outra função resultará em comportamento como TODOS. Ignora mensagens observadas de malhas estrangeiras que estão abertas ou aquelas que não pode descriptografar. Apenas retransmite mensagem nos nós de canais primários / secundários. Ignora mensagens observadas de malhas estrangeiras como APENAS LOCAL, e vai ainda mais longe ignorando também mensagens de nós que não estão na lista conhecida do nó. - Nenhum Somente permitido para os papéis SENSOR, TRACKER e TAK_TRACKER, isso irá inibir todas as retransmissões, como do papel CLIENT_MUTE. Ignora pacotes de portnums não padrão como: TAK, RangeTest, PaxCounter, etc. Apenas retransmite pacotes com portnums padrão: NodeInfo, Text, Position, Telemetry, and Routing. Tratar toque duplo nos acelerômetros suportados enquanto um botão pressionado pelo usuário. @@ -80,29 +79,20 @@ Código QR Nome desconhecido Enviar - Você ainda não pareou um rádio compatível ao Meshtastic com este smartphone. Por favor pareie um dispositivo e configure seu nome de usuário.\n\nEste aplicativo open source está em desenvolvimento, caso encontre algum problema por favor publique em nosso fórum: https://github.com/orgs/meshtastic/discussions\n\nPara mais informações acesse nossa página: www.meshtastic.org. Você Aceitar Cancelar Salvar Novo link de canal recebido - Meshtastic precisa de permissões de localização ativadas para encontrar novos dispositivos via Bluetooth. Você pode desativar quando não estiver usando. - Informar Bug - Informar um bug - Tem certeza que deseja informar um erro? Após o envio, por favor envie uma mensagem em https://github.com/orgs/meshtastic/discussions para podermos comparar o relatório com o problema encontrado. Informar - Pareamento concluído, iniciando serviço - Pareamento falhou, favor selecionar novamente Localização desativada, não será possível informar sua posição. Compartilhar Novo Nó Visto: %1$s Desconectado Dispositivo em suspensão (sleep) - Conectado: %1$s ligado(s) Endereço IP: Porta: Conectado - Conectado ao rádio (%1$s) Não conectado Conectado ao rádio, mas ele está em suspensão (sleep) Atualização do aplicativo necessária @@ -181,7 +171,6 @@ Mostrar menu de chat rápido Ocultar menu de chat rápido Redefinição de fábrica - O Bluetooth está desativado. Por favor, ative-o nas configurações do seu dispositivo. Abrir configurações Versão do firmware: %1$s Meshtastic precisa das permissões de \"Dispositivos próximos\" habilitadas para localizar e conectar a dispositivos via Bluetooth. Você pode desativar quando não estiver em uso. @@ -232,7 +221,6 @@ Bateria Logs Qtd de saltos - Distância em Saltos: %1$d Informação Utilização para o canal atual, incluindo TX bem formado, RX e RX mal formado (conhecido como ruído). Percentagem do tempo de ar utilizado na última hora para transmissões. @@ -241,13 +229,9 @@ Criptografia de Chave Pública Chave pública não confere Novas notificações de nó - Mais detalhes 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 @@ -267,10 +251,8 @@
Salto em direção a %1$d Saltos de volta %2$d 24H - 48H 1S 2S - 4S Máx. Idade Desconhecida Copiar @@ -290,7 +272,6 @@ Notificações de bateria fraca Bateria fraca: %1$s Notificações de bateria fraca (nós favoritos) - Configuração UDP Última vez: %2$s
Última posição: %3$s
Bateria: %4$s]]>
Habilitar minha posição Usuário @@ -366,7 +347,6 @@ Pino GPIO para monitorar Tipo de gatilho de deteção Usar o modo INPUT_PULLUP - Dispositivo Norte da bússola no topo Inverter tela Unidades de exibição @@ -393,13 +373,14 @@ Usar I2S como campainha LoRa Largura da banda - Deslocamento da frequência (MHz) Região Ignorar ciclo de trabalho Ignorar entrada Ventilador do PA desativado Ignorar MQTT Configurações MQTT + Desconectado + Conectado MQTT habilitado Endereço Nome de usuário @@ -415,7 +396,6 @@ Informações do Vizinho ativado Intervalo de atualização (segundos) Transmitir por LoRa - Rede Wi-Fi ativado SSID PSK @@ -429,23 +409,11 @@ Contador de Pessoas ativado Limite de RSSI do Wi-Fi (o padrão é -80) Limite de RSSI BLE (o padrão é -80) - Posição - Intervalo de transmissão de posição (segundos) - Posição inteligente ativada - Distância mínima da transmissão inteligente (metros) - Intervalo mínimo da transmissão inteligente (segundos) - Usar posição fixa Latitude Longitude - Altitude (metros) Definir a partir da localização atual do telefone - Intervalo de atualização do GPS (segundos) - Redefinir GPS_RX_PIN - Redefinir GPS_TX_PIN - Redefinir PIN_GPS_EN Configuração de Energia Ativar modo de economia de energia - Espera para desligar ao passar para bateria (segundos) Alterar proporção do multiplicador ADC Endereço I2C da bateria INA_2XX Configuração de Teste de Distância @@ -456,7 +424,6 @@ Hardware Remoto ativado Permitir acesso indefinido ao pino Pinos disponíveis - Segurança Chave Publica Chave Privada Chave do Administrador @@ -525,7 +492,6 @@ Pressione e arraste para reordenar Desmutar Dinâmico - Escanear Código QR Compartilhar Contato Importar contato compartilhado? Impossível enviar mensagens @@ -541,7 +507,6 @@ Métricas do Host Host Memória Livre - Armazenamento Livre Carregar String de Usuário Navegar Em @@ -576,8 +541,6 @@ Remoto Reagir Desconectar - Nenhum dispositivo de rede encontrado. - Nenhum dispositivo USB Serial encontrado. Rolar para o final Meshtastic Status de segurança @@ -592,8 +555,6 @@ Limpar Banco de Dados de Nó Limpar nós vistos há mais de %1$d dias Limpar somente nós desconhecidos - Limpar nós com baixa/nenhuma interação - Limpar nós ignorados Limpar Agora Isto irá remover %1$d nós de seu banco de dados. Esta ação não pode ser desfeita. Um cadeado verde significa que o canal é criptografado com uma chave AES de 128 ou 256 bits. @@ -612,7 +573,6 @@ Mostrar Todos os Significados Exibir Status Atual Ignorar - Tem certeza que deseja excluir este nó? Respondendo a %1$s Cancelar resposta Excluir Mensagens? @@ -668,17 +628,13 @@ Configurar Alertas Críticos Meshtastic usa notificações para mantê-lo atualizado sobre novas mensagens e outros eventos importantes. Você pode atualizar suas permissões de notificação a qualquer momento nas configurações. Avançar - Conceder Permissões %1$d nós na fila para exclusão: Cuidado: Isso irá remover nós dos bancos de dados do aplicativo e do dispositivo.\nSeleções são somadas. - Conectando ao dispositivo Normal Satélite Terreno Híbrido Gerenciar Camadas do Mapa - Camadas do Mapa - Adicionar Camada Ocultar Camada Mostrar Camada Remover Camada @@ -708,4 +664,6 @@ Azul Verde Concluído + Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index b63ae1a02..a00bce554 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -25,7 +25,6 @@ Ocultar nós offline Mostrar apenas nós diretos Está a visualizar nós ignorados,\nPrima para regressar à lista de nós. - Mostrar detalhes Ordenar por Opções de ordenação de nodes A-Z @@ -55,34 +54,22 @@ Chave pública desconhecida Chave de sessão inválida Public Key não autorizada - Cliente Ligado por app, ou dispositivo autónomo de mensagens. - Cliente silenciado Dispositivo que não encaminha mensagens de outros dispositivos. - Roteador Node de infraestrutura que retransmite mensagens para estender a cobertura da rede (Router). Visível na lista de nodes. - Cliente Roteador Combinação de ROUTER e CLIENT. Não indicado para dispositivos móveis. - Repetidor Node de infraestrutura para estender a cobertura da rede retransmitindo mensagens com overhead mínimo. Não visível na lista de nodes. - Monitor Transmite dados de posições GPS como prioridade. - Sensor Transmite dados de telemetria como prioridade. - TAK — ‘Kit’ de Consciencialização da Equipa Otimizado para comunicação do sistema ATAK, reduz as transmissões de rotina. - Cliente oculto Dispositivo que só transmite quando necessário para economizar energia ou anonimidade. - Perdidos e Achados Transmite regularmente a localização como uma mensagem para o canal default, para auxiliar na recuperação do dispositivo. Permite transmissões automáticas do TAK PLI e reduz as transmissões de rotina. Node de infraestrutura que vai sempre retransmitir dados uma vez, mas apenas após todos os outros modos, garantindo cobertura adicional para grupos locais. Visível na lista de nós. - Tudo Se estiver no nosso canal privado ou de outra rede com os mesmos parâmetros LoRa, retransmite qualquer mensagem observada. Modo indêntico ao ALL, mas apenas retransmite os dados sem os descodificar. Apenas disponível em modo Repeater. Esta opção em qualquer outro modo resulta em comportamento igual ao ALL. Ignora mensagens observadas de malhas estrangeiras que estão abertas ou aquelas que não pode desencriptar. Apenas retransmite mensagem nos canais primários / secundários locais. Ignora mensagens observadas de malhas estrangeiras, como APENAS LOCAL, mas leva mais longe ignorando também mensagens de nodes que não já estão na lista conhecida do node. - Nenhum Permitido apenas para SENSOR, TRACKER e TAK_TRACKER, isto irá desativar todas as retransmissões, como o papel CLIENT_MUTE. Ignora pacotes de portas não padrão, tais como: TAK, RangeTest, PaxCounter, etc. Apenas retransmite pacotes com portas padrão: NodeInfo, Texto, Posição, Telemetria e Roteamento. Tratar toques duplos em acelerómetros suportados como pressionar um botão. @@ -105,27 +92,19 @@ Código QR Nome de utilizador desconhecido Enviar - Ainda não emparelhou um rádio compatível com Meshtastic com este telefone. Emparelhe um dispositivo e defina seu nome de usuário.\n\nEste aplicativo de código aberto está em teste alfa, se encontrar problemas, por favor reporte através do nosso forum em: https://github.com/orgs/meshtastic/discussions\n\nPara obter mais informações, consulte a nossa página web - www.meshtastic.org. Você Aceitar Cancelar Salvar Novo Link Recebido do Canal - Reportar Bug - Reportar a bug - Tem certeza de que deseja reportar um bug? Após o relatório, comunique também em https://github.com/orgs/meshtastic/discussions para que possamos comparar o relatório com o que encontrou. Reportar - Emparelhamento concluído, a iniciar serviço - Emparelhamento falhou, por favor escolha novamente Acesso à localização desativado, não é possível fornecer a localização na mesh. Partilha Desconectado Dispositivo a dormir - Ligado: %1$s “online” Endereço IP: Porta: Ligado - Ligado ao rádio (%1$s) A ligar Desligado Ligado ao rádio, mas está a dormir @@ -239,13 +218,9 @@ Criptografia de chave pública Incompatibilidade de chave pública Notificações de novos nodes - Mais detalhes 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 @@ -264,10 +239,8 @@
Saltos em direção a %1$d Saltos de regresso %2$d 24h - 48h 1sem 2sem - 4sem Máximo Idade desconhecida Copiar @@ -287,7 +260,6 @@ Notificação de bateria fraca Bateria fraca: %1$s Notificações de bateria fraca (nodes favoritos) - Configuração UDP Ouvido última vez: %2$s
Última posição: %3$s
Bateria: %4$s]]>
Utilizador Canal @@ -360,7 +332,6 @@ Pin GPIO para monitorizar Tipo de gatilho de deteção Usar o modo INPUT_PULLUP - Dispositivo Norte da bússola no topo Inverter ecrã Unidade de visualização @@ -387,12 +358,13 @@ Usar I2S como buzzer LoRa Largura de banda - Compensação de frequência (MHz) Região Ignorar ciclo de trabalho Ignorar entrada Ignorar MQTT Configuração MQTT + Desconectado + Ligado MQTT ativo Endereço Utilizador @@ -408,7 +380,6 @@ Enviar informações de vizinhos Intervalo de atualização (segundos) Enviar por LoRa - Rede WiFi ligado SSID PSK @@ -422,22 +393,10 @@ Ativar contador de pessoas Limite de RSSI do Wi-Fi (o padrão é -80) Limite de RSSI BLE (o padrão é -80) - Posição - Intervalo de difusão da posição (segundos) - Ativar posição inteligente - Distância mínima de difusão inteligente (metros) - Distância mínima de difusão inteligente (segundos) - Utilizar posição fixa Latitude Longitude - Altitude (metros) - Intervalo de atualização GPS (segundos) - Definir GPS_RX_PIN - Definir GPS_TX_PIN - Definir PIN_GPS_EN Configuração de Energia Ativar modo de poupança de energia - Espera para desligar ao passar para bateria (segundos) Alterar rácio do multiplicador ADC Endereço I2C da bateria INA_2XX Configuração de Teste de Alcance @@ -447,7 +406,6 @@ Hardware Remoto ativado Permitir acesso indefinido ao pin Pins disponíveis - Segurança Chave pública Chave privada Chave do Administrador @@ -509,7 +467,6 @@ Pressionar e arrastar para reordenar Tirar mute Dinâmico - Ler código QR Partilhar Contacto Importar contacto partilhado? Impossível enviar mensagens @@ -545,7 +502,6 @@ 24 Horas 48 Horas - A desligar... Atualização falhou Não Definido @@ -558,4 +514,6 @@ Azul Verde Ligar + Nome do nó de alternativo + Filtrar diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 5a7dfc08e..f9787ba93 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -26,7 +26,6 @@ Ascunde nodurile offline Afișați doar nodurile directe Vizualizați nodurile ignorate,\nApăsați pentru a reveni la lista de noduri. - Afișare detalii Sortare după Opțiuni sortare noduri A-Z @@ -36,11 +35,15 @@ Ultima recepție via MQTT via MQTT + Intern după favorite Arată doar nodurile ignorate + Exclude MQTT Nerecunoscut În așteptarea confirmării În coadă pentru trimitere + Livrat la Mesh + Necunoscut Rutare prin lanțul SF++… Confirmat pe lanțul SF++ Confirmat @@ -60,43 +63,24 @@ Cheie de sesiune incorectă Cheie publică neautorizată Trimiterea PKI nu a reușit, nici o cheie publică - Client Dispozitiv de mesagerie conectat la aplicație sau independent. - Client mut Dispozitiv care nu redirecționează pachetele de la alte dispozitive. - Client bază Tratează pachetele provenite de la sau destinate nodurilor favorite ca ROUTER_LATE, iar toate celelalte pachete ca CLIENT. - Ruter Nod de infrastructură pentru extinderea acoperirii rețelei prin retransmiterea mesajelor. Vizibil în lista de noduri. - Ruter client Combinație între ROUTER și CLIENT. Nu este compatibil cu dispozitivele mobile. - Releu Nod de infrastructură pentru extinderea acoperirii rețelei prin retransmiterea mesajelor cu un consum suplimentar minim. Nu este vizibil în lista de noduri. - Tracker Transmite de poziție GPS ca prioritate. - Senzor Transmite pachete telemetrice ca prioritate. - TAK Optimizat pentru comunicarea de sistem ATAK, reduce emisiunile de rutină. - Client ascuns Dispozitiv care transmite numai atunci când este necesar pentru a asigura discreția sau economisirea energiei. - Pierdut și găsit Transmite locația ca mesaj către canalul implicit în mod regulat pentru a ajuta la recuperarea dispozitivului. - Tracker TAK Activează transmisiile TAK PLI automate și reduce transmisiile de rutină. - Ruter cu întârziere Nod de infrastructură care retransmite întotdeauna pachetele o singură dată, dar numai după toate celelalte moduri, asigurând acoperire suplimentară pentru clusterele locale. Vizibil în lista de noduri. - Toate Retransmite orice mesaj observat, dacă acesta se afla pe canalul nostru privat sau provine de la o altă rețea cu aceiași parametri LoRa. - Toate, emite decodarea Același comportament ca „Toate”, dar omite decodarea pachetelor și le retransmite direct. Disponibil numai în rolul Releu. Setarea acestei opțiuni pentru orice alt rol va avea ca rezultat comportamentul „Toate” - Numai local Ignoră mesajele observate provenite de la rețele străine deschise sau pe care nu le poate decripta. Retransmite mesajele numai pe canalele locale primare/secundare ale nodurilor. - Numai cunoscute Ignoră mesajele observate din rețele străine, cum ar fi „Numai local”, dar merge mai departe, ignorând și mesajele de la noduri care nu se află deja în lista cunoscută a nodului. - Niciunul Permis numai pentru rolurile SENSOR, TRACKER și TAK_TRACKER, aceasta va inhiba toate retransmisiile, similar rolului CLIENT_MUTE. - Doar numere de port standard Ignoră pachetele provenite de la numere de port non-standard, cum ar fi: TAK, RangeTest, PaxCounter etc. Retransmite numai pachetele cu numere de port standard: NodeInfo, Text, Position, Telemetry și Routing. Tratează o apăsare dublă pe accelerometrele compatibile ca apăsare a butonului utilizatorului. Trimite o poziție pe canalul principal când butonul utilizatorului este apăsat de trei ori. @@ -109,6 +93,9 @@ Busola de pe ecran, în afara cercului, va indica întotdeauna nordul. Rotire ecran vertical. Unitățile afișate pe ecranul dispozitivului. + Suprascrie ecranul OLED automat. + Suprascrie aspectul implicit al ecranului. + Îngroşează textul din antet de pe ecran. Necesită ca dispozitivul dvs. să aibă un accelerometru. Regiunea în care veți folosi radioul. Presetările modemului disponibile, implicit este Long Fast (Rază lungă - rapid). @@ -130,9 +117,10 @@ Intervalul maxim care poate trece fără ca un nod să transmită o poziție. Cea mai rapidă actualizare a poziției care va fi trimisă dacă distanța minimă a fost respectată. Modificarea minimă a distanței în metri care trebuie luată în considerare pentru o transmisie inteligentă a poziției. - Cât de des ar trebui să se încerce obținerea locației GPS (<10 secunde menține GPS-ul activ). + Cât de des ar trebui să încercăm să obținem o poziție GPS (<10sec păstrează GPS activat). Câmpuri opționale care trebuie incluse la asamblarea mesajelor de poziție. Cu cât sunt incluse mai multe câmpuri, cu atât mesajul va fi mai mare, ceea ce va duce la un timp de transmisie mai lung și la un risc mai mare de pierdere a pachetelor. Va păstra totul în repaus cât mai mult posibil, pentru rolul de tracker și senzor, aceasta va include și radioul LoRa. Nu utilizați această setare dacă doriți să utilizați dispozitivul cu aplicațiile telefonului sau dacă utilizați un dispozitiv fără buton de utilizator. + Generată din cheia publică și trimisă către alte noduri din rețea pentru a le permite să calculeze o cheie secretă comună. Utilizat pentru a crea o cheie partajată cu un dispozitiv la distanță. Cheia publică autorizată să trimită mesaje de administrare către acest nod. Dispozitivul este gestionat de un administrator de rețea, utilizatorul neputând accesa niciuna dintre setările dispozitivului. @@ -159,7 +147,6 @@ Cod QR Nume utilizator necunoscut Trimite - Încă nu ai asociat un radio compatibil cu Meshtastic cu acest telefon. Te rugăm să asociezi un dispozitiv și să îți setezi numele de utilizator.\n\nAceastă aplicaţie open-source este în dezvoltare, dacă întâmpinaţi probleme, vă rugăm să postaţi pe forumul nostru: https://github.com/orgs/meshtastic/discussions\n\nPentru mai multe informații, consultați pagina noastră de internet - www.meshtastic.org. Tu Permiteți analiza și raportări de erori. Accept @@ -167,44 +154,41 @@ Eliminați Salvează Am primit un nou URL de canal - Meshtastic necesită permisiuni de localizare activate pentru a găsi dispozitive noi prin Bluetooth. Puteți dezactiva această funcție când nu o utilizați. - Raportează Bug - Raportează un bug - Ești sigur că vrei să raportezi un bug? După ce ai raportat, te rog postează în https://github.com/orgs/meshtastic/discussions că să reușim să potrivim reportul tău cu ce ai găsit. Raportare - Conectare reușită, începem serviciul - Conectare eșuată, te rog reselecteaza Accesul locației este dezactivat, nu putem furniza locația ta la rețea. Distribuie Nod nou găsit: %1$s Deconectat - Dispozitiv în sleep mode - Conectat: %1$s online + Adormirea dispozitivului Adresa IP: Port: Conectat - Conectat la dispozitivul (%1$s) Conexiuni actuale: IP Wi-Fi: IP Ethernet: 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 - Acest contact nu este valid și nu poate fi adăugat - Panou debug + Panou de depanare Date decodate: Export jurnale - Export anulat %1$d (de) jurnale exportate Nu s-a reușit scrierea fișierului jurnal: %1$s - Niciun jurnal de exportat %1$d oră %1$d ore @@ -226,15 +210,28 @@ Ștergeți toate filtrele Adăugare filtru personalizat Presetări filtre - Arată doar nodurile ignorate - Salvează jurnalele din mesh - Dezactivați pentru a omite scrierea jurnalelor din mesh pe disc + Salvează jurnalele din retea + Dezactivați pentru a omite scrierea jurnalelor din retea pe disc Ștergeți jurnalele Potrivire oricare | toate Potrivire toate | oricare Aceasta va șterge toate pachetele de jurnal și intrările din baza de date de pe dispozitivul dvs. - Este o resetare completă și este permanentă. Șterge + Căutare emoji-uri... + Mai multe reacţii Canal + %1$s:%2$s + Mesaj de la %1$s %2$s + Antet + Obiect %1$d + Subsol + Casetă + Bulină + Text + Indicator + Degrade + Acesta este un element compozabil personalizat + Cu mai multe linii şi stiluri Status livrare mesaj Mesaje noi mai jos Notificări mesaje directe @@ -260,6 +257,7 @@ Setarea telefonului Alege tema Furnizați locația telefonului la mesh + Codare compactă pentru chirilică Ștergeți mesajul? Ștergeți %1$s mesaje? @@ -283,11 +281,9 @@ Oprire Oprirea nu este acceptată pe acest dispozitiv ⚠️ Aceasta va OPRI nodul. Pentru a-l repune în funcțiune, va fi necesară o intervenție fizică. - ⚠️ Acesta este un nod de infrastructură critică. Tastați numele nodului pentru a confirma: Nod: %1$s - Tastați: %1$s Restartează - Traceroute + Trasare traseu Arată Introducere Mesaj Opțiuni chat rapid @@ -297,16 +293,16 @@ Trimite instant Arată meniul de chat rapid Ascunde meniul de chat rapid - Arată chat-ul rapid Resetare la setările din fabrică - Bluetooth este dezactivat. Vă rugăm să îl activați în setările dispozitivului. Deschideți setările Versiune firmware: %1$s Meshtastic necesită permisiunea „Dispozitive din apropiere” pentru a găsi și conecta dispozitive prin Bluetooth. Puteți dezactiva această funcție când nu o utilizați. 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. @@ -341,14 +337,14 @@ Eliminare Acest nod va fi eliminat din listă până când nodul dvs. va primi din nou date de la acesta. Notificări silențioase - O oră 8 ore O săptămână Mereu În prezent: Mereu silențios Nu este silențios - Stare 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 @@ -358,13 +354,14 @@ Baterie ChUtil AirUtil + %1$s + %1$s:%2$s Temp Hum Temp sol Umid sol Jurnale Salturi distanță - Salturi distanță: %1$d Informaţie Utilizarea pentru canalul curent, inclusiv TX bine format, RX și RX malformat (zgomot). Procentul de timp de emisie utilizat în ultima oră. @@ -378,14 +375,10 @@ Cheia publică nu corespunde cu cheia înregistrată. Puteți elimina nodul și permiteți schimbul de chei din nou, dar acest lucru poate indica o problemă de securitate mai gravă. Contactați utilizatorul printr-un alt canal de încredere, pentru a determina dacă schimbarea cheii s-a datorat unei resetări la setările din fabrică sau unei alte acțiuni intenționate. Info utilizator Notificări noduri noi - Mai multe detalii 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 @@ -413,15 +406,23 @@ Acest traceroute nu are încă noduri care pot fi mapate. Se afișează %1$d/%2$d noduri Durată: %1$s s - %1$s - %2$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 - 48H 1W 2W - 4W Maxim + Extindeți graficul + Restrânge graficul Vârstă necunoscută Copiere Caracter clopoțel de alertă! @@ -435,18 +436,22 @@ 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) Baro Activat - Difuzare UDP - Configurare UDP Ultima recepție: %2$s
Ultima poziție: %3$s
Baterie: %4$s]]>
Comută poziția mea Orientare spre nord @@ -528,7 +533,6 @@ Pin GPIO de monitorizat Tip declanșator detectare Folosește modul INPUT_PULLUP - Dispozitiv Rolul dispozitivului GPIO buton GPIO buzzer @@ -573,12 +577,26 @@ LoRa Opțiuni Avansate + Utilizare presetare Presetări Lățime bandă + Factor de răspândire + Rata de codificare Regiune + Numărul de Hops + Transmisie activată + Putere transmisie + Slot pentru frecvenţă + Suprascrie ciclul de obligații + Ignoră primirea + Amplificare RX amplificată + Suprascriere frecvență + Ventilator PA dezactivat Ignoră MQTT Acceptă MQTT Configurare MQTT + Deconectat + Conectat MQTT activat Adresă Nume de utilizator @@ -586,57 +604,568 @@ Criptare activată Ieșire JSON activată TLS activat - Rețea + 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 - Poziție - Securitate + 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. - O oră + Oricare + 1 Oră 8 Ore 24 Ore 48 Ore + Filtrați după ultima oră: %1$s + %1$d dBm + Setări ale sistemului + Nici o statistică disponibilă + Analytics sunt colectate pentru a ne ajuta să îmbunătățim aplicația Android (mulțumesc), vom primi informații anonime despre comportamentul utilizatorului. Aceasta include rapoarte de accidente, ecrane folosite în aplicație, etc. + Platforme analitice: + Pentru mai multe informații, consultați politica noastră de confidențialitate. + Nesetat - 0 + %1$s de obicei este livrat cu un bootloader care nu acceptă actualizări OTA. Este posibil să fie nevoie să instalați un bootloader OTA prin USB înainte de a instala OTA. + Pentru RAK WisBlock RAK4631, folosiți unealta DFU serială's (de exemplu, seria adafruit-nrfutil dfu cu bootloader furnizat. fișier ip). Copierea fișierului .uf2 nu va actualiza bootloader-ul singur. + Don't arată din nou pe acest dispozitiv + Păstrează favoritele? + Actualizare firmware + Căutare actualizări... + Dispozitiv: %1$s + Instalat în prezent: %1$s + Actualizare către: %1$s + Stabil + Notă: Aceasta va deconecta temporar dispozitivul dumneavoastră în timpul actualizării. + Se descarcă firmware... %1$d% + Eroare: %1$s + Reîncercați + Actualizare reușită! + Gata + Se pornește DFU... + Se activează modul DFU... + Se validează firmware-ul... + Model hardware necunoscut: %1$d + Niciun dispozitiv conectat + Nu am putut găsi firmware-ul pentru %1$s în versiune + Extragere firmware... Actualizare eșuată + lucrăm la acest lucru... + Ţineţi dispozitivul aproape de telefon. + Nu închideți aplicația. + Aproape gata... + Acest lucru ar putea dura un minut... + Selectare fișier local + Fișier local + Sursa: Fișier Local + Lansare la distanţă necunoscută + Avertisment actualizare + Sunteți pe cale să instalați firmware-ul nou pe dispozitiv. Acest proces poartă riscuri.\n\n• Asigurați-vă că aparatul este încărcat.\n• Țineți dispozitivul aproape de telefon.\n• Nu închideți aplicația în timpul actualizării.\n\nVerificați că ați selectat firmware-ul corect pentru hardware-ul dumneavoastră. + Chirpy spune, \"Ţineţi-vă scara la îndemână!\" + Chirpy + Repornirea pe DFU... + High-cinci! Așteptați, copiere firmware-ul... + Vă rugăm să salvaţi fişierul .uf2 pe dispozitivul dvs's DFU unitate. + Atașare dispozitiv, vă rog așteptați... + Transfer fişier USB + BLE OTA + WiFi OTA + Updateaza către %1$s + Selectați DFU USB disk + Dispozitivul dvs. a repornit în modul DFU şi ar trebui să apară ca un dispozitiv USB (de exemplu, RAK4631).\n\nCând se deschide selectorul de fişiere, vă rugăm să selectaţi rădăcina acelui disc pentru a salva fişierul de firmwar. + Verific actualizarea... + Verificarea a expirat. Dispozitivul nu a reconectat în timp. + Se așteaptă ca dispozitivul să se reconecte... + Target: %1$s + Note de lansare + Eroare necunoscuta + Informațiile utilizatorului nodului lipsesc. + Baterie prea mică (%1$d%). Vă rugăm să încărcați dispozitivul înainte de actualizare. + Nu s-a putut recupera fișierul de firmware. + Actualizare USB nereuşită + Hash firmware respins. Dispozitivul poate avea nevoie de provizioane hash sau actualizare bootloader + Actualizare OTA esuata: %1$s + Se așteaptă ca dispozitivul să se repornească în modul OTA... + Conectarea la dispozitiv (încercarea %1$d/%2$d)... + Încărcare firmware... + Ştergere... + Înapoi Nesetat + Mereu pornit %1$d oră %1$d ore %1$d de ore + Busolă + Deschide busola + Distanță: %1$s + Bearing: %1$s + Acest dispozitiv nu are un senzor de busolă. Heading este indisponibil. + Este necesară permisiunea de localizare pentru a afișa distanța și rularea. + Furnizorul de localizare este dezactivat. Porniți serviciile de localizare + Se așteaptă o rezolvare GPS-ul pentru a calcula distanța și rularea. + Suprafață estimată: \u00b1%1$s (\u00b1%2$s) + Zonă estimată: precizie necunoscută + Marchează ca Citit + Acum + Următoarele canale au fost găsite în codul QR. Selectaţi o dată ce doriţi să adăugaţi pe dispozitivul dumneavoastră. Canalele existente vor fi păstrate. + Acest cod QR conţine o configuraţie completă. Aceasta va REPLACE canalele existente şi setările radio existente. Toate canalele existente vor fi eliminate. + Încărcare + Filtru mesaje + Activați filtrarea + Ascunde mesajele ce conțin cuvinte filtre + Filtrare cuvinte + Mesajele ce conţin aceste cuvinte vor fi ascunse + Adaugă cuvânt sau regex:pattern-ul + Nici un filtru cuvinte configurate + Model regex + Cuvânt întreg se potrivește + Arata %1$d filtrate + Ascunde %1$d filtrate + Filtrat + Activați filtrarea + Dezactivați filtrarea + Adresa canalului + Scanați NFC + Scanare contacte partajate NFC + Scanare cod QR contacte partajat + Introducere adresă contact partajată + Scanare canale NFC + Scanează canale cod QR + Introduceți URL-ul canalului + Distribuie codul QR al canalelor + Aduceți dispozitivul aproape de tag-ul NFC pentru a scana. + Generați codul QR + NFC este dezactivat. Vă rugăm să îl activați în setările de sistem. Toate Bluetooth + Configuraţi permisiunile Bluetooth + Descoperiți + Gestionați fără fir setările și canalele dispozitivului dvs. + Selecție stil hartă + Baterie: %1$d%% + Noduri: %1$d online / %2$d total + Actualizare: %1$s + ChUtil: %1$s% | AirTX: %2$s% + Trafic: TX %1$d / RX %2$d (D: %3$d) + Relee: %1$d (Canceled: %2$d) + Diagnosticuri: %1$s + Zgomotul %1$d dBm + Greșit %1$d + A pierdut %1$d + Titlu + %1$d / %2$d + %1$s + Alimentare + Reimprospatare + Actualizat + Adaugă nivel rețea + Fișier local MBTiles + Adaugă fișier MBTiles local + TAK (ATAK) + Configurare TAK + Activare server TAK local + Pornește un server TCP pe portul 8089 pentru conexiunile ATAK + Culoarea echipei + Rolul membrului + Nespecificat + Alb + Galben + Portocaliu + Mov Roșu + Maro + Violet + Albastru închis Albastru + Azuriu + Albastru-verzui Verde + Verde închis + Maro + Nespecificat + Membrii Echipei + Lider de echipă + Sediul Principal + Lunetist + Medic + Retrimite observatorul + Operator Radio Telefon + caine + Gestionare trafic + Modul activat + Deduplicare poziție + Precizie poziție (bits) + Interval poziţie minimă (sec) + NodeInfo Răspuns direct + Hops maxim pentru răspuns direct + Evaluare limitare + Evaluează fereastra limită (secunde) + Pachete Max în fereastră + Plasează pachete necunoscute + Prag de pachet necunoscut + Telemetrie doar local + Poziție doar-locală (raioane) + Păstrează Hops Router + Notiță + Dispozitiv de stocare & UI (doar cu permisiune) + Tema %1$s, Limba %2$s + Fișiere disponibile (%1$d): + - %1$s (%2$d bytes) + Nici un fişier manifestat. + Conectare + Gata + Wi-Fi Provisioning for mPWRD-OS + Furnizați acreditări Wi-Fi pentru dispozitivul mPWRD-OS prin Bluetooth. + Aflați mai multe despre proiectul mPWRD-OS\nhttps://github.com/mPWRD-OS + Căutare dispozitive + Dispozitiv gasit + Gata de scanare pentru rețele WiFi. + Scanare pentru reţele + Scanare… + Se aplică configurarea WiFi… + Nu au fost găsite rețele + Nu se poate conecta: %1$s + Nu s-a reușit scanarea pentru rețelele WiFi: %1$s + %1$d% + Rețele disponibile + Nume rețea (SSID) + Introdu sau selecteaza o retea + WiFi configurat cu succes! + Nu s-a reușit aplicarea configurației Wi-Fi + Meshtastic + Filtru diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index c31a3e7e9..8d4590e82 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -27,7 +27,6 @@ Скрыть ноды офлайн Отображать только слышимые ноды Вы просматриваете игнорируемые ноды,\nНажмите, чтобы вернуться к списку всех нод. - Показать детали Сортировать по Варианты сортировки нод А-Я @@ -46,6 +45,8 @@ Нераспознанный Ожидание подтверждения В очереди на отправку + Доставляется в сеть + Неизвестно Маршрутизация по SF++ цепочке… Подтверждено в цепочке SF++ Принято @@ -65,43 +66,24 @@ Неверный ключ сессии Публичный ключ не авторизован PKI не отправлен, нет открытого ключа - Client Приложение подключено или автономное устройство обмена сообщениями. - Client Mute Устройство, которое не пересылает пакеты с других устройств. - Client Base Обрабатывает пакеты от избранных нод как ROUTER_LATE, а все остальные пакеты - как от CLIENT. - Router Инфраструктурная нода для расширения охвата сети путем передачи сообщений. Видима в списке нод. - Router Client Сочетание ROUTER и CLIENT. Не для носимых устройств. - Repeater Инфраструктурная нода для расширения покрытия сети путем передачи сообщений с минимальными накладными расходами. Не видна в списке нод. - Tracker Транслирует пакеты местоположения GPS в приоритетном порядке. - Sensor Транслирует пакеты телеметрии в приоритетном порядке. - Тактический Оптимизировано для связи с системой ATAK, сокращает текущие передачи. - Client Hidden Устройство, которое передает сигнал только при необходимости для скрытности или экономии энергии. - Lost and Found Регулярно передает местоположение в виде сообщения на канал по умолчанию для помощи в восстановлении устройства. - TAK Tracker Включает автоматические трансляции TAK PLI и сокращает рутинные трансляции. - Router Late Инфраструктурная нода, которая всегда ретранслирует пакеты один раз, но только после всех остальных режимов, обеспечивая дополнительное покрытие для локальных кластеров. Видима в списке. - Всё Ретранслировать замеченное сообщение, если оно было на нашем частном канале или из другой сетки с теми же параметрами lora. - Все пропущенные декодирования Так же, как и ALL, но пропускает декодирование пакетов и просто ретранслирует их. Доступно только в роли Repeater. Установка этого параметра для любых других ролей приведет к изменению поведения ALL. - Только локальные Игнорирует обнаруженные сообщения из чужих mesh-сетей, которые открыты или не могут быть расшифрованы. Ретранслирует сообщение только на локальных основных / дополнительных каналах нод. - Только известные Игнорируемые сообщения из других сетей, таких как LOCAL ONLY, но так же, и игнорирует сообщения от узлов, которые еще не включены в известный список узлов. - Отсутствует Разрешено только для ролей SENSOR, TRACKER и TAK_TRACKER, это запретит все ретрансляции, не похожие на роль CLIENT_MUTE. - Только основные номера портов Игнорирует пакеты из нестандартных портов, таких как: TAK, RangeTest, PaxCounter и т. д. Только ретранслирует пакеты со стандартными номерами портов: NodeInfo, Text, Position, Telemetry, Routing. Рассматривать двойное нажатие на поддерживаемых акселерометрах как нажатие пользовательской кнопки. Отправлять позицию на основной канал по тройному нажатию кнопки. @@ -168,7 +150,6 @@ QR-код Неизвестное имя пользователя Отправить - Вы еще не подключили к телефону устройство, совместимое с Meshtastic радио. Пожалуйста, подключите устройство и задайте имя пользователя.\n\nЭто приложение с открытым исходным кодом находится в альфа-тестировании, если вы обнаружите проблемы, пожалуйста, напишите в чате на нашем сайте.\n\nДля получения дополнительной информации посетите нашу веб-страницу - www.meshtastic.org. Вы Разрешить аналитику и отчеты о сбоях. Принять @@ -176,23 +157,15 @@ Отмена Сохранить URL нового канала получен - Meshtastic требуется разрешение, чтобы найти новые устройства через Bluetooth. Вы можете отключить если они не используются. - Сообщить об ошибке - Сообщить об ошибке - Вы уверены, что хотите сообщить об ошибке? После сообщения, пожалуйста, напишите в https://github.com/orgs/meshtastic/discussions, чтобы мы могли сопоставить отчет с тем, что вы нашли. Отчет - Сопряжение завершено, запуск сервиса - Сопряжение не удалось, пожалуйста, выберите еще раз Доступ к местоположению выключен, невозможно посылать местоположение в сеть. Поделиться Возникла новая нода - %1$s Отключено Устройство спит - Подключено: %1$s в сети IP-адрес: Порт: Подключено - Подключен к радиостанции (%1$s) Текущие подключения: Wi-Fi IP: Ethernet IP: @@ -214,14 +187,11 @@ Meshtastic создан с использованием следующих библиотек с открытым исходным кодом. Нажмите на любую библиотеку, чтобы просмотреть ее лицензию. %1$d библиотек Этот URL-адрес канала недействителен и не может быть использован - Контакт неверный и не может быть добавлен Панель отладки Декодированная нагрузка: Экспортировать логи - Экспорт отменён %1$d журналов экспортировано Не удалось записать файл журнала: %1$s - Нет журналов для экспорта %1$d час %1$d часа @@ -245,7 +215,6 @@ Очистить все фильтры Добавить пользовательский фильтр Предустановленные фильтры - Показать только игнорируемые ноды Хранить журналы mesh-сети Выключить запись сетевых журналов на диск Очистить журнал @@ -288,10 +257,15 @@ Сброс значений по умолчанию Применить Тема + Контрастность Светлая Темная По умолчанию Выберите тему + Уровень контрастности + Стандартный + Средний + Высокий Предоставление местоположения для сети Компактная кодировка кириллицы @@ -318,9 +292,7 @@ Выключение Выключение не поддерживается на этом устройстве ⚠️ Эта нода будет ВЫКЛЮЧЕНА. Для её включения потребуется физическое взаимодействие. - ⚠️ Это критичная нода инфраструктуры. Введите её имя для подтверждения: Узел: %1$s - Тип: %1$s Перезагрузка Трассировка маршрута Показать введение @@ -332,9 +304,7 @@ Мгновенная отправка Показать меню быстрого чата Скрыть меню быстрого чата - Показать быстрый чат Сброс до заводских настроек - Bluetooth отключен. Пожалуйста, включите его в настройках вашего устройства. Открыть настройки Версия прошивки: %1$s Meshtastic требует разрешение на поиск и подключение к устройствам через Bluetooth. Вы можете отключить его, когда он не используется. @@ -343,6 +313,7 @@ Доставка подтверждена Ваше устройство может отключиться и перезагрузиться во время применения настроек. Ошибка + Неизвестная ошибка Игнорировать Удалить из игнорируемых Добавить '%1$s' в список игнорируемых? @@ -377,7 +348,6 @@ Удалить Эта нода будет удалена из вашего списка, пока ваша нода снова не получит данные от неё. Отключить уведомления - 1 час 8 часов 1 неделя Всегда @@ -386,7 +356,6 @@ Не заглушен Обеззвучен на %1$d дней, %2$s часов Обеззвучен на %1$s часов - Статус заглушки Включить уведомления для '%1$s'? Откл. уведомления для '%1$s? Заменить @@ -396,9 +365,9 @@ Батарея ChUtil AirUtil - %1$s: %2$.1f%% - %1$s: %2$.1f В - %1$.1f + %1$s: %2$s%% + %1$s: %2$s В + %1$s %1$s: %2$s Темп Влажн @@ -406,7 +375,6 @@ Влажн почвы Журналы Прыжков - Количество ретрансляций %1$d Информация Использование для текущего канала, включая хорошо сформированный TX, RX и неправильно сформированный RX (так называемый шум). Процент времени эфира для передачи в течение последнего часа. @@ -420,14 +388,10 @@ Открытый ключ не соответствует записанному ключу. Вы можете удалить ноду и позволить ей снова обменяться ключами, но это может указывать на серьезную проблему с безопасностью. Свяжитесь с пользователем по другому надежному каналу чтобы определить, произошла ли смена ключа в результате сброса настроек или другого преднамеренного действия. Пользовательская информация Уведомления о новых нодах - Подробнее Сигнал/шум - Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных. RSSI - Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение. (Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500. Интервал передачи - Карта нод Местоположение Обновление последнего местоположения Метрики окружения @@ -456,17 +420,28 @@ В этой трассировке маршрута пока нет отображаемых узлов. Показаны %1$d/%2$d узлов Продолжительность: %1$s с - %1$s - %2$s Обратный маршрут:\n\n Маршрут к нам:\n\n + Хопов вперёд + Хопов обратно + Круговой маршрут + Без ответа + Загрузка 1м + Загрузка 5м + Загрузка 15м + Среднее значение нагрузки системы за 1 минуту + Среднее значение нагрузки системы за 5 минут + Среднее значение нагрузки системы за 15 минуту + Доступная оперативная память в байтах 24ч - 48ч 1нед 2нед - 4нед Макс + Мин + Развернуть диаграмму + Свернуть диаграмму Неизвестный возраст Копировать Символ колокольчика оповещения! @@ -480,6 +455,11 @@ Канал 1 Канал 2 Канал 3 + Канал 4 + Канал 5 + Канал 6 + Канал 7 + Канал 8 Ток Напряжение Вы уверены? @@ -491,8 +471,6 @@ Уведомления о низком заряде батареи (избранные ноды) Давл Включено - Трансляция UDP - UDP Config Последний приём: %2$s
Последнее местоположение: %3$s
Батарея: %4$s]]>
Переключить мою позицию Ориентация на север @@ -571,11 +549,9 @@ Трансляция состояния (в секундах) Отправить колокол с уведомлением Понятное имя - Дружеское обращение GPIO контакт для мониторинга Тип триггера обнаружения Использовать режим INPUT_PULLUP - Устройство Роль устройства Кнопка GPIO Зуммер GPIO @@ -615,6 +591,9 @@ Продолжительность вывода (миллисекунды) Таймаут Nag (в секундах) Рингтон + Импортировать рингтон + Файл пуст + Ошибка импорта: %1$s Воспроизвести Использовать I2S как буззер LoRa @@ -625,7 +604,6 @@ Ширина канала Коэффициент распространения Частота кодирования - Смещение частоты (MHz) Регион / Страна Количество прыжков Передача включена @@ -639,6 +617,23 @@ Игнорировать MQTT ОК в MQTT Настройка MQTT + Неактивно + Отключено + Отключено — %1$s + Подключение... + Подключено + Переподключение... + Переподключение (попытка %1$d) — %2$s + Проверить соединение + Проверяем брокер… + Доступно. Брокер принял учетные данные. + Доступно (%1$s) + Брокер отклонен: %1$s + Узел не найден + Не удается подключиться к брокеру (TCP) + Сбой TLS-рукопожатия + Тайм-аут после %1$d мс + Соединение не удалось MQTT включен Адрес Имя пользователя @@ -654,13 +649,11 @@ Информация о соседях включена Интервал обновления (в секундах) Передать через LoRa - Сеть Настройки WiFi Включено WiFi включен Название сети Пароль - Получить документ Настройки Ethernet Ethernet включен NTP-сервер @@ -669,6 +662,7 @@ IP-адрес Шлюз Подсеть + Служба доменных имен (DNS) Настройки Paxcounter Paxcounter включен Состояние сообщения @@ -676,31 +670,18 @@ Строка фактического состояния Порог WiFi RSSI (по умолчанию -80) BLE RSSI порог (по умолчанию -80) - Местоположение - Интервал трансляции местоположения (в секундах) - Умное местоположение включено - Умная трансляция минимальное расстояние (метры) - Минимальный интервал умной трансляции (секунд) - Использовать фиксированное местоположение Широта Долгота - Высота (в метрах) Установить местоположение с телефона Режим GPS (физическое оборудование) - Интервал обновления GPS (в секундах) - Переопределить GPS_RX_PIN - Переопределить GPS_TX_PIN - Переопределить PIN_GPS_EN Флаги позиции Настройка питания Включить режим энергосбережения Выключение при потере мощности - Задержка выключения в режиме батареи (в секундах) Коэффициент переопределения ADC Коэффициент переопределения ADC Длительность ожидания Bluetooth Длительность супер-глубокого сна - Длительность легкого сна Минимальное время бодрствования I2C-адрес INA_2XX батареи Настройка проверки дальности @@ -711,7 +692,6 @@ Удаленное оборудование включено Разрешить неопределённый контакт Доступные контакты - Безопасность Ключ прямого сообщения Ключи администратора Публичный ключ @@ -725,6 +705,8 @@ COM-порт включен Echo включен Скорость COM-порта + RX + TX Время ожидания истекло Режим COM-порта Переопределить COM-порт консоли @@ -759,8 +741,15 @@ Расстояние Освещённость Ветер + Скорость ветра + Порыв ветра + Штиль + Напр ветра + Дождь (1ч) + Дождь (24ч) Вес Радиация + Темп. 1-Wire Качество воздуха в помещении (IAQ) URL-адрес @@ -773,8 +762,6 @@ ID пользователя Аптайм Нагрузка %1$d - Получен канал %1$d/%2$d - Получен %1$s Свободно на диске %1$d Отметка времени Курс @@ -792,7 +779,6 @@ Нажмите и перетащите для изменения порядка Включить микрофон Динамический - Сканировать QR код Отправить контакт Заметки Добавить личную заметку… @@ -805,13 +791,11 @@ Запрос Запрашиваю %1$s у %2$s Пользовательская информация - Информация о соседях (2.7.15+) Запрос телеметрии Метрики устройства Метрики окружения Метрики качества воздуха Метрики мощности - Локальная статистика Метрики хоста Метрика прохожих Метаданные @@ -822,7 +806,6 @@ Метрики хоста Хост Свободная память - Свободно памяти на диске Загрузка Строка пользователя Перейти в @@ -865,8 +848,6 @@ (онлайн %1$d / показано %2$d / всего %3$d) Среагировать Отключиться - Сетевые устройства не найдены. - USB-устройства COM-порта не найдены. Прокрутить вниз Meshtastic Статус безопасности @@ -882,8 +863,6 @@ Очистить базу данных нод Очистить ноды, старее чем %1$d дней Очистить только неизвестные ноды - Очистка нод с низким/отсутствием взаимодействия - Очистка игнорируемых нод Очистить сейчас Это приведет к удалению %1$d нод из вашей базы данных. Это действие не может быть отменено. Зеленый замок означает, что канал надежно зашифрован либо 128, либо 256 битным ключом AES. @@ -902,9 +881,6 @@ Показать все значения Показать текущий статус Отменить - Вы действительно хотите удалить эту ноду? - Забыть подключение - Вы уверены, что хотите забыть это подключение? Ответить %1$s Отменить ответ Удалить сообщения? @@ -913,10 +889,15 @@ Написать сообщение Метрика прохожих PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s Метрики прохожих недоступны Настройка Wi-Fi для mPWRD-OS Устройства Bluetooth - Сопряженные устройства Подключённые устройства Превышен лимит запросов. Пожалуйста, повторите попытку позже. Просмотреть релиз @@ -944,7 +925,6 @@ Уведомления для новых обнаруженных нод. Низкий заряд батареи Уведомления о низком заряде батареи для подключенного устройства. - Выберите пакеты, отправленные как критические; они будут игнорировать переключение сообщений и настройки «Не беспокоить» в центре уведомлений ОС. Настроить права доступа для уведомлений Местоположение телефона Meshtastic использует местоположение вашего телефона, чтобы включить ряд функций. Вы можете обновить права доступа к вашему местоположению в любое время из настроек. @@ -967,19 +947,15 @@ Настроить критические оповещения Meshtastic использует уведомления, чтобы держать вас в курсе новых сообщений и других важных событий. Вы можете обновить разрешения уведомлений в любое время из настроек. Далее - Предоставить разрешения %1$d нод в очереди для удаления: Осторожно: Это удаляет ноды из базы данных в приложении и устройства.\nВыбор является суммирующим - Подключение к устройству Обычный Спутниковая Ландшафт Смешанный Управление Слоями Карты Слои карты поддерживают форматы .kml, .kmz или GeoJSON. - Слои карты Слои карты не загружены. - Добавить слой Скрыть слой Показать слой Удалить слой @@ -1017,14 +993,12 @@ 48 часов Фильтр по времени последнего сообщения: %1$s %1$d dBm - Нет приложения для обработки ссылки. Настройка системы Статистика недоступна Аналитика помогает нам улучшить Android приложение (спасибо), мы будем получать анонимизированную информацию о поведении пользователя. В частности: отчеты о сбоях, используемые экраны и пр. Платформы для аналитики: Дополнительная информация доступна в нашей политике конфиденциальности. Не задано - 0 - Ретранслировано: %1$s Услышано %1$d ретранслятором Услышано %1$d ретрансляторами @@ -1036,7 +1010,6 @@ Для RAK WisBlock RAK4631, используйте прошивальщик от производителя (например, adafruit-nrfutil с предоставленным .zip файлом загрузчика). Копирование файла .uf2 само по себе не обновит загрузчик. Не показывать снова на этом устройстве Сохранить избранное? - USB устройства Обновление прошивки Проверка обновлений... @@ -1052,16 +1025,12 @@ Обновлено успешно! Готово Запуск прошивки... - Обновление... %1$s Включение DFU режима... Проверка прошивки... - Отключение... Неизвестная модель оборудования: %1$d - Подключенное устройство не является допустимым BLE устройством или адрес неизвестен (%1$s). Нет подключенных устройств Не удалось найти в релизе прошивку для %1$s. Извлечение прошивки... - Отключение для запуска сервиса DFU... Ошибка обновления Держитесь крепче, работаем... Держите устройство поближе к телефону. @@ -1077,7 +1046,6 @@ Щебетун говорит: \"Держите лестницу под рукой!\" Щебетун Перезагрузка в DFU... - Ожидание DFU устройства... Дай пять! Подожди, идет копирование прошивки... Пожалуйста, сохраните файл \".uf2\" на вашем устройстве с DFU. Прошивка устройства, подождите... @@ -1093,26 +1061,16 @@ Целевое устройство: %1$s Список изменений Неизвестная ошибка - Локальное обновление не удалось - Ошибка DFU: %1$s - Отменено DFU Отсутствует информация о пользователе ноды. Слишком низкий заряд (%1$d%). Пожалуйста, зарядите устройство перед обновлением. Не удалось получить файл прошивки. - Ошибка обновления DFU Ошибка обновления USB Хэш прошивки отклонен. Устройство может потребовать подготовки хэша или обновления загрузчика. Ошибка обновления OTA: %1$s - Загрузка прошивки... Ожидание перезагрузки устройства в OTA режим... Подключение к устройству (попытка %1$d/%2$d)... - Проверка версии устройства... Запуск OTA обновления... Загрузка прошивки... - Загрузка прошивки... %1$d% (%2$s) - Перезагрузка устройства... - Обновление прошивки - Статус обновления прошивки Очистка... Назад Не установлена @@ -1149,9 +1107,7 @@ Предполагаемая площадь: точность неизвестна Пометить прочитанным Только что - Добавить каналы В QR-коде были найдены следующие каналы. Выберите тот, который вы хотели бы добавить на свое устройство. Существующие каналы будут сохранены. - Заменить каналы и настройки Этот QR-код содержит полную конфигурацию. Это заменит ваши существующие каналы и настройки радио. Все существующие каналы будут удалены. Загрузка @@ -1164,7 +1120,6 @@ Фильтр слов не настроен Шаблон регулярного выражения Совпадение всего слова - %1$d отфильтрованы Показать %1$d отфильтрованных Скрыть %1$d отфильтрованных Отфильтрованные @@ -1185,14 +1140,10 @@ Всё Bluetooth Настроить разрешения Bluetooth - Подключиться к радио - Просканируйте и подключитесь к вашей радиостанции Meshtastic. Обнаружение Найдите и определите устройства Meshtastic рядом с вами. Настройки Беспроводное управление настройками устройства и каналами. - Разрешение получено - Доступ запрещён Выбор стиля карты Батарея: %1$d Нод: %1$d онлайн / %2$d всего @@ -1208,17 +1159,12 @@ %1$d / %2$d %1$s Питание - Статистика Meshtastic Обновить Обновлено Добавить сетевой уровень - Обновить уровень Локальный файл MBTiles Добавить локальный файл MBTiles - Недопустимое имя, шаблон URL или локальный URI для провайдера плиток пользователя. - Провайдер плиток с этим именем уже существует. - Не удалось скопировать файл MBTiles во внутреннее хранилище. TAK (ATAK) Настройка TAK Включить локальный сервер TAK @@ -1265,17 +1211,7 @@ Телеметрия только для локальной сети (ретрансл.) Только локальная позиция (ретрансл.) Сохраняить хопы маршрутизатора - Пока нет сообщений - %1$d непрочитанное - Поддержка карт скоро появится на компьютере - Нет подключенных устройств - Состояние обновления - Готово к обновлению прошивки - Проверка обновлений - Загрузить прошивку - Обновление устройства Примечание - Убедитесь, что ваше устройство полностью заряжено перед началом обновления прошивки. Не отключайте и не выключайте устройство во время процесса обновления. Хранилище устройства и UI (только для чтения) Тема: %1$s, язык: %2$s Доступные файлы (%1$d): @@ -1292,17 +1228,30 @@ Поиск сетей Поиск... Применение настроек Wi-Fi… - 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-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index 651c6c549..6beec1a74 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -25,7 +25,6 @@ Skryť neaktívne uzle Zobraziť len priame uzle Prezeráte si ignorované uzly,\nStalčte tlačidlo späť aby ste sa vrátili k zoznamu uzlov. - Zobraziť detaily Zoradiť podľa Nastavenie triedenia uzlov A-Z @@ -56,39 +55,22 @@ Neznámy verejný kľúč Zlý kľúč relácie Verejný kľúč neautorizovaný - Klient Pripojená aplikácia, alebo samostatné zariadenie na odosielanie správ. - Stlmený Klient Zariadenie, ktoré nepreposiela pakety z ďalších zariadení. - Smerovač Uzol infraštruktúry na rozšírenie pokrytia siete prenosom správ. Viditeľný v zozname uzlov. - Smerovač Klient Kombinácia ROUTER a CLIENT. Nie pre mobilné zariadenia. - Opakovač Uzol infraštruktúry na rozšírenie pokrytia siete prenosom správ s minimálnou réžiou. Nezobrazuje sa v zozname uzlov. - Sledovač Prioritne vysiela pakety polohy GPS. - Senzor Prioritne vysiela telemetrické pakety. - TAK Optimalizované pre systémovú komunikáciu ATAK, znižuje rutinné vysielanie. - Skrytý Klient Zariadenie, ktoré vysiela len podľa potreby pre utajenie, alebo úsporu energie. - Straty a nálezy Pravidelne vysiela polohu ako správu na predvolený kanál, aby pomohol pri obnove zariadenia. - TAK Sledovač Umožňuje automatické vysielanie TAK PLI a znižuje rutinné vysielanie. - Smerovač s Oneskorením Uzol infraštruktúry, ktorý vždy preposiela pakety raz, ale až po všetkých ostatných režimoch, čím zabezpečuje dodatočné pokrytie pre miestne zväzky. Viditeľný v zozname uzlov. - Všetky Preposiela akúkoľvek pozorovanú správu, ak bola na našom súkromnom kanáli alebo z inej siete s rovnakými parametrami lora. - Preskočiť Dekódovanie Všetkých Rovnaké ako správanie ako ALL, ale preskočí dekódovanie paketov a jednoducho ich prepošle. Dostupné iba v úlohe Opakovača. Nastavenie tejto možnosti na akékoľvek iné roly bude mať za následok správania sa ako ALL. - Iba Lokálne Ignoruje pozorované správy z cudzích sietí, ktoré sú otvorené alebo tie, ktoré nedokáže dešifrovať. Opätovne vysiela správu iba na lokálnych primárnych / sekundárnych kanáloch uzlov. - Iba Známe Ignoruje pozorované správy z cudzích sietí, ako napríklad LOCAL ONLY, ale ide o krok ďalej tým, že ignoruje aj správy z uzlov, ktoré ešte nie sú v známom zozname uzla. - Žiadny Povolené len pre role SENSOR, TRACKER a TAK_TRACKER, zamedzí to všetkým opätovným vysielaniam, na rozdiel od roly CLIENT_MUTE. Ignoruje pakety z neštandardných portov, ako sú: TAK, RangeTest, PaxCounter atď. Opätovne vysiela iba pakety so štandardnými portami: NodeInfo, Text, Position, Telemetry a Routing. Vykoná dvojklepnutie na podporovaných akcelerometroch ako stlačenie užívateľského tlačidla. @@ -120,7 +102,6 @@ QR kód Neznáme užívateľské meno Odoslať - K tomuto telefónu ste ešte nespárovali žiadne zariadenie kompatibilné s Meshtastic. Prosím spárujte zariadenie a nastavte svoje užívateľské meno.\n\nTáto open-source aplikácia je v alpha testovacej fáze, ak nájdete chybu, prosím popíšte ju na fóre: https://github.com/orgs/meshtastic/discussions\n\n Pre viac informácií navštívte web stránku - www.meshtastic.org. Vy Povoliť posielanie analytiky a chybových hlásení. Prijať @@ -128,21 +109,14 @@ Vymazať Uložiť Prijatá nová URL kanálu - Nahlásiť chybu - Nahlásiť chybu - Ste si istý, že chcete nahlásiť chybu? Po odoslaní prosím pridajte správu do https://github.com/orgs/meshtastic/discussions aby sme vedeli priradiť Vami nahlásenú chybu ku Vášmu príspevku. Nahlásiť - Párovanie ukončené, štartujem službu - Párovanie zlyhalo, prosím skúste to znovu Prístup k polohe zariadenia nie je povolený, nedokážem poskytnúť polohu zariadenia Mesh sieti. Zdieľať Odpojené Vysielač uspaný - Pripojený: %1$s online IP adresa: Port: Pripojený - Pripojené k vysielaču (%1$s) Wifi IP: Eternet IP: Prebieha pripájanie @@ -177,7 +151,6 @@ Vymazať všetky filtre Pridať vlastný filter Prednastavené filtre - Zobraziť len ignorované Uzly Zmazať Kanál Stav doručenia správy @@ -283,13 +256,9 @@ Šifrovanie verejného kľúča Nezhoda verejného kľúča Notifikácie nových uzlov - Viac detailov 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 @@ -310,10 +279,8 @@ Počet skokov smerom k %1$d Počet skokov späť %2$d 24 hodín - 48 hodín 1 týždeň 2 týždne - 4 týždne Maximum Neznámy vek Kopírovať @@ -333,7 +300,6 @@ Upozornenia o slabej batérii Slabá batéria: %1$s Upozornenia o slabej batérii (obľúbene uzle) - Konfigurácia UDP Naposledy počutý: %2$s
Posledná pozícia: %3$s
Batéria: %4$s]]>
Zapnúť lokalizáciu Užívateľ @@ -388,25 +354,23 @@ GPIO konektor pre Enkóder A port GPIO konektor pre Enkóder B port Správy - Zariadenie Otoč Obrazovku Zvonenie LoRa Šírka pásma Región + Odpojené + Pripojený Adresa Používateľské meno Heslo Vysielať cez sieť LoRa - Sieť WiFi zapnutá SSID PSK Ethernet zapnutý NTP server IPv4 režim - Pozícia - Zabezpečenie Verejný kľúč Súkromný kľúč Časový limit @@ -462,4 +426,6 @@ Červená Modrá Zelená + Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index a085cc00d..bff8e6150 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -20,7 +20,6 @@ Filter Počisti filtre vozlišča Vključi neznane - Prikaži podrobnosti A-Z Kanal Razdalja @@ -63,7 +62,6 @@ Enako kot vedenje ALL, vendar preskoči dekodiranje paketkov in jih preprosto ponovno odda. Na voljo samo v vlogi Repeater. Če to nastavite za katero koli drugo vlogo, bo to povzročilo vedenje ALL. Ignorira opažena sporočila tujih odprtih mrež, ali tistih, ki jih ne more dešifrirati. Ponovno oddaja samo sporočila na lokalnih primarnih/sekundarnih kanalih vozlišč. Ignorira opažena sporočila iz tujih mrež, kot je LOCAL ONLY, vendar gre korak dlje, tako da ignorira tudi sporočila vozlišč, ki še niso na seznamu znanih. - Brez Dovoljeno samo za vloge SENSOR, TRACKER in TAK_TRACKER, prepovedano bo vsakršnje ponovno oddajanje, v nasprotju z vlogo CLIENT_MUTE. Ignorira nestandardne paketke, kot so: TAK, RangeTest, PaxCounter itd. Ponovno oddaja samo standardne paketke: NodeInfo, Text, Position, Telemetry in Routing. Obravnavaj dvojni pritisk na podprtih merilnikih pospeška kot pritisk uporabnika. @@ -74,24 +72,17 @@ QR koda Neznano uporabniško ime Pošlji - S tem telefonom še niste seznanili združljivega Meshtastic radia. Prosimo povežite napravo in nastavite svoje uporabniško ime. \n\nTa odprtokodna aplikacija je v alfa testiranju, če imate težave, objavite na našem spletnem klepetu.\n\nZa več informacij glejte našo spletno stran - www.meshtastic.org. Jaz Sprejmi Prekliči/zavrzi Shrani Prejet je bil novi URL kanala - Prijavi napako - Prijavite napako - Ali ste prepričani, da želite prijaviti napako? Po poročanju objavite v https://github.com/orgs/meshtastic/discussions, da bomo lahko primerjali poročilo s tistim, kar ste našli. Poročilo - Seznanjanje zaključeno, zagon storitve - Seznanjanje ni uspelo. Prosimo, izberite znova Dostop do lokacije je onemogočen, mreža ne more prikazati položaja. Souporaba Prekinjeno Naprava je v \"spanju\" IP naslov: - Povezana z radiem (%1$s) Ni povezano Povezan z radiem, vendar radio \"spi\" Aplikacija je prestara @@ -204,13 +195,9 @@ Šifriranje javnega ključa Neujemanje javnega ključa Obvestila novih vozlišč - Več podrobnosti 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 @@ -230,14 +217,13 @@
Skokov k %1$d Skokov nazaj %2$d 24ur - 48ur 1T 2T - 4T Maks. Kopiraj Znak opozorilnega zvonca! Regija + Prekinjeno Javni ključ Zasebni ključ Časovna omejitev @@ -256,4 +242,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index 5a9b7fc49..edfac59b0 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -20,7 +20,6 @@ Filtrimi pastro filtrin e nyjës Përfshi të panjohurat - Shfaq detajet Kanal Distanca Hop-e larg @@ -61,7 +60,6 @@ Po të njëjtën sjellje si ALL, por kalon pa dekoduar paketat dhe thjesht i ritransmeton. I disponueshëm vetëm për rolin Repeater. Vendosja e kësaj në rolet e tjera do të rezultojë në sjelljen e ALL. Injoron mesazhet e vëzhguara nga rrjete të huaja që janë të hapura ose ato që nuk mund t'i dekodoj. Vetëm ritransmeton mesazhe në kanalet lokale primare / dytësore të nyjës. Injoron mesazhet e vëzhguara nga rrjete të huaja si LOCAL ONLY, por e çon më tutje duke injoruar edhe mesazhet nga nyje që nuk janë në listën e njohur të nyjës. - Asnjë Lejohet vetëm për rolet SENSOR, TRACKER dhe TAK_TRACKER, kjo do të pengojë të gjitha ritransmetimet, jo ndryshe nga roli CLIENT_MUTE. Injoron paketat nga portnumra jo standardë si: TAK, RangeTest, PaxCounter, etj. Vetëm ritransmeton paketat me portnumra standard: NodeInfo, Text, Position, Telemetry, dhe Routing. @@ -69,24 +67,17 @@ Kodi QR Emri i përdoruesit është i panjohur Dërgo - Ju ende nuk keni lidhur një paisje radio Meshtastic me këtë telefon. Ju lutem lidhni një paisje radio dhe vendosni emrin e përdoruesit.\n\nKy aplikacion është software i lire \"open-source\" dhe në variantin Alpha për testim. Nëse hasni probleme, ju lutem shkruani në çatin e faqes tonë të internetit: https://github.com/orgs/meshtastic/discussions\n\nPër më shumë informacione vizitoni faqen tonë në internet - www.meshtastic.org. Ju Prano Anullo Ruaj Ju keni një kanal radio të ri URL - Raporto Bug - Raporto një bug - Jeni të sigurtë që dëshironi të raportoni një bug? Pas raportimit, ju lutem postoni në https://github.com/orgs/meshtastic/discussions që të mund të lidhim raportin me atë që keni gjetur. Raporto - Lidhja u përfundua, duke nisur shërbimin - Lidhja dështoi, ju lutem zgjidhni përsëri Aksesimi në vendndodhje është i fikur, nuk mund të ofrohet pozita për rrjetin mesh. Ndaj I shkëputur Pajisja po fle Adresa IP: - E lidhur me radio (%1$s) Nuk është lidhur E lidhur me radio, por është në gjumë Përditësimi i aplikacionit kërkohet @@ -195,11 +186,7 @@ Kriptimi me Çelës Publik Përputhje e Gabuar e Çelësit Publik Njoftimet për nyje të reja - Më shumë detaje - 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 @@ -212,6 +199,7 @@ Hops drejt %1$d Hops prapa %2$d 訊息 Rajon + I shkëputur Koha e skaduar Distanca @@ -228,4 +216,5 @@ + Filtrimi diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 15a0bba90..a365fc888 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -20,7 +20,6 @@ Filter očisti filter čvorova Uključi nepoznato - Prikaži detalje A-Š Kanal Udaljenost @@ -31,6 +30,7 @@ Nekategorisano Čeka na potvrdu U redu za slanje + Непознато Potvrđeno Nema rute Primljena negativna potvrda @@ -47,34 +47,22 @@ Nepoznat javni ključ Loš ključ sesije Javni ključ nije autorizovan - Клијент Povezana aplikacija ili samostalni uređaj za slanje poruka. - Клијент мутиран Uređaj koji ne prosleđuje pakete od drugih uređaja. - Рутер Infrastrukturni čvor za proširenje pokrivenosti mreže prosleđivanjem poruka. Vidljiv na listi čvorova. Kombinacija i RUTERA i KLIJENTA. Nije namenjeno za mobilne uređaje. - Поновљач Infrastrukturni čvor za proširenje pokrivenosti mreže prosleđivanjem poruka sa minimalnim troškovima energije. Nije vidljiv na listi čvorova. - Трекер Emituje GPS pakete položaja kao prioritet. - Сензор Emituje telemetrijske pakete kao prioritet. Optimizovano za komunikaciju u ATAK sistemu, smanjuje rutinske emisije. - Скривени клијент Uređaj koji prenosi samo kada je potrebno radi skrivenosti ili uštede energije. - Изгубљено и нађено Prenosi lokaciju kao poruku na podrazumevani kanal redovno kako bi pomogao u pronalasku uređaja. - ТАК Трекер Omogućava autmatske TAK PLI emisije i smanjuje rutinske emisije. - Рутер са кашњењем Infrastrukturni čvor koji uvek ponovo prenosi pakete jednom, ali tek nakon svih drugih načina, osiguravajući dodatno pokrivanje za lokalne klastere. Vidljiv u listi čvorova. - Сви Ponovo prenosi svaku primećenu poruku, ako je bila na našem privatnom kanali ili iz druge mreže sa istim LoRA parametrima. Isto kao ponašanje kod ALL moda, ali preskače dekodiranje paketa i jednostavno ih ponovo prenosi. Dostupno samo u Repeater ulozi. Postavljanje ovoga na bilo koju drugu ulogu rezultovaće ALL ponašanjem. Ignoriše primećene poruke iz stranih mreža koje su otvorene ili one koje ne može da dekodira. Ponovo prenosi poruku samo na lokalne primarne/sekundarne kanale čvora. Ignoriše primećene poruke iz stranih mreža kao LOCAL ONLY, ali ide korak dalje tako što takođe ignoriše poruke sa čvorova koji nisu već na listi nepoznatih čvorova. - Bez Dozvoljeno samo za uloge SENSOR, TRACKER, TAK_TRACKER, ovo će onemogućiti sve ponovne prenose, slično kao uloga CLIENT_MUTE. Ignoriše pakete sa nestandardnim brojevima porta kao što su: TAK, RangeTest, PaxCounter, itd. Ponovo prenosi samo pakete sa standardnim brojevima porta: NodeInfo, Text, Position, Temeletry i Routing. Treniraj dvostruki dodir na podržanim akcelerometrima kao pritisak korisničkog dugmeta. @@ -123,25 +111,18 @@ QR kod Nepoznato korisničko ime Pošalji - Још нисте упарили Мештастик компатибилан радио са овим телефоном. Молимо вас да упарите уређај и поставите своје корисничко име.\n\nОва апликација отвореног кода је у развоју, ако нађете проблеме, молимо вас да их објавите на нашем форуму: https://github.com/orgs/meshtastic/discussions\n\nЗа више информација посетите нашу веб страницу - www.meshtastic.org. Ti Prihvati Otkaži Сачувај Primljen novi link kanala - Prijavi grešku - Prijavi grešku - \"Da li ste sigurni da želite da prijavite grešku? Nakon prijavljivanja, molimo vas da postavite na https://github.com/orgs/meshtastic/discussions kako bismo mogli da povežemo izveštaj sa onim što ste pronašli. Izveštaj - Uparivanje završeno, pokrećem servis - Uparivanje neuspešno, molim izaberite ponovo Pristup lokaciji je isključen, ne može se obezbediti pozicija mreži. Podeli Raskačeno Uređaj je u stanju spavanja IP adresa: Блутут повезан - Povezan na radio uređaj (%1$s) Nije povezan Povezan na radio uređaj, ali uređaj je u stanju spavanja Nepohodno je ažuriranje aplikacije @@ -170,6 +151,7 @@ Тамна Прати систем Одабери тему + Стандардно Обезбедите локацију телефона меш мрежи Обриши поруку? @@ -250,7 +232,6 @@ Влажност Дневници Скокова удаљено - Скокови удаљености: %1$d Информација Искоришћење за тренутни канал, укључујући добро формиран TX, RX и неисправан RX (такође познат као шум). Проценат искоришћења ефирског времена за пренос у последњем сату. @@ -259,14 +240,10 @@ Шифровање јавним кључем Неусаглашеност јавних кључева Обавештење о новом чвору - Више детаља 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 @@ -286,11 +263,10 @@ %d skokova Skokova ka %1$d Skokova nazad %2$d + Нема одговора 28č - 48č 1n 2n - 4n Maksimum Непозната старост Kopiraj @@ -314,7 +290,6 @@ Низак ниво батерије: %1$s Нотификације о ниском нивоу батерије (омиљени чворови) Омогућено - UDP конфигурација Корисник Канали Уређај @@ -342,7 +317,6 @@ Поруке Подешавања ензора откривања Пријатељски назив - Уређај Улога уређаја Дугме GPIO Звучни сигнал GPIO @@ -372,20 +346,19 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Raskačeno + Блутут повезан Адреса Корисничко име Лозинка - Мрежа Опције вајфаја Омогућено Етернет опције - Позиција Ширина Дужина Заставице позиције Подешавања напајња Конфигурација теста домета - Сигурност Javni ključ Privatni ključ Подешавања серијске везе @@ -397,6 +370,7 @@ Дуго име Кратко име Udaljenost + Брзина ветра Квалитет ваздуха у затвореном простору (IAQ) Хардвер @@ -448,7 +422,6 @@ Ukloni Увек укључен - Додај канале Линк канала Генерисање QR кода @@ -456,5 +429,5 @@ Блутут Напајано - Нема повезаних уређаја + Filter diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 4cedcc3cb..5bfbb0a84 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -20,7 +20,6 @@ Филтер очисти филтер чворова Укључи непознато - Прикажи детаље А-Ш Канал Удаљеност @@ -31,6 +30,7 @@ Некатегорисано Чека на потврду У реду за слање + Непознато Потврђено Нема руте Примљена негативна потврда @@ -47,34 +47,22 @@ Непознат јавни кључ Лош кључ сесије Јавни кључ није ауторизован - Клијент Повезана апликација или самостални уређај за слање порука. - Клијент мутиран Уређај који не прослеђује пакете примљене од других уређаја. - Рутер Инфраструктурни чвор за проширење покривености мреже прослеђивањем порука. Видљив на листи чворова. Комбинација и РУТЕРА и КЛИЈЕНТА. Нису намењени за мобилне уређаје. - Поновљач Инфраструктурни чвор за проширење покривености мреже прослеђивањем порука са минималним трошковима енергије. Није видљив на листи чворова. - Трекер Емитује пакете са GPS позицијом као приоритет. - Сензор Емитује телеметријске пакете као приоритет. Оптимизован за комуникацију са ATAK системом, смањује рутинске емисије. - Скривени клијент Уређај који емитује само по потреби ради прикривености или уштеде енергије. - Изгубљено и нађено Редовно емитује локацију као поруку подразумеваном каналу ради помоћи при проналаску уређаја. - ТАК Трекер Омогућава аутоматске TAK PLI емисије и смањује рутинске емисије. - Рутер са кашњењем Инфраструктурни чвор који увек поново емитује пакете само једном, али тек након свих других режима, обезбеђујући додатно покривање за локалне кластере. Видљиво на листи чворова. - Сви Поново преноси сваку примећену поруку, ако је била на нашем приватном каналу или из друге мреже са истим LoRA параметрима. Исто као понашање као ALL, али прескаче декодирање пакета и једноставно их поново преноси. Доступно само у Repeater улози. Постављање овога на било коју другу улогу резултираће ALL понашањем. Игнорише примећене поруке из страних мрежа које су отворене или оне које не може да декодира. Поново преноси поруку само на локалне примарне/секундарне канале чвора. Игнорише примећене поруке из страних мрежа као LOCAL ONLY, али иде корак даље тако што такође игнорише поруке са чворова који нису већ на листи познатих чворова. - Ништа Дозвољено само за улоге SENSOR, TRACKER и TAK_TRACKER, ово ће онемогућити све поновне преносе, слично као улога CLIENT_MUTE. Игнорише пакете са нестандардним бројевима порта као што су: TAK, RangeTest, PaxCounter, итд. Поново преноси само пакете са стандардним бројевима порта: NodeInfo, Text, Position, Telemetry и Routing. Третирај двоструки тап на подржаним акцелерометрима као притисак корисничког дугмета. @@ -123,25 +111,18 @@ QR код Непознато корисничко име Пошаљи - Још нисте упарили Мештастик компатибилан радио са овим телефоном. Молимо вас да упарите уређај и поставите своје корисничко име.\n\nОва апликација отвореног кода је у развоју, ако нађете проблеме, молимо вас да их објавите на нашем форуму: https://github.com/orgs/meshtastic/discussions\n\nЗа више информација посетите нашу веб страницу - www.meshtastic.org. Ти Прихвати Откажи Сачувај Примљен нови линк канала - Пријави грешку - Пријави грешку - Да ли сте сигурни да желите да пријавите грешку? Након пријаве, молимо вас да објавите на https://github.com/orgs/meshtastic/discussions како бисмо могли да упаримо извештај са оним што сте нашли. Извештај - Упаривање завршено, покрећем сервис - Упаривање неуспешно, молимо изабери поново Приступ локацији је искључен, не може се обезбедити позиција мрежи. Подели Раскачено Уређај је у стању спавања IP адреса: Блутут повезан - Повезан на радио уређај (%1$s) Није повезан Повезан на радио уређај, али уређај је у стању спавања Неопходно је ажурирање апликације @@ -170,6 +151,7 @@ Тамна Прати систем Одабери тему + Стандардно Обезбедите локацију телефона меш мрежи Обриши поруку? @@ -250,7 +232,6 @@ Влажност Дневници Скокова удаљено - Скокови удаљености: %1$d Информација Искоришћење за тренутни канал, укључујући добро формиран TX, RX и неисправан RX (такође познат као шум). Проценат искоришћења ефирског времена за пренос у последњем сату. @@ -259,14 +240,10 @@ Шифровање јавним кључем Неусаглашеност јавних кључева Обавештења о новим чворовима - Више детаља SNR - Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI - Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу. Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500. Метрика уређаја - Мапа чворова Позиција Метрике сензора Администрација @@ -286,11 +263,10 @@ %d скокова Скокови ка %1$d Скокови назад %2$d + Нема одговора 24ч - 48ч - Максимум Непозната старост Копирај @@ -314,7 +290,6 @@ Низак ниво батерије: %1$s Нотификације о ниском нивоу батерије (омиљени чворови) Омогућено - UDP конфигурација Корисник Канали Уређај @@ -342,7 +317,6 @@ Поруке Подешавања ензора откривања Пријатељски назив - Уређај Улога уређаја Дугме GPIO Звучни сигнал GPIO @@ -372,20 +346,19 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Раскачено + Блутут повезан Адреса Корисничко име Лозинка - Мрежа Опције вајфаја Омогућено Етернет опције - Позиција Ширина Дужина Заставице позиције Подешавања напајња Конфигурација теста домета - Сигурност Јавни кључ Приватни кључ Подешавања серијске везе @@ -397,6 +370,7 @@ Дуго име Кратко име Раздаљина + Брзина ветра Квалитет ваздуха у затвореном простору (IAQ) Хардвер @@ -448,7 +422,6 @@ Уклони Увек укључен - Додај канале Линк канала Генерисање QR кода @@ -456,5 +429,5 @@ Блутут Напајано - Нема повезаних уређаја + Филтер diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 3221bf832..59e19f1e5 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -26,7 +26,6 @@ Dölj offline-noder Visa endast direkta noder Du visar ignorerade noder,\nTryck för att återvända till nodlistan. - Visa detaljer Sortera efter Sorteringsalternativ för noder A-Ö @@ -44,6 +43,8 @@ Okänd Inväntar kvittens Kvittens köad + Levererad till nät + Okänd Kvitterad Ingen rutt Misslyckad kvittens @@ -61,43 +62,24 @@ Felaktig sessionsnyckel Obehörig publik nyckel PKI-sändningen misslyckades, ingen offentlig nyckel - Client App uppkopplad eller fristående nod. - Client Mute Nod som inte vidarebefordrar meddelanden. - Client Base Hantera paket till och från favoritnoder som ROUTER_LATE och alla andra paket som CLIENT. - Router Nod som utökar nätverket igenom att vidarebefordra meddelanden. Syns i nod listan. - Router Client Kombinerad ROUTER och CLIENT. Ej för mobila noder. - Repeater Nod som utökar nätverket igenom att vidarebefordra meddelanden utan egen information. Syns ej i nod listan. - Tracker Nod som prioriterar GPS meddelanden. - Sensor Nod som prioriterar telemetri meddelanden. - TAK Roll optimerad för användning tillsammans med ATAK. - Client Hidden Nod som endast kommunicerar vid behov för att gömma sig och samtidigt hålla nere strömförbrukningen. - Hittegods Skickar regelbundet ut GPS position på standardkanalen för att assistera vid uppsökande. - TAK Tracker Skickar automatiskt ut GPS position för användning med ATAK. - Router Late Nod som utökar nätverket igenom att vidarebefordra meddelanden men endast efter alla noder. Syns i nod listan. - Alla Vidarebefordra alla mottagna meddelanden med samma lora inställningar. - Hoppa över all avkodning Vidarebefordra alla mottagna meddelanden med samma lora inställningar utan avkodning. Endast valbar som REPEATER. Om vald med annan roll används ALL. - Endast lokalt Ignorerar mottagna meddelanden från okända kanaler som är öppna eller krypterade. Vidarebefordrar endast meddelanden för nodens primära och sekundära kanaler. - Endast kända Ignorerar mottagna meddelanden från okända meshnätverk som är öppna eller krypterade samt från noder som inte finns i nod listan. Vidarebefordrar endast meddelanden för kända kanaler. - Ingen Endast för SENSOR, TRACKER och TAK_TRACKER. Stoppar all annan vidarebefordran av meddelanden. - Endast kärnportnummer Ignorerar meddelanden från icke-standard portnummer. Exempelvis: TAK, RangeTest, PaxCounters, etc. Vidarebefordrar endast standard portnummer. Exempelvis: NodeInfo, Text, Position, Telemetri och Routing. Dubbelklick på supporterad accelerometer räknas som användarknapp. Skicka en position på den primära kanalen när användarknappen är trippelklickad. @@ -163,7 +145,6 @@ QR-kod Okänt användarnamn Skicka - Du har ännu inte parat en Meshtastic-kompatibel radio med den här telefonen. Koppla ihop en enhet och ange ditt användarnamn.\n\nDetta öppna källkodsprogram (open source) är under utveckling, om du hittar problem, vänligen publicera det på vårt forum: https://github.com/orgs/meshtastic/discussions\n\nFör mer information se vår webbsida - www.meshtastic.org. Du Tillåt analys och kraschrapportering. Acceptera @@ -171,23 +152,15 @@ Släng Spara Ny kanal-länk mottagen - Meshtastic behöver platsbehörigheter aktiverade på telefonen för att hitta nya enheter via Bluetooth. Du kan inaktivera när den inte används. - Rapportera bugg - Rapportera bugg - Är du säker på att du vill rapportera en bugg? Efter rapportering, vänligen posta i https://github.com/orgs/meshtastic/discussions så att vi kan matcha rapporten med buggen du hittat. Rapportera - Parkoppling slutförd, startar tjänst - Parkoppling misslyckades, försök igen Platsåtkomst är avstängd, kan inte leverera position till meshnätverket. Dela Ny nod: %1$s Frånkopplad Enheten i sovläge - Anslutna: %1$s online IP-adress: Port: Ansluten - Ansluten till radioenhet (%1$s) Aktuella anslutningar: Wifi IP: Ethernet IP: @@ -202,14 +175,11 @@ Tjänsteaviseringar Bekräftelser Denna kanal-URL är ogiltig och kan inte användas - Denna kontakt är ogiltig och kan inte läggas till Felsökningspanel Avkodad nyttolast: Exportera loggar - Exporten avbröts %1$d loggar exporterade Det gick inte att skriva loggfil: %1$s - Inga loggar att exportera %1$d timme %1$d timmar @@ -229,7 +199,6 @@ Rensa alla filter Lägg till anpassat filter Förinställda filter - Visa endast ignorerade noder Spara meshnätsloggar Töm loggar Matcha någon <unk> alla @@ -284,9 +253,7 @@ Stäng av Enhet stöder inte avstängning ⚠️ Detta kommer STÄNGA AV noden. Fysisk interaktion kommer att krävas för att slå på den. - ⚠️ Detta är en viktig infrastrukturnod. Skriv nodens namn för att bekräfta: Nod: %1$s - Typ: %1$s Starta om Traceroute (spåra rutt) Visa introduktion @@ -298,9 +265,7 @@ Skicka direkt Visa snabbchattsmenyn Dölj snabbchattsmenyn - Visa snabbchatten Återställ till standardinställningar - Bluetooth är inaktiverat. Aktivera den i inställningarna för enheten. Öppna inställningar Fast programversion: %1$s Meshtastic behöver \"Närliggande enheter\"-behörigheter aktiverade för att hitta och ansluta till enheter via Bluetooth. Du kan inaktivera när den inte används. @@ -308,6 +273,7 @@ Nollställ NodeDB Sändning bekräftad Fel + Okänt fel Ignorera Ta bort från ignorerade Lägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring. @@ -342,7 +308,6 @@ Ta bort Denna nod kommer att tas bort från din lista till dess att din nod tar emot data från den igen. Tysta notifieringar - 1 timme 8 timmar 1 vecka Alltid @@ -362,7 +327,6 @@ Fukthalt i jord Loggar Hopp bort - Antal hop: %1$d Information Utnyttjande av den nuvarande kanalen, inklusive välformad TX, RX och felformaterad RX (sk. brus). Procent av luftrumstid använd för sändningar inom den senaste timmen. @@ -375,14 +339,10 @@ Publik nyckel matchar inte Användarinfo Ny nod avisering - Mer detaljer 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 @@ -409,15 +369,13 @@ Denna trafikspårning har inte några mappbara noder ännu. Visar %1$d/%2$d noder Varaktighet: %1$s s - %1$s • %2$s Rutt spårad mot destination:\n\n Rutten spårad tillbaka till oss:\n\n + Inget svar 1h 24T - 48T 1V 2V - 4V 1m Max Okänd ålder @@ -443,8 +401,6 @@ Meddelanden om lågt batteri (favoritnoder) Tryck Aktiverad - UDP-sändning - UDP-konfiguration Senast hörd: %2$s
Senaste position: %3$s
Batteri: %4$s]]>
Växla min position Orientera mot norr @@ -514,7 +470,6 @@ Visningsnamn GPIO-pin att övervaka Använd INPUT_PULLUP-läge - Enhet Enhetens roll GPIO för knapp GPIO för summer @@ -561,7 +516,6 @@ Bandbredd Spridningsfaktor Kodningshastighet - Frekvensförskjutning (MHz) Region Antal hopp Sändning aktiverad @@ -574,6 +528,10 @@ Ignorera MQTT Ok till MQTT MQTT-konfiguration + Frånkopplad + Ansluten + Testa anslutningen + Anslutningen misslyckades MQTT är aktiverat Adress Användarnamn @@ -586,7 +544,6 @@ Grannskapsinformation aktiverat Uppdateringsintervall (sekunder) Skicka över LoRa - Nätverk WiFi-alternativ Aktiverad WiFi är aktiverat @@ -599,33 +556,22 @@ IPv4-läge Ip-adress Gateway + DNS Konfiguration av PAX-räknare PAX-räknare aktiverad Statusmeddelande Inställningar för statusmeddelande Själva statustexten - Plats - Sändningsintervall av position (sekunder) - Smart position aktiverad - Smart sändning minsta avstånd (meter) - Minsta intervall för smart sändning (sekunder) - Använd en fast position Latitud Longitud - Höjd (meter) Ställ in från aktuell telefonplats GPS-läge (fysisk maskinvara) - GPS uppdateringsintervall (sekunder) - Omdefiniera GPS_RX_PIN - Omdefiniera GPS_TX_PIN - Omdefiniera PIN_GPS_EN Positionsflaggor Ströminställningar Aktivera strömsparläge Stäng av vid strömförlust Vänta in Bluetooth (sekunder) Tid för djup strömsparläge - Tid för lätt strömsparläge Batteriets INA_2XX I2C-adress Räckvidstest konfiguration Räckvidstest aktiverat @@ -634,7 +580,6 @@ Konfiguration av fjärrhårdvara Fjärrhårdvara aktiverad Tillgängliga pin - Säkerhet Knapp för direktmeddelanden Admin-nycklar Publik nyckel @@ -693,8 +638,6 @@ Användar-ID Upptid Ladda %1$d - Hämtar kanal %1$d/%2$d - Hämtar %1$s Ledigt lagringutrymme %1$d Tidsstämpel Riktning @@ -711,7 +654,6 @@ Tryck och dra för att ändra ordning Ljud på Dynamisk - Skanna QR-kod Dela kontakt Anteckningar Lägg till en privat anteckning @@ -728,7 +670,6 @@ Miljövärden Luftkvalitetsdata Strömdata - Lokal statistik Begär värdens värden Metadata Åtgärder @@ -738,7 +679,6 @@ Värdstatistik Värd Ledigt minne - Ledig lagring Ladda Användarens sträng Navigera till @@ -776,8 +716,6 @@ (%1$d aktiva / %2$d visas / %3$d totalt) Reagera Koppla från - Inga nätverksenheter hittades. - Inga USB-seriella enheter hittades. Gå till slutet Meshtastic Säkerhetsstatus @@ -793,8 +731,6 @@ Rensa noddatabas Rensa bort noder som sågs för minst %1$d dagar sedan Rensa endast okända noder - Rensa upp noder med låg/ingen interaktion - Rensa ignorerade noder Rensa nu Detta kommer att ta bort %1$d noder från din databas. Denna åtgärd kan inte ångras. Ett grönt lås innebär att kanalen är säkert krypterad med antingen en 128 eller 256 bitars AES-nyckel. @@ -813,9 +749,6 @@ Visa alla betydelser Visa aktuell status Stäng - Är du säker på att du vill ta bort den här noden? - Glöm anslutningen - Är du säker på att du vill glömma den här anslutningen? Svarar till %1$s Avbryt svar Ta bort meddelanden? @@ -824,7 +757,6 @@ Skriv ett meddelande PAX Blåtandsenheter - Parkopplade enheter Ansluten enhet Sändningsgräns uppnådd. Försök igen senare. Visa version @@ -874,17 +806,13 @@ Konfigurera kritiska larm Meshtastic använder aviseringar för att hålla dig uppdaterad om nya meddelanden och andra viktiga händelser. Du kan uppdatera dina aviseringsbehörigheter när som helst från inställningar. Nästa - Ge behörigheter %1$d noder köade för radering: Varning: Detta tar bort noder från både appen och enhetens databaser.\nMarkeringar är inklusive. - Ansluter till enhet Normal Satellit Terräng Hybrid Hantera kartlager - Kartlager - Lägg till lager Dölj lager Visa lager Ta bort lager @@ -917,18 +845,15 @@ 48 timmar Filtrera på senaste kontakt: %1$s %1$d dBm - Saknar applikation för att hantera länken. Systeminställningar Ingen tillgänglig statistik Mätdata samlas in för att hjälpa oss att förbättra Android-appen (tack), vi kommer att få anonymiserad information om användarnas beteende. Detta inkluderar kraschrapporter, skärmar som används i appen etc. Analysplattformar: För mer information, se vår integritetspolicy. Odefinierad - 0 - Vidaresänt av: %1$s Läs mer Visa inte igen för denna enhet Behåll favoriter? - USB-enheter Uppdatering av fast programvara Söker efter uppdateringar... @@ -943,9 +868,7 @@ Försök igen Uppdatering lyckades! Klart - Uppdaterar... %1$s Validerar fast programvara... - Kopplar från... Okänd hårdvarumodell: %1$d Ingen ansluten enhet Kunde inte hitta fast programvara för %1$s i utgåvan. @@ -967,15 +890,9 @@ Väntar på att enheten ska återansluta... Versionsinformation Okänt fel - Lokal uppdatering misslyckades Kunde inte hämta den inbyggda programvaran. USB-uppdateringen misslyckades - Laddar fast programvara... - Kontrollerar enhetsversion... Laddar upp fast programvara... - Startar om enhet... - Uppdatering av fast programvara - Status för uppdatering av programvara Raderar... Tillbaka Ej inställd @@ -995,7 +912,6 @@ Väntar på en GPS-position för att beräkna avstånd och riktning. Markera som läst Nu - Lägg till kanaler Denna QR-kod innehåller en komplett konfiguration. Detta kommer att ERSÄTTA dina befintliga kanaler och radioinställningar. Alla befintliga kanaler kommer att tas bort. Laddar @@ -1030,8 +946,9 @@ Blått Grönt Modul aktiverad - Ingen ansluten enhet - Laddar ner programvara 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 c30946642..75a9e3a5d 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -21,7 +21,6 @@ Filtre düğüm filtresini kaldır Bilinmeyenleri dahil et - Detayları göster Düğüm sıralama seçenekleri A-Z Kanal @@ -34,6 +33,7 @@ Tanınmayan Ulaştı bildirisi bekleniyor Gönderilmek üzere sırada + Bilinmeyen Onaylandı Rota yok Negatif bir onay alındı @@ -62,12 +62,10 @@ Cihazın kurtarılmasına yardımcı olmak için konumunu düzenli olarak varsayılan kanala mesaj olarak gönderir. Rutin yayınları azaltarak otomatik TAK PLI yayınlarını etkinleştirir. Tüm diğer modlardan sonra paketleri her zaman bir kez yeniden yayınlayan ve yerel kümeler için ek kapsama alanı sağlayan altyapı düğümü. Düğümler listesinde görünür. - Hepsi Tespit edilen herhangi bir mesajı, özel kanalımızdaysa veya aynı LoRa parametrelerine sahip başka bir ağdan geliyorsa yeniden yayınlayın. ALL ile aynı davranış, ancak paketleri çözmeksizin yeniden yayınlar. Yalnızca Repeater rolünde kullanılabilir. Bunu başka herhangi bir rolde ayarlamak ALL davranışıyla sonuçlanacaktır. Açık olan veya şifresini çözemediği yabancı ağlardan geldiği tespit edilen mesajları yok sayar. Yalnızca düğümlerin yerel birincil / ikincil kanallarında mesajı yeniden yayınlar. LOCAL ONLY gibi yabancı ağlardan geldiği tespit edilen mesajları yok sayar, ancak düğümün bilinen listesinde bulunmayan düğümlerden gelen mesajları da yok sayarak bir adım daha ileri gider. - Yok Yalnızca SENSOR, TRACKER ve TAK_TRACKER rolleri için izin verilir, CLIENT_MUTE rolünden farklı olarak tüm yeniden yayınları engeller. TAK, RangeTest, PaxCounter gibi standart olmayan portnum'ları yok sayarken sadece standart portnum'lar olan NodeInfo, Text, Position, Telemetry ve Routing'i yeniden yayınlar. Desteklenen ivmeölçerlere çift dokunmayı kullanıcı düğmesine basma olarak değerlendirir. @@ -80,27 +78,19 @@ Karekod Bilinmeyen kullanıcı adı Gönder - Telefonu, Meshtastic uyumlu bir cihaz ile eşleştirmediniz. Bir cihazla eşleştirin ve kullanıcı adınızı belirleyin.\n\nAçık kaynaklı bu uygulama şu an alfa-test aşamasında, problem fark ederseniz forumda lütfen paylaşın: https://github.com/orgs/meshtastic/discussions\n\nDaha fazla bilgi için, sitemiz: www.meshtastic.org. Siz Kabul et İptal Kaydet Yeni Kanal Adresi(URL) alındı - Hata Bildir - Hata Bildir - Hata bildirmek istediğinizden emin misiniz? Hata bildirdikten sonra, lütfen https://github.com/orgs/meshtastic/discussions sayfasında paylaşınız ki raporu bulgularınızla eşleştirebilelim. Bildir - Eşleşme tamamlandı, servis başlatılıyor - Eşleşme başarısız, lütfen tekrar seçiniz Konum erişimi kapalı, konum ağ ile paylaşılamıyor. Paylaş Bağlantı kesildi Cihaz uyku durumunda - Bağlı: %1$s çevrimiçi IP Adresi: Bağlantı noktası: Bağlandı - (%1$s) telsizine bağlandı Bağlanıyor Bağlı değil Bilinmeyen Cihaz @@ -222,13 +212,9 @@ Genel Anahtar Şifrelemesi Genel Anahtar Uyuşmazlığı Yeni düğüm bildirimleri - Daha fazla detay 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 @@ -247,10 +233,8 @@
İleri atlama %1$d Geri atlama %2$d 24S - 48S 1H 2H - 4H Maks Bilinmeyen Yaş Kopyala @@ -271,7 +255,6 @@ Düşük pil: %1$s Düşük pil bildirimleri (favori düğümler) Açık - UDP Ayarları Son duyulma: %2$s
Son konum: %3$s
Pil: %4$s]]>
Konumunumu aç/kapa Kullanıcı @@ -346,7 +329,6 @@ İzlenecek GPIO pini Algılama tetikleme türü INPUT_PULLUP modu kullan - Cihaz Node Bilgisi Yayın Aralığı Pusula kuzey üstte Ekranı Çevir @@ -375,13 +357,14 @@ LoRa Gelişmiş Bant genişliği - Frekans kayması (MHz) Bölge Görev Döngüsünü Geçersiz Kıl Gelenleri Yoksay PA fanı devre dışı MQTT'yi Yoksay MQTT Yapılandırması + Bağlantı kesildi + Bağlandı MQTT etkin Adres Kullanıcı adı @@ -397,7 +380,6 @@ Komşu Bilgisi etkin Güncelleme aralığı (saniye) LoRa üzerinden ilet - Açık WiFi etkin SSID @@ -408,26 +390,15 @@ IPv4 modu IP Ağ geçidi + DNS Pax sayacı Ayarı Pax sayacı etkin WiFi RSSI eşiği (varsayılan -80) BLE RSSI eşiği (varsayılan -80) - Konum - Konum yayılma aralığı (saniye) - Akıllı konum etkin - Akıllı yayılma minimum mesafe (metre) - Akıllı yayılma minimum aralık (saniye) - Sabit konum kullan Enlem Boylam - Yükseklik (metre) - GPS güncelleme aralığı (saniye) - GPS_RX_PIN’i yeniden tanımla - GPS_TX_PIN’i yeniden tanımla - PIN_GPS_EN’i yeniden tanımla Güç Ayarı Güç tasarrufu modunu etkinleştir - Pilin kapanma gecikmesi (saniye) ADC çarpanını geçersiz kılma oranı Pilin INA_2XX I2C adresi Menzi Test Ayarı @@ -438,7 +409,6 @@ Uzak Donanım etkin Tanımlanmamış pin erişimine izin ver Mevcut pinler - Güvenlik Genel Anahtar Özel Anahtar Yönetici Anahtarı @@ -506,7 +476,6 @@ Yeniden sıralamak için basılı tutup sürükleyin Sesi aç Dinamik - QR Kodu Tara Kişiyi paylaş Paylaşılan kişiyi içe aktar? Mesaj gönderilemez @@ -522,7 +491,6 @@ Sunucu Ölçümleri Sunucu Boş Hafıza - Boş Disk Yükle Kullanıcı Karakter Dizisi Düğümler @@ -545,7 +513,6 @@ Vazgeç - Bu node silinsin mi? Mesaj Mesaj yaz İndir @@ -564,12 +531,10 @@ 24 Saat 48 Saat - Bağlantı Kesiliyor... Güncelleme başarısız Ayarlanmamış Şimdi - Kanal Ekle QR kod oluştur Hepsi @@ -580,4 +545,6 @@ Mavi Yeşil Bağlan + Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 85a63617f..c9a86af43 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -26,7 +26,6 @@ Сховати вузли не в мережі Показувати лише прямі вузли Ви переглядаєте ігноровані вузли,\nНатисніть щоб повернутися до списку вузлів. - Показати деталі Сортувати за Опції сортування вузлів A-Z @@ -53,32 +52,16 @@ Невідомий відкритий ключ Несанкціонований відкритий ключ Помилка надсилання PKI, відсутній публічний ключ - Клієнт Застосунок з'єднано або автономний режим обміну повідомленнями. - Client Mute Пристрій, який не пересилає пакети з інших пристроїв. - Client Base Розглядає пакети від або до улюблених вузлів так само як ROUTER_LATE, а всі інші пакети як CLIENT. - Router Вузол інфраструктури для розширення покриття мережею повторними повідомленнями. Видимий у списку вузлів. - Router Client Комбінація ROUTER і CLIENT. Не для мобільних пристроїв. - Repeater - Трекер - Датчик Пріоритетна передача пакетів телеметрії. - ТАК Оптимізовано для з'єднання з системою ATAK, зменшує рутинні радіо трансляції. - Client Hidden Пристрій, який передає лише у разі потреби для економії енергії або скритності. - Loast and Found - TAK Tracker Увімкнути автоматичну передачу TAK PLI та зменшити кількість звичайних трансляцій. - Router Late - Усі Така сама поведінка, як і ВСІ (ALL), але пропускає декодування і просто пересилає їх. Доступно лише в ролі Repeater. Установка цієї опції на будь-які інші ролі призведе до поведінки ВСІ. - Лише локальні - Лише відомі Ігнорує отримані повідомлення від чужих мереж, як-от LOCAL ONLY, але робить крок далі, також ігноруючи повідомлення від вузлів, яких немає в списку відомих вузлів. Дозволяється лише для таких ролей, як SENSOR, TRACKER та TAK_TRACKER, і гальмуватиме всі перенаправлення, на відміну від ролі CLIENT_MUTE. Часовий пояс для дати на екрані та журналі пристрою. @@ -117,7 +100,6 @@ QR код Невідомий користувач Надіслати - Ви ще не підєднали пристрій, сумісний з Meshtastic. Будьласка приєднайте пристрій і введіть ім’я користувача.\n\nЦя програма з відкритим вихідним кодом знаходиться в розробці, якщо ви виявите проблеми, опублікуйте їх на нашому форумі: https://github.com/orgs/meshtastic/discussions\n\nДля отримання додаткової інформації відвідайте нашу веб-сторінку - www.meshtastic.org. Ви Дозволити аналітику і звіти про збої Прийняти @@ -125,22 +107,15 @@ Відхилити Зберегти Отримано URL-адресу нового каналу - Повідомити про помилку - Повідомити про помилку - Ви впевнені, що бажаєте повідомити про помилку? Після звіту опублікуйте його в https://github.com/orgs/meshtastic/discussions, щоб ми могли зіставити звіт із тим, що ви знайшли. Звіт - Пара створена, запуск сервісу - Не вдалося створити пару, виберіть ще раз Доступ до місцезнаходження вимкнено, неможливо транслювати позицію. Поділіться Виявлено новий вузол: %1$s Відключено Пристрій в режимі сну - Під'єднано: %1$s онлайн IP Адреса: Порт: Під’єднано - Підключено до радіомодуля (%1$s) Поточні з'єднання: Wi-Fi IP: IP Ethernet: @@ -155,7 +130,6 @@ URL-адреса цього каналу недійсна та не може бути використана Панель налагодження Експортувати журнали - Експорт скасовано %1$d журналів експортовано Не вдалося записати файл журналу: %1$s @@ -180,7 +154,6 @@ Очистити всі фільтри Додати свій фільтр Готові фільтри - Показати лише ігноровані вузли Очистити журнал Очистити Канал @@ -230,9 +203,7 @@ Вимкнути Вимкнення не підтримується на цьому пристрої ⚠️ Це призведе до ВИМКНЕННЯ вузла. Знадобиться фізична взаємодія для його увімкнення. - ⚠️ Це критичний інфраструктурний вузол. Введіть назву вузла для підтвердження: Вузол: %1$s - Вузол: %1$s Перевантажити Маршрут Показати підказки @@ -244,15 +215,14 @@ Миттєво відправити Показати меню швидкого чату Приховати меню швидкого чату - Показати швидкий чат Скинути до заводських налаштувань - Bluetooth вимкнено. Будь ласка, увімкніть його в налаштуваннях вашого пристрою. Відкрити налаштування Версія прошивки: %1$s Пряме повідомлення Очищення бази вузлів Доставку підтверджено Помилка + Невідома помилка Ігнорувати Вилучити з ігнорованих Додати '%1$s' до чорного списку? Після цієї зміни ваш пристрій перезавантажиться. @@ -286,7 +256,6 @@ Видалити Цей вузол буде видалений зі списку доки ваш вузол не отримає дані з нього знову. Вимкнути сповіщення - 1 година 8 годин 1 тиждень Завжди @@ -306,12 +275,9 @@ Не збігаються відкритий ключ Дані користувача Сповіщення про нові вузли - Докладніше SNR RSSI - Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання. Показники пристрою - Мапа вузлів Місцезнаходження Показники довкілля Адміністрування @@ -326,15 +292,12 @@ Переглянути на мапі Показується %1$d/%2$d вузлів Тривалість: %1$s сек - %1$s - %2$s Маршрут у напрямку призначення:\n\n Зворотний маршрут до нас:\n\n 24Г - 48Г - Макс Копіювати @@ -356,8 +319,6 @@ Низький заряд батареї: %1$s Сповіщення про низький рівень заряду акумулятора (улюблені вузли) Увімкнено - UDP трансляція - Налаштування UDP Користувач Канали Пристрій @@ -406,7 +367,6 @@ Дружня назва GPIO контакт для моніторингу Використовувати режим INPUT_PULLUP - Пристрій Роль пристрою GPIO кнопки GPIO гудка @@ -432,7 +392,6 @@ Використовувати пресет Пресети Швидкість кодування - Зсув частоти (МГц) Регіон Потужність передачі Слот частоти @@ -440,6 +399,9 @@ Перевизначити частоту Ігнорувати MQTT Налаштування MQTT + Відключено + Під’єднано + Перевірка зʼєднання MQTT увімкнений Адреса Ім'я користувача @@ -452,7 +414,6 @@ Інформацію про сусідів увімкнено Інтервал оновлення (секунд) Передавати через LoRa - Мережа Налаштування WiFi Увімкнено WiFi увімкнено @@ -465,26 +426,18 @@ Режим IPv4 IP-адреса Шлюз + DNS RSSI поріг WiFi (за замовчуванням -80) RSSI поріг BLE (за замовчуванням -80) - Місцезнаходження - Використовувати зафіксоване місцезнаходження Широта Довгота - Висота (метри) - Інтервал оновлення GPS (в секундах) - Перевизначити GPS_RX_PIN - Перевизначити GPS_TX_PIN - Перевизначити PIN_GPS_EN Налаштування живлення Увімкнути енергоощадний режим Вимкнути при втраті живлення - Вимкнути при затримці батареї (секунд) Налаштування тесту дальності Тест на відстань увімкнений Зберегти .CSV у сховищі (лише ESP32) Доступні піни - Безпека Ключ адміністратора Відкритий ключ Приватний ключ @@ -533,8 +486,6 @@ ID користувача Час роботи Завантаження %1$d - Отримання каналу %1$d/%2$d - Отримання %1$s Вільне місце %1$d Мітка часу Швидкість @@ -544,7 +495,6 @@ Вторинний Натисніть і перетягніть, щоб змінити порядок Динамічна - Сканувати QR-код Поділитися контактом Нотатки Додати приватну нотатку… @@ -559,7 +509,6 @@ Екологічні показники Показники якості повітря Показники живлення - Локальна статистика Показники хоста Показники Pax Метадані @@ -570,7 +519,6 @@ Показники хоста Хост Вільна пам'ять - Вільне місце Завантажити Підключення Мапа мережі @@ -593,7 +541,6 @@ Експортувати ключі (%1$d онлайн / %2$d показані / %3$d загалом) Від'єднатись - Не знайдено жодного мережевого пристрою. Прокрутити донизу Meshtastic Невідомий канал @@ -602,8 +549,6 @@ Очистити базу даних вузлів Очистити вузли, які не були онлайн більше %1$d дні(в) Очистити лише невідомі вузли - Очистити вузли з низькою/відсутньою взаємодією - Очистити проігноровані вузли Очистити зараз Це призведе до вилучення %1$d вузлів з вашої бази даних. Цю дію не можна скасувати. @@ -614,7 +559,6 @@ Показати всі значення Показати поточний статус Відхилити - Забути з'єднання Скасувати відповідь Видалити повідомлення? Повідомлення @@ -622,7 +566,6 @@ Показники PAX PAX Немає доступних показників PAX. - Прив'язані пристрої Під'єднаний пристрій Переглянути реліз Завантажити @@ -652,16 +595,12 @@ Критичні сповіщення Налаштування критичних оповіщень Далі - Надати дозволи %1$d вузлів поставлено в чергу до видалення: - Під'єднання до пристрою Нормальний Супутниковий Рельєф Гібридний Керування шарами мап - Шари мапи - Додати шар Сховати шар Показати шар Видалити шар @@ -690,7 +629,6 @@ Докладніше Не показувати знову для цього пристрою Зберегти улюблені? - USB пристрої Оновити прошивку Перевірка наявності оновлень... @@ -706,15 +644,12 @@ Оновлення успішне! Готово Запуск DFU... - Оновлення... %1$s Увімкнення режиму DFU... Перевірка прошивки... - Від'єднання... Невідома модель обладнання: %1$d Немає під'єднаних пристроїв Не вдалося знайти прошивку %1$s в релізі. Розпакування прошивки... - Відключення для запуску DFU сервісу... Помилка оновлення Зачекайте, ми над цим працюємо... Тримайте пристрій близько до вашого телефону. @@ -729,7 +664,6 @@ Chirpy каже: \"Тримайся напоготові!\" Chirpy Перезавантаження у DFU... - Очікування пристрою DFU... Будь ласка, збережіть .uf2 файл на DFU диск вашого пристрою. Прошивка пристрою, будь ласка, зачекайте... Передача файлів через USB @@ -744,16 +678,10 @@ Ціль: %1$s Примітки до релізу Невідома помилка - Помилка DFU: %1$s Не вдалося отримати файл прошивки. - Завантаження прошивки... Підключення до пристрою (спроба %1$d/%2$d)... - Перевірка версії пристрою... Запуск OTA оновлення... Завантаження прошивки... - Перезавантаження пристрою... - Оновити прошивку - Статус оновлення прошивки Видалення... Назад Скинути @@ -771,7 +699,6 @@ Пеленг: %1$s Позначити як прочитане Зараз - Додайте канали Завантаження Фільтр повідомлень @@ -782,7 +709,6 @@ Додати слово або regex:pattern Жодного фільтра не налаштовано Шаблон регулярного виразу - %1$d відфільтровано Увімкнути фільтрацію Вимкнути фільтрацію Згенерувати QR-код @@ -794,7 +720,9 @@ Червоний Синій Зелений - Немає під'єднаних пристроїв Під’єднатися Готово + 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 4378a7f86..7fff0db20 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -26,7 +26,6 @@ 隐藏离线节点 仅显示直连节点 您正在查看被忽略的节点,\n点击返回到节点列表。 - 显示详细信息 排序规则 节点排序选项 字母顺序 @@ -41,9 +40,11 @@ 内置 通过收藏夹 仅显示忽略的节点 + 排除MQTT 无法识别的 正在等待确认 发送队列中 + 未知 通过 SF++ 链路路由… 已在 SF++ 链上确认 已确认 @@ -63,43 +64,24 @@ 会话密钥错误 未授权的公钥 PKI 发送失败,无公钥 - 客户端 应用配对或独立使用的消息传递设备 - 客户端静默 不转发其他设备数据包的设备。 - 客户群 将来自或收藏节点的数据包视为ROUTER_LATE,所有其他数据包均为CLIENT。 - 路由 用于通过转发消息扩展网络覆盖范围的基础设施节点。可在节点列表中看到。 - 路由客户端 同时兼具路由器和客户端功能的设备。不适用于移动设备。 - 中继 通过最低开销转发消息扩展网络覆盖的基础设施节点。不可见于节点列表。 - 追踪器 定位模式 - 用于作为 GPS 跟踪器。从该设备发送的定位数据包优先级较高,每两分钟广播一次。智能位置广播默认为关闭。 - 传感器 将遥测数据包优先广播。 - TAK 针对 ATAK 系统通信进行优化,减少常规广播。 - 客户端隐藏 只在需要时才广播的设备,以达到隐蔽或省电的目的。 - 失物招领 定期向默认信道发送位置信息,以协助设备恢复。 - TAK 追踪器 启用自动 TAK PLI(Position Location Information)广播,并减少常规广播。 - 延迟时间 基础设施节点,总是在所有其他模式之后重新广播数据包一次,以确保本地集群的额外覆盖范围。会在节点列表中显示。 - 全部 重新广播任何观察到的消息,无论是来自我们的私有频道还是具有相同 LoRa 参数的其他网状网络。 - 全部跳过解码 与 ALL 模式的行为相同,但跳过数据包解码,仅简单地重新广播它们。仅适用于中继器角色。在其他角色中设置此选项将表现为 ALL 模式。 - 仅本地 忽略来自开放网状网络或无法解密的消息,仅在节点的本地主/次频道上重新广播消息。 - 仅已识别 与 LOCAL_ONLY 类似,忽略来自其他网状网络的消息,但更进一步,忽略来自不在节点已知列表中的节点的消息。 - 仅限 SENSOR、TRACKER 和 TAK_TRACKER 角色,此模式将禁止所有重新广播,与 CLIENT_MUTE 角色类似。 - 仅核心Portnumber 忽略来自非标准端口号(如 TAK、RangeTest、PaxCounter 等)的数据包,仅重新广播标准端口号的数据包:NodeInfo、Text、Position、Telemetry 和 Routing。 将支持的加速度计上的双击操作视为 User 按键的按压动作。 当用户按钮被点击三次时,在主通道上发送定位。 @@ -139,6 +121,7 @@ 我们应该多长时间尝试获取GPS位置(<10秒将GPS保持开启)。 包含的字段越多,信息就越大,导致通讯时间更长,丢包风险更高. 尽可能让所有设备处于睡眠状态,对于跟踪器和传感器来说,这也包括 LoRa 无线电。如果您想将电台与手机 App 一起使用,或使用没有用户按钮的电台,请不要使用此设置。 + 从您的私钥生成并发送到网络上的其他节点,让它们能够计算共享的密钥。 用来创建远程设备共享密钥 授权向该节点发送管理员密钥 设备由 Mesh 管理员管理,用户无法访问任何设备设置。 @@ -165,7 +148,6 @@ QR 码 未知的使用者名称 传送 - 您尚未将手机与 Meshtastic 兼容的装置配对。请先配对装置并设置您的用户名称。\n\n此开源应用程序仍在开发中,如有问题,请在我们的论坛 https://github.com/orgs/meshtastic/discussions 上面发文询问。\n\n 也可参阅我们的网页 - www.meshtastic.org。 报告崩溃信息 接受 @@ -173,23 +155,15 @@ 忽略 保存 收到新的频道 URL - Meshtastic 需要启用位置权限才能通过蓝牙找到新的设备。如果未使用,您可以禁用。 - 报告 Bug - 报告 Bug 详细信息 - 您确定要报告错误吗?报告后,请在 https://github.com/orgs/meshtastic/discussions 上贴文,以便我们可以将报告与您发现的问题匹配。 报告 - 配对完成,启动服务 - 配对失败,请重新选择 位置访问已关闭,无法向网络提供位置信息 分享 新节点: %1$s 已断开连接 设备休眠中 - 已连接:%1$s / 在线 IP地址: 端口: 已连接 - 已连接至设备 (%1$s) 当前连接 Wifi IP地址: 以太网 IP 地址: @@ -211,14 +185,11 @@ Meshtastic 是用以下开源库构建的。点击任何库查看其许可证。 %1$d 库 此频道 URL 无效,无法使用 - 此频道 URL 无效,无法使用 调试面板 解码Payload: 导出程序日志 - 已取消导出 导出%1$d 日志 写入日志文件失败: %1$s - 没有可导出的日志 %1$d 小时 @@ -236,7 +207,6 @@ 清除所有筛选条件 添加过滤器 重置筛选 - 仅显示忽略的节点 储存mesh日志 禁用以跳过将msh日志写入磁盘。 清除日志 @@ -244,8 +214,11 @@ 匹配所有 | 任意 这将从您的设备中移除所有日志数据包和数据库条目 - 完整重置,永久失去所有内容。 清除 + 搜索Emoji…… + 更多反应 频道 %1$s: %2$s + 来自 %1$s: %2$s 的消息 消息传递状态 新消息 私信提醒 @@ -270,6 +243,7 @@ 深色 系统默认设置 选择主题 + 标准 向网格提供手机位置 紧凑的Cyrillic编码 @@ -295,9 +269,7 @@ 关机 此设备不支持关机 ⚠️ 警告!此操作将会关闭该节点。你需要使用电源开关按键才能重启设备~ - 警告:这是一个关键的基础设施节点。请输入节点名称以确认: 节点 (%1$s - 类型: %1$s 重启 追踪器 显示简介 @@ -309,9 +281,7 @@ 立即发送 显示快速聊天菜单 隐藏快速聊天菜单 - 显示快捷消息 恢复出厂设置 - 蓝牙已被禁用。请在您的设备设置中启用它。 打开设置 固件版本 Meshtastic需要启用“附近的设备”权限,以便通过蓝牙查找并连接设备。不使用时,您可以将其禁用。 @@ -320,6 +290,7 @@ 已送达 在应用设置时,您的设备可能会断开连接并重启。 错误 + 未知错误 忽略 从忽略中删除 添加 '%1$s' 到忽略列表? @@ -354,14 +325,12 @@ 移除 此节点将从您的列表中删除,直到您的节点再次收到它的数据。 消息免打扰 - 1 小时 8 小时 1周 始终 当前: 始终静音 非静音 - 静默状态 是否静音通知 '%1$s? 是否静音通知 '%1$s? 替换 @@ -371,9 +340,7 @@ 电池 ChUtil AirUtil - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s %1$s: %2$s 温度 湿度 @@ -381,7 +348,6 @@ 土壤湿度 日志 跃点数 - 越点数: %1$d 信息 当前信道的利用情况,包括格式正确的发送(TX)、接收(RX)以及无法解码的接收(即噪声)。 过去一小时内用于传输的空中占用时间百分比。 @@ -395,14 +361,10 @@ 公钥与输入的密钥不匹配。 您可以移除该节点并让它再次交换密钥,但这可能会出现密钥泄露问题。 请通过另一个受信任的频道来联系用户,以确定密钥更改是否由于出厂重置或其他故意操作。 用户信息 新节点通知 - 查看更多 SNR - 信噪比(Signal-to-Noise Ratio, SNR)是一种用于通信领域的测量指标,用于量化目标信号与背景噪声的比例。在 Meshtastic 及其他无线系统中,较高的信噪比表示信号更加清晰,从而能够提升数据传输的可靠性和质量。 RSSI - 接收信号强度指示(Received Signal Strength Indicator, RSSI)是一种用于测量天线接收到的信号功率的指标。较高的 RSSI 值通常表示更强、更稳定的连接。 室内空气质量(Indoor Air Quality, IAQ):由 Bosch BME680 传感器测量的相对标尺 IAQ 值,取值范围为 0–500。 设备指标 - 节点地图 定位 最后位置更新 传感器指标 @@ -428,15 +390,13 @@ 此轨迹追踪器还没有任何可映射的节点。 显示 %1$d/%2$d 节点 持续时间: %1$s 秒 - %1$s - %2$s 路由追踪到目的地:\n\n 路由回退到当前节点:\n\n + 无响应 1H 24 小时 - 48 小时 1 周 2 周 - 4 周 1M 最大值 未知时长 @@ -462,8 +422,6 @@ 低电量通知 (收藏节点) Baro 启用 - UDP 广播 - UDP 设置 最后听到: %2$s
最后位置: %3$s
电量: %4$s]]>
切换我的位置 朝北 @@ -542,11 +500,9 @@ 状态广播(秒) 发送带有警报消息的响铃声 易记名称 - 友好地址 显示器的 GPIO 引脚 检测触发器类型 使用 输入上拉 模式 - 设备 设备角色 按钮 GPIO 蜂鸣器 GPIO @@ -596,7 +552,6 @@ 带宽 扩散因子 编码率 - 频率偏移(MHz) 区域 节点数 启用传输 @@ -610,6 +565,9 @@ 忽略 MQTT 使用MQTT MQTT设置 + 已断开连接 + 已连接 + 连接测试 启用MQTT 地址 用户名 @@ -625,13 +583,11 @@ 启用邻居信息 更新间隔(秒) 通过 LoRa 传输 - 网络 WiFi设置 启用 启用 WiFi SSID 共享密钥/PSK - 获取文档 以太网选项 启用以太网 NTP 服务器 @@ -640,6 +596,7 @@ IP 网关 子版块 + DNS Paxcount 配置 启用 Paxcount 状态消息 @@ -647,31 +604,18 @@ 当前状态字符串 WiFi RSSI 阈值(默认为-80) BLE RSSI 阈值(默认为-80) - 定位 - 位置广播间隔 (秒) - 启用智能位置 - 智能广播最小距离(米) - 智能广播最小间隔(秒) - 使用固定位置 纬度 经度 - 海拔(米) 根据当前手机位置设置 GPS 模式 (物理硬件) - GPS 更新间隔 (秒) - 重新定义 GPS_RX_PIN - 重新定义 GPS_TX_PIN - 重新定义 PIN_GPS_EN 位置标记 电源配置 启用节能模式 断电时关机 - 电池延迟关闭(秒) ADC 倍数覆盖 ADC乘数修正比率 等待蓝牙持续时间 深度睡眠时间 - 轻度睡眠时间 最小唤醒时间 电池INA_2XX I2C 地址 范围测试设置 @@ -682,7 +626,6 @@ 启用远程硬件 允许未定义的引脚访问 可用引脚 - 安全 私信密钥 管理密钥 公钥 @@ -744,8 +687,6 @@ 用户 ID 正常运行时间 载入 %1$d - 正在获取频道 %1$d/%2$d - 正在获取 %1$s 存储空间剩余 %1$d 时间戳 航向 @@ -762,7 +703,6 @@ 长按并拖动以重新排序 取消静音 动态 - 扫描二维码 分享联系人 添加便笺… @@ -775,13 +715,11 @@ 请求 正在从 %2$s 请求 %1$s 用户信息 - 邻居信息(2.7.15+) 请求远程操作 设备指标 传感器指标 空气质量日志 电源计量日志 - 本地统计数据 主机测量 Pax 计量 元数据 @@ -792,7 +730,6 @@ 主机测量 主机 可用内存 - 可用存储 负载 用户字符串 导航到 @@ -830,8 +767,6 @@ (%1$d 在线 / %2$d 显示 / %3$d 总计) 互动 断开 - 未找到网络设备。 - 未找到 USB 串口设备。 滚动到底部 Meshtastic 安全状态 @@ -847,8 +782,6 @@ 清理节点数据库 清理上次看到的 %1$d 天以上的节点 仅清理未知节点 - 清理低/无交互的节点 - 清理忽略的节点 立即清理 这将从您的数据库中删除 %1$d 节点。 此操作无法撤消。 绿色锁意为频道安全加密,使用128 位或 256 位 AES密钥。 @@ -867,9 +800,6 @@ 显示所有含义 显示当前状态 收起键盘 - 您确定要删除此节点吗? - 删除连接 - 您确定要删除此节点吗? 回复给 %1$s 取消回复 删除消息? @@ -880,7 +810,6 @@ PAX 无可用的 PAX 计量. 蓝牙设备 - 已配对设备 已连设备 超过速率限制。请稍后再试。 查看发行版 @@ -908,7 +837,6 @@ 新发现节点通知。 电池电量低 已连接设备的低电量警报通知。 - 选择按关键值发送的数据包将忽略msg开关和“请勿扰”系统通知中心中的设置。 配置通知权限 手机位置 Meshtastic 通过使用您的手机定位功能来实现多项功能。您可随时通过设置菜单调整定位权限。 @@ -930,19 +858,15 @@ 配置关键警报 Meshtastic 使用通知来随时更新新消息和其他重要事件。您可以随时从设置中更新您的通知权限。 下一步 - 授权 %1$d 节点待删除: 注意:这将从应用内和设备上的数据库中移除节点。\n选择是附加性的。 - 正在连接设备 普通 卫星 地形 混合 管理地图图层 地图图层支持 .kml, .kmz, 或 GeoJSON 格式。 - 地图图层 没有加载地图层 - 添加图层 隐藏图层 显示图层 移除图层 @@ -980,14 +904,12 @@ 48 小时 按最后听到时间筛选:%1$s %1$d dBm - 没有可用的应用程序来处理链接。 系统设置 没有可用的统计信息 我们收集分析数据是为了帮助改进这款安卓应用(感谢您的支持),我们会收到关于用户行为的匿名信息。这包括崩溃报告、应用中使用过的屏幕等内容。 分析平台: 欲了解更多信息,请参阅我们的隐私政策。 未设定 - 0 - 由: %1$s 连接到的 %1$d 中继节点 @@ -996,7 +918,6 @@ 对于RAK WisBlock RAK4631,请使用供应商的串行DFU工具(例如,搭配提供的引导加载程序.zip文件使用adafruit-nrfutil dfu serial)。仅复制.uf2文件无法更新引导加载程序。 此设备再次显示Don't 保留收藏夹? - USB 设备 固件更新 正在检查更新… @@ -1012,16 +933,12 @@ 更新成功! 完成 正在启动 DFU... - 正在升级... %1$s 正在进入DFU模式 正在验证固件... - 断开连接... 未知硬件型号: %1$d - 连接的设备不是BLE设备,或者地址未知(%1$s)。DFU需要BLE设备或模块支持。 设备未连接 未找到 %1$s 的固件。 正在提取固件... - 正在断开连接以启动 DFU 服务... 更新失败 稍等,我们正在处理…… 请将设备靠近您的手机。 @@ -1043,7 +960,6 @@ 请提前备份旧版本固件及降级教程,以备更新失败时恢复设备 Chirpy 正在重启到 DFU…… - 正在等待 DFU 设备... 请稍候,正在复制固件… 请将 .uf2 文件保存到您的设备's DFU 驱动器。 正在刷入设备,请稍候... @@ -1059,24 +975,15 @@ 目标:%1$s 更新日志 未知错误 - 本地升级失败 - DFU 错误: %1$s - DFU 已中止 节点用户信息缺失 无法获取固件文件 - Nordic DFU 更新失败 USB 更新失败 固件hash值错误。设备可能需要正确的hash配置或 bootloader更新。 OTA更新失败: %1$s - 正在载入固件... 正在等待设备重启到 OTA 模式... 正在连接设备(尝试 %1$d/%2$d)... - 正在检查设备版本... 正在开始 OTA更新... 正在上传固件…… - 重启设备... - 固件更新 - 固件更新状态 擦除中... 后退 未设置 @@ -1104,9 +1011,7 @@ 估计区域:精度未知 设为已读 当前 - 增加频道 找到了以下频道,请选择您需要添加的,同时现有频道将被保存。 - 替换频道 & 设置 此二维码包含了完整配置,将替换您现有的频道和无线电设置,所有现有的频道将被删除。 正在加载 @@ -1119,7 +1024,6 @@ 未配置过滤词 正则表达式 完整匹配 - %1$d 已过滤 显示已过滤的 %1$d 隐藏 %1$d 过滤 已过滤 @@ -1140,14 +1044,10 @@ 全部 蓝牙 设置蓝牙权限 - 连接无线电 - 扫描并连接到您的Meshtastic无线电设备 发现 查找并识别附近的Meshtastic设备 配置 无线的方式来管理您的设备设置和频道 - 权限已授予 - 权限不足 地图样式选择 节点: %1$d 在线 / %2$d 总计 运行时间: %1$s @@ -1160,18 +1060,13 @@ 空闲 %1$d / %2$d %1$s - 支持 - Meshtastic 统计 + 已插电 刷新 更新 添加网络图层 - 刷新图层 本地MBTiles 文件 添加本地MBTiles 文件 - 自定义地图源的名称无效,URL模板或本地URI。 - 此名称的自定义瓦片源已存在 - 无法将 MBTiles 文件复制到内部存储 TAK (ATAK) TAK 配置 队伍颜色 @@ -1216,18 +1111,10 @@ 仅本地远程远程(中继) 本地位置(中继) 保留路由跳数 - 尚无消息 - %1$d 未读 - 地图支持将很快到桌面 - 设备未连接 - 更新状态 - 准备好固件更新 - 检查更新 - 下载固件 - 更新设备 备注 - 在启动固件更新之前确认您的设备已完全充电。在更新过程中不要断开连接或断开设备。 连接 完成 - 刷新 + 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 462760c22..20ee6c639 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s 過濾器 清除節點過濾器 篩選條件 @@ -26,7 +27,6 @@ 隱藏離線節點 只顯示直連節點 您正在檢視已忽略的節點\n請返回到節點列表。 - 顯示詳細資料 排序方式 節點排序選項 依名字排序 @@ -41,9 +41,12 @@ 內部傳輸 通過喜好 僅顯示已忽略的節點 + 排除 MQTT 無法識別 正在等待確認 發送佇列中 + 已傳送至 Mesh + 不明 透過 SF++ 鏈路由… 已在 SF++ 鏈上確認 已確認 @@ -63,43 +66,24 @@ 無效的會話金鑰 無法識別公鑰 PKI 傳送失敗,無公開金鑰 - Client 應用程式連接或獨立收發裝置。 - Client Mute 對其他裝置封包不予轉播的節點。 - 客戶端基礎模式 將來自或發往我的最愛節點的封包視為 ROUTER_LATE,其他所有封包視為 CLIENT。 - Router 加強網路覆蓋的中繼基地台節點。顯示在節點列表上。 - Router Client 兼具路由器和用戶端功能的節點。行動裝置不宜使用。 - Repeater 加強網路覆蓋的中繼基地台節點,但轉播時僅添加最低限度的額外負擔(Overhead)。不會顯示在節點列表上。 - Repeater 優先廣播 GPS 位置封包。 - Sensor 優先廣播遙測資料封包。 - TAK 最佳化以供 ATAK 系統通訊使用,減少日常廣播量。 - Client Hidden 基於省電或隱私需求,僅提供最低限度廣播通訊的節點。 - Lost and Found 定期向預設頻道播送定位的裝置,以便於裝置復原。 - TAK Tracker 啓用自動 TAK PLI 廣播,將減少定期廣播。 - Router Late 基礎建設節點,總是在所有其他模式之後才重新廣播一次封包,以確保本地群集有額外的覆蓋範圍。在節點清單中可見。 - 全部 重播任何觀察到的訊息,如果它是在我們的私人頻道上或來自具有相同 lora 參數的其他網路上。 - 忽略所有傳入資料 與「ALL」行為相同,但會跳過封包解碼,僅重新廣播它們。此功能僅適用於中繼器角色。在其他角色上設定此功能將導致「ALL」行為。 - 僅限本地 忽略來自開放的或無法解密的外部 Mesh 觀察到的訊息。僅轉播來自本地節點的主要/次要頻道的訊息。 - 僅限已知節點 近似於 LOCAL_ONLY 角色,將忽略來自外部 Mesh 節點的訊息,同時也忽略已知節點列表以外節點的訊息。 - 僅允許 SENSOR、TRACKER 和 TAK_TRACKER 角色,與 CLIENT_MUTE 角色不同,此模式將禁止所有重新廣播行為。 - 僅轉發基本通訊封包 忽略來自非標準通訊埠號(諸如 TAK、RangeTest、PaxCounter 等)的封包,僅重新廣播標準通訊埠號的封包:NodeInfo、Text、Position、Telemetry 和 Routing。 將支援加速度計上的雙撃行為視作按壓使用者按鍵。 點擊三次 User 按鈕時,向主頻道發送位置資訊。 @@ -139,6 +123,7 @@ 嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。 位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。 將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。 + 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。 用於與遠端設備交換密鑰。 被授權可對此節點發送管理訊息的公鑰。 設備處於受管理狀態,使用者無法變更任何設備設定。 @@ -165,7 +150,6 @@ QRCODE 未知的使用者名稱 傳送 - 您尚未將手機與 Meshtastic 相容的裝置配對。請先配對裝置並設置您的使用者名稱。\n\n此開源應用程式仍在開發中,如有問題,請在我們的論壇 https://github.com/orgs/meshtastic/discussions 上面發文詢問。\n\n 也可參閱我們的網頁 - www.meshtastic.org。 允許傳送分析及崩潰報告。 接受 @@ -173,44 +157,41 @@ 放棄變更 儲存 收到新的頻道 URL - Meshtastic需要啟用定位及藍芽才能尋找新裝置,可以選擇在不使用時停用。 - 回報BUG - 回報問題 - 您確定要報告錯誤嗎?報告後,請在 https://github.com/orgs/meshtastic/discussions 上貼文,以便我們可以將報告與您發現的問題比對。 報告 - 配對完成,開始服務 - 配對失敗,請重新選擇 定位服務已關閉,無法向設備提供位置。 分享 發現新節點: %1$s 已中斷連線 設備休眠中 - 已連接:線上 %1$s IP地址: IP連接埠: 已連線 - 已連接至設備 (%1$s) 目前連線: WIFI IP: 乙太網路 IP: 正在連線 未連線 未選擇裝置 + 未知的裝置 + 找不到網路裝置 + 找不到 USB 裝置 + USB + 展示模式 已連接裝置,但該裝置正在休眠中 需要應用程式更新 您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件 無(停用) 服務通知 致謝 + 開放原始碼函式庫 + Meshtastic 採用以下開源函式庫建構而成。點擊任一函式庫以查看其授權條款。 + %1$d 函式庫 此頻道 URL 無效,無法使用 - 此聯絡人無效,無法新增 偵錯面板 解析封包: 匯出日誌 - 已取消匯出 %1$d 日誌已匯出 寫入日誌檔案失敗:%1$s - 無日誌可匯出 %1$d 小時 @@ -228,7 +209,6 @@ 清除所有篩選 新增自訂篩選條件 預設篩選條件 - 僅顯示已忽略的節點 儲存網狀網路日誌 停用後將不會把網狀網路日誌寫入磁碟 清除所有日誌 @@ -236,7 +216,19 @@ 符合全部條件 這將完全移除裝置上的所有日誌封包與資料庫記錄 - 這是一個完整的重設,且無法復原。 清除 + 搜尋表情符號…… + 更多符號 頻道 + %1$s: %2$s + 來自 %1$s 的訊息:%2$s + 標頭 + 標尾 + 點形 + 文字 + 儀表板 + 梯度 + 這是一個一個一個可客製化的組合元件 + 還支援多行文字與多種樣式 訊息傳遞狀態 下方有新的訊息 私訊通知 @@ -257,10 +249,15 @@ 恢復預設設置 套用 主題 + 對比度 淺色 深色 系統預設 選擇主題 + 對比度等級 + 標準 + 中等 + 將手機位置提供給Mesh網路 使用同形異意字元編碼處理西里爾字母 @@ -284,9 +281,7 @@ 關機 此裝置不支援關機功能 ⚠️ 這將會關閉節點。需要實體操作才能重新開啟。 - ⚠️ 這是關鍵基礎設施節點。請輸入節點名稱以確認: 裝置:%1$s - 請輸入:%1$s 重新開機 路由追蹤 顯示介紹指南 @@ -298,9 +293,7 @@ 即時發送 顯示快速聊天選單 隱藏快速聊天選單 - 顯示快速聊天 恢復出廠設置 - 藍芽已關閉,請至手機設定內開啟藍芽功能。 開啟設定 韌體版本:%1$s Meshtastic 應用程式需要啟用「鄰近裝置」權限,才能透過藍牙尋找並連接到裝置,可以選擇在不使用時停用。 @@ -309,6 +302,7 @@ 已確認送達 在設定套用的過程中,您的裝置可能會斷開連線並重新啟動。 錯誤 + 未知錯誤 忽略 從忽略清單中移除 將 '%1$s' 加入忽略清單嗎? @@ -343,14 +337,14 @@ 移除 該節點將從您的列表中移除,直到您的節點再次收到來自該節點的數據。 靜音通知 - 1小時 8小時 1週 總是 目前: 永久靜音 未靜音 - 靜音狀態 + 已靜音 %1$d 天 %2$s 小時 + 已靜音 %1$s 小時 將「%1$s」的通知設為靜音? 取消「%1$s」的通知靜音? 替換 @@ -360,13 +354,16 @@ 電池 頻道利用率 空中時間使用率 + %1$s:%2$s%% + %1$s:%2$s%V + %1$s + %1$s:%2$s 溫度 濕度 土壤溫度 土壤濕度 系統記錄 節點距 - 經過節點數:%1$d 資訊 目前頻道的使用情況,包括格式正確的傳輸(TX)、接收(RX)和格式錯誤的接收(也稱為雜訊)。 過去一小時内傳輸所使用的通話時間(airtime)百分比。 @@ -380,14 +377,10 @@ 公開金鑰與先前記錄不符。您可以移除此節點並重新進行金鑰交換,但這可能代表有更嚴重的安全性問題。建議透過其他可靠的通訊方式聯繫該使用者,確認金鑰改變是否為重設裝置或其他有意的操作。 使用者資訊 新節點通知 - 詳細資訊 SNR - 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。 RSSI - 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。 (室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。 裝置計量資料 - 節點地圖 位置 最後位置更新 環境計量資料 @@ -413,17 +406,28 @@ 此路由追蹤尚未包含任何可標記於地圖的節點。 顯示 %1$d / %2$d 個節點 持續時間:%1$s 秒 - %1$s - %2$s 追蹤至目的地的路由:\n\n 追蹤回到本機的路由:\n\n + 去程跳數 + 回程跳數 + 來回跳數 + 無回應 + 1分鐘負載 + 5分鐘負載 + 15分鐘負載 + 1分鐘系統負載平均值 + 5分鐘系統負載平均值 + 15分鐘系統負載平均值 + 可用系統記憶體(位元組) 1小時 二十四小時 - 四十八小時 一週 二週 - 四週 1個月 最大值 + 最小 + 展開圖表 + 收起圖表 未知年齡 複製 警鈴字符! @@ -437,18 +441,22 @@ 頻道1 頻道2 頻道3 + 頻道 4 + 頻道 5 + 頻道 6 + 頻道 7 + 頻道 8 當前 電壓 你確定嗎? 設備角色檔案和關於選擇正確的設備角色的博客文章 。]]> 我知道我在做什麼。 + 節點 %1$s 電量過低 (%2$d%) 低電量通知 低電量:%1$s 低電量通知(收藏節點) 氣壓 已啟用 - UDP 廣播 - UDP 設置 最後接收: %2$s
最後位置: %3$s
電量: %4$s]]>
切換我的位置 定位朝北 @@ -527,11 +535,9 @@ 狀態廣播間隔 (秒) 告警訊息發送提示音 顯示名稱 - 友善地址 螢幕的 GPIO 腳位 偵測觸發類型 使用輸入上拉模式 - 裝置 裝置角色 按鈕腳位 蜂鳴器腳位 @@ -571,6 +577,9 @@ 輸出持續時間(毫秒) 通知逾時時間(秒) 鈴聲 + 已匯入鈴聲 + 檔案為空 + 匯入錯誤:%1$s 播放 使用 I2S 控制蜂鳴器 LoRa @@ -581,7 +590,6 @@ 帶寬 擴頻因子 編碼速率 - 頻率偏移量 (MHz) 地區 中繼次數 啟用 LoRa 發射 @@ -595,6 +603,23 @@ 無視MQTT 允許轉發至 MQTT MQTT配置 + 已停用 + 已中斷連線 + 已斷線 — %1$s + 正在連接… + 已連線 + 重新連接中… + 重新連接中(第 %1$d 次嘗試) — %2$s + 測試連線 + 正在查詢 Broker… + 可供連線,Broker 已驗證並接受憑證。 + 可供連線(%1$s) + Broker 遭拒:%1$s + 找不到伺服器 + 無法連線至 Broker 中繼伺服器(TCP) + TLS 握手失敗 + 經過 %1$d 毫秒後逾時 + 測試失敗 啟用MQTT服務器 地址 用戶名 @@ -610,13 +635,11 @@ 啟用鄰居資訊 更新間隔(秒) 通過Lora無線電傳輸 - 網路 Wi-Fi 選項 已啟用 啟用Wi-Fi SSID PSK - 取得文件 乙太網路選項 啟用以太網 時間伺服器 @@ -625,6 +648,7 @@ IP 網閘 子網路 + DNS 人流計數(Paxcount)設置 已啟用人流計數(Paxcount) 狀態訊息 @@ -632,31 +656,18 @@ 實際狀態字串 Wi-Fi RSSI 閾值(預設為-80) 藍牙 RSSI 閾值(預設為-80) - 位置 - 位置廣播間隔(秒) - 啟用智慧位置 - 智慧廣播最小距離(公尺) - 智慧廣播最小間隔(秒) - 使用固定位置 緯度 經度 - 高度(米) 使用手機目前定位 GPS 模式(實體硬體) - GPS更新間隔(秒) - 重定義 GPS_RX_PIN - 重定義 GPS_TX_PIN - 重定義 PIN_GPS_EN 位置標誌 電源設定 啟用省電模式 電源中斷時關機 - 電池延時關閉(秒) ADC 校正係數 ADC乘數修正比率 藍牙等待持續時間 超深度睡眠時長 - 淺層睡眠時長 最小喚醒時間 電池 INA_2XX I2C 地址 範圍測試設定 @@ -667,7 +678,6 @@ 啟動遠端硬體 允許未定義腳位連接 可用腳位 - 安全 私訊金鑰 管理金鑰 公鑰 @@ -681,6 +691,8 @@ 啟用序列埠 啟用 Echo 序列埠鮑率 + RX + TX 逾時 序列埠模式 覆蓋控制台序列埠 @@ -715,8 +727,15 @@ 距離 照度 風速 + 風速 + 陣風 + 風停 + 風向 + 降雨(1h) + 降雨(24h) 重量 輻射 + 1-Wire 溫度 室內空氣品質 (IAQ) 網址 @@ -729,12 +748,11 @@ 使用者 ID 運行時間 負載:%1$d - 正在取得頻道 %1$d / %2$d - 正在取得 %1$s 硬碟可用空間:%1$d 時間戳記 航向 速度 + %1$d Km/h 衛星數 海拔 頻率 @@ -747,7 +765,6 @@ 長按後可拖曳排列順序 解除靜默 動態 - 掃描QR碼 分享聯絡人 備註 新增私人備註… @@ -760,13 +777,11 @@ 請求 正在向 %1$s 請求 %2$s 用戶資訊 - 鄰近節點資訊 (2.7.15+) 請求遙測資料 裝置計量資料 環境計量資料 空氣品質計量資料 電源計量資料 - 本機統計資料 主機資訊 人流計量資料 中繼資料 @@ -777,7 +792,6 @@ 主機資訊 裝置 可用記憶體 - 可用儲存空間 負載 使用者設定 導航至 @@ -804,6 +818,11 @@ 顯示路徑 顯示定位精準度 客户端通知 + 金鑰驗證 + 金鑰驗證請求 + 金鑰驗證已完成 + 偵測到重複的公鑰 + 偵測到加密金鑰強度不足 偵測到金鑰已洩漏,點選確定後重新產生金鑰。 重新產生私鑰 您確定要重新產生密鑰嗎?\n\n連線過的其他節點需要刪除並重新交換金鑰後才能恢復加密通訊連線。 @@ -815,8 +834,6 @@ (線上 %1$d / 顯示 %2$d / 總計 %3$d) 回應 中斷連線 - 找不到網路裝置。 - 找不到 USB 序列裝置。 移至最底部 Meshtastic 安全性狀態 @@ -832,8 +849,6 @@ 清除節點資料庫 清除最後出現時間超過 %1$d 日的節點 僅清除不明節點 - 清理低互動的節點 - 清除已忽略的節點 立即清理 此操作將刪除資料庫內的%1$d個節點,並且無法恢復。 綠色鎖頭表示該頻道已使用 128 位元或 256 位元 AES 金鑰安全加密。 @@ -852,9 +867,6 @@ 顯示全部狀態 顯示目前狀態 關閉 - 您確定要刪除此節點嗎? - 清除連線 - 確定要清除此連線嗎? 回覆 %1$s 取消回覆 確認刪除訊息? @@ -863,9 +875,15 @@ 請輸入訊息 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 設定 藍牙裝置 - 已配對的裝置 連接裝置 超過速率限制,請稍後再嘗試。 查看版本資訊 @@ -893,7 +911,6 @@ 發現新節點的通知。 電量不足 已連線裝置的低電量通知。 - 標記為關鍵的封包在傳送時,將忽略訊息開關及作業系統通知中心的勿擾模式設定。 設定通知權限 手機定位 Meshtastic 會使用您手機的定位資訊來啟用多項功能。您隨時可以在設定中修改定位權限。 @@ -916,19 +933,15 @@ 設定緊急警示 Meshtastic 使用通知功能讓您隨時了解新訊息和其他重要事件。您可以隨時在設定中更新通知權限。 繼續 - 授予權限 %1$d 個節點已排定移除: 注意:這會將節點從應用程式和裝置資料庫中移除。\n所選的項目將會加入待處理中。 - 正在連線至裝置 標準 衛星 地形 混合 管理地圖圖層 自訂圖層支援 .kml、.kmz 或 GeoJSON 檔案。 - 地圖圖層 未載入自訂圖層。 - 添加圖層 隱藏圖層 顯示圖層 移除圖層 @@ -966,14 +979,12 @@ 48 小時 依最後收到時間篩選:%1$s %1$d dBm - 沒有應用程式可以開啟此連結。 系統設定 沒有可用的統計資料 我們會收集分析數據以協助改善 Android 應用程式(感謝您的支持),我們將收到匿名化的使用者行為資訊,包括當機報告、應用程式使用畫面等。 分析平台: 如欲了解更多資訊,請查閱我們的隱私權政策。 預設值 - 0 - 經由:%1$s 聽到 %1$d 個中繼 @@ -983,7 +994,6 @@
不再顯示此裝置的提示 保留我的最愛? - USB 裝置 韌體更新 正在檢查更新…… @@ -993,21 +1003,18 @@ 穩定版 Alpha 測試版 注意:更新期間將會暫時中斷您的裝置連線。 + 正在下載韌體... %1$d% 錯誤: %1$s 重試 更新成功! 完成 正在啟動 DFU⋯⋯ - %1$s 更新中⋯⋯ 正在啟用 DFU 模式⋯⋯ 正在驗證韌體⋯⋯ - 正在中斷連線⋯⋯ 未知的硬體型號: %1$d - %1$s 連線裝置無效或無法識別其藍牙位址。 尚未連線裝置 在發行版本中找不到 %1$s 的韌體。 正在解壓縮韌體⋯⋯ - 正在中斷連線以啟動 DFU 服務⋯⋯ 更新失敗 請稍候,正在處理中⋯⋯ 請確保裝置在手機附近。 @@ -1023,7 +1030,6 @@ Chirpy 小提醒:「緊握扶手!」 Chirpy 正在進入 DFU 模式⋯⋯ - 等待裝置進入 DFU 模式⋯⋯ 正在複製韌體⋯⋯記得要強調是史上最快喔! 請將 .uf2 檔案複製到您裝置 DFU 的磁碟機。 刷入韌體中,請稍等⋯⋯ @@ -1039,24 +1045,16 @@ 目標裝置:%1$s 版本說明 未知錯誤 - 本機更新失敗 - DFU錯誤: %1$s - DFU 已中止 缺少節點使用者資訊。 + 電量過低 (%1$d%%),請在更新前為您的裝置充電。 無法取得韌體檔案。 - Nordic DFU 更新失敗 USB 更新失敗 韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。 OTA 更新失敗: %1$s - 正在載入韌體⋯⋯ 等待裝置重新啟動至 OTA 模式⋯⋯ 正在連線至裝置(第 %1$d / %2$d次嘗試)⋯⋯ - 正在檢查裝置版本⋯⋯ 正在啟動 OTA 更新⋯⋯ 正在上傳韌體⋯⋯ - 正在重新啟動裝置⋯⋯ - 韌體更新 - 韌體更新狀態 正在清除⋯⋯ 返回 取消設定 @@ -1084,9 +1082,7 @@ 估計範圍: 精確度未知 標記為已讀 現在 - 新增頻道 QR Code 包含以下頻道。請勾選要新增的頻道。現有設定將被保留。 - 取代頻道 & 設定 此 QR Code 包含完整的設定檔,這將會覆寫您目前的頻道和無線電設定,所有頻道都會被刪除。 載入中 @@ -1099,7 +1095,6 @@ 尚未設定篩選關鍵字 正規表示式 完整字詞比對 - 已篩選 %1$d 則 顯示 %1$d 個已篩選 隱藏已篩選 %1$d 則 已篩選 @@ -1120,17 +1115,15 @@ 全部 藍牙 設定藍牙權限 - 連線至無線電 - 掃描並連線至你的 Meshtastic 網狀無線電裝置。 探索 尋找並識別附近的 Meshtastic 裝置。 設定 無線管理你的裝置設定與頻道。 - 已授予權限 - 已拒絕權限 地圖樣式選擇 + 電量:%1$d% 線上 %1$d / 總計 %2$d 上線時間: %1$s + 頻道使用率: %1$s% | 空中傳輸佔用率: %2$s% 流量: 傳送 %1$d / 接收 %2$d (丟棄: %3$d) 中繼: %1$d (取消: %2$d) 診斷: %1$s @@ -1141,19 +1134,16 @@ %1$d / %2$d %1$s 已供電 - Meshtastic 統計 重新整理 已更新 新增線上圖層 - 重新整理圖層 本機 MBTiles 檔案 新增本機 MBTiles 檔案 - 自訂圖磚來源的名稱、URL 範本或本機 URI 無效。 - 已存在相同名稱的自訂圖磚來源。 - 無法將 MBTiles 檔案複製至內部儲存空間。 TAK (ATAK) TAK 設定 + 啓用本地 TAK 伺服器 + 在 8089 埠啟動一個用於 ATAK 連線的 TCP 伺服器 隊伍顏色 隊員角色 未指定 @@ -1196,10 +1186,47 @@ 僅本地遙測資訊(中繼) 僅本地定位資訊(中繼) 保留路由跳數 - 尚未連線裝置 - 下載 Firmware 注意 + 裝置儲存空間與使用者介面(唯讀) + 主題 %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 7fac1ccc7..505d80821 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -24,7 +24,6 @@ 简体中文 繁體中文 - SKH hey I found the cache, it is over here next to the big tiger. I'm kinda scared. mqtt.meshtastic.org @@ -38,7 +37,6 @@ Hide offline nodes Only show direct nodes You are viewing ignored nodes,\nPress to return to the node list. - Show details Sort by Node sorting options A-Z @@ -78,44 +76,25 @@ Bad session key Public Key unauthorized PKI send failed, no public key - Client App connected or standalone messaging device. - Client Mute Device that does not forward packets from other devices. - Client Base Treats packets from or to favorited nodes as ROUTER_LATE, and all other packets as CLIENT. - Router Infrastructure node for extending network coverage by relaying messages. Visible in nodes list. - Router Client Combination of both ROUTER and CLIENT. Not for mobile devices. - Repeater Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in nodes list. - Tracker Broadcasts GPS position packets as priority. - Sensor Broadcasts telemetry packets as priority. - TAK Optimized for ATAK system communication, reduces routine broadcasts. - Client Hidden Device that only broadcasts as needed for stealth or power savings. - Lost and Found Broadcasts location as message to default channel regularly for to assist with device recovery. - TAK Tracker Enables automatic TAK PLI broadcasts and reduces routine broadcasts. - Router Late Infrastructure node that always rebroadcasts packets once but only after all other modes, ensuring additional coverage for local clusters. Visible in nodes list. - All Rebroadcast any observed message, if it was on our private channel or from another mesh with the same lora parameters. - All Skip Decoding Same as behavior as ALL but skips packet decoding and simply rebroadcasts them. Only available in Repeater role. Setting this on any other roles will result in ALL behavior. - Local Only Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels. - Known Only Ignores observed messages from foreign meshes like LOCAL ONLY, but takes it step further by also ignoring messages from nodes not already in the node's known list. - None Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role. - Core Portnums Only Ignores packets from non-standard portnums such as: TAK, RangeTest, PaxCounter, etc. Only rebroadcasts packets with standard portnums: NodeInfo, Text, Position, Telemetry, and Routing. Treat double tap on supported accelerometers as a user button press. @@ -194,7 +173,6 @@ QR code Unknown Username Send - You haven't yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: https://github.com/orgs/meshtastic/discussions.\n\nFor more information see our web page - www.meshtastic.org. You Allow analytics and crash reporting. Accept @@ -202,23 +180,15 @@ Discard Save New Channel URL received - Meshtastic needs location permissions enabled to find new devices via Bluetooth. You can disable when not in use. - Report Bug - Report a bug - Are you sure you want to report a bug? After reporting, please post in https://github.com/orgs/meshtastic/discussions so we can match up the report with what you found. Report - Pairing completed, starting service - Pairing failed, please select again Location access is turned off, can not provide position to mesh. Share New Node Seen: %1$s Disconnected Device sleeping - Connected: %1$s online IP Address: Port: Connected - Connected to radio (%1$s) Current connections: Wifi IP: Ethernet IP: @@ -240,14 +210,11 @@ Meshtastic is built with the following open source libraries. Tap any library to view its license. %1$d libraries This Channel URL is invalid and can not be used - This contact is invalid and can not be added Debug Panel Decoded Payload: Export Logs - Export canceled %1$d logs exported Failed to write log file: %1$s - No logs to export %1$d hour @@ -269,7 +236,6 @@ Clear all filters Add custom filter Preset Filters - Only show ignored Nodes Store mesh logs Disable to skip writing mesh logs to disk Clear Logs @@ -312,10 +278,15 @@ Reset to defaults Apply Theme + Contrast Light Dark System default Choose theme + Contrast level + Standard + Medium + High Provide phone location to mesh Compact encoding for Cyrillic @@ -340,9 +311,7 @@ Shutdown Shutdown not supported on this device ⚠️ This will SHUTDOWN the node. Physical interaction will be required to turn it back on. - ⚠️ This is a critical infrastructure node. Type the node name to confirm: Node: %1$s - Type: %1$s Reboot Traceroute Show Introduction @@ -354,9 +323,7 @@ Instantly send Show quick chat menu Hide quick chat menu - Show quick chat Factory reset - Bluetooth is disabled. Please enable it in your device settings. Open settings Firmware version: %1$s Meshtastic needs "Nearby devices" permissions enabled to find and connect to devices via Bluetooth. You can disable when not in use. @@ -365,6 +332,7 @@ Delivery confirmed Your device may disconnect and reboot while settings are applied. Error + Unknown error Ignore Remove from ignored Add '%1$s' to ignore list? @@ -399,7 +367,6 @@ Remove This node will be removed from your list until your node receives data from it again. Mute notifications - 1 hour 8 hours 1 week Always @@ -408,7 +375,6 @@ Not muted Muted for %1$d days, %2$s hours Muted for %1$s hours - Mute status Mute notifications for '%1$s'? Unmute notifications for '%1$s'? Replace @@ -418,9 +384,9 @@ Battery ChUtil AirUtil - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s Temp Hum @@ -428,7 +394,6 @@ Soil Moist Logs Hops Away - Hops Away: %1$d Information Utilization for the current channel, including well formed TX, RX and malformed RX (aka noise). Percent of airtime for transmission used within the last hour. @@ -442,14 +407,10 @@ The public key does not match the recorded key. You may remove the node and let it exchange keys again, but this may indicate a more security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action. User Info New node notifications - More details 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 @@ -476,17 +437,28 @@ This traceroute does not have any mappable nodes yet. Showing %1$d/%2$d nodes Duration: %1$s s - %1$s - %2$s Route traced toward destination:\n\n Route traced back to us:\n\n + Forward Hops + Return Hops + Round Trip + No Response + Load 1m + Load 5m + Load 15m + One-minute system load average + Five-minute system load average + Fifteen-minute system load average + Available system memory in bytes 1H 24H - 48H 1W 2W - 4W 1M Max + Min + Expand chart + Collapse chart Unknown Age Copy Alert Bell Character! @@ -500,6 +472,11 @@ Channel 1 Channel 2 Channel 3 + Channel 4 + Channel 5 + Channel 6 + Channel 7 + Channel 8 Current Voltage Are you sure? @@ -511,8 +488,6 @@ Low battery notifications (favorite nodes) Baro Enabled - UDP Broadcast - UDP Config Last heard: %2$s
Last position: %3$s
Battery: %4$s]]>
Toggle my position Orient north @@ -591,11 +566,9 @@ State broadcast (seconds) Send bell with alert message Friendly name - Friendly address GPIO pin to monitor Detection trigger type Use INPUT_PULLUP mode - Device Device Role Button GPIO Buzzer GPIO @@ -635,6 +608,9 @@ Output duration (milliseconds) Nag timeout (seconds) Ringtone + Imported ringtone + File is empty + Error importing: %1$s Play Use I2S as buzzer LoRa @@ -645,7 +621,6 @@ Bandwidth Spread Factor Coding Rate - Frequency offset (MHz) Region Number of Hops Transmit Enabled @@ -659,6 +634,23 @@ Ignore MQTT Ok to MQTT MQTT Config + Inactive + Disconnected + Disconnected — %1$s + Connecting… + Connected + Reconnecting… + Reconnecting (attempt %1$d) — %2$s + Test connection + Probing broker… + Reachable. Broker accepted credentials. + Reachable (%1$s) + Broker rejected: %1$s + Host not found + Cannot reach broker (TCP) + TLS handshake failed + Timed out after %1$d ms + Connection failed MQTT enabled Address Username @@ -674,13 +666,11 @@ Neighbor Info enabled Update interval (seconds) Transmit over LoRa - Network WiFi Options Enabled WiFi enabled SSID PSK - Get Document Ethernet Options Ethernet enabled NTP server @@ -689,6 +679,7 @@ IP Gateway Subred + DNS Paxcounter Config Paxcounter enabled Status Message @@ -696,31 +687,18 @@ The actual status string WiFi RSSI threshold (defaults to -80) BLE RSSI threshold (defaults to -80) - Position - Position broadcast interval (seconds) - Smart position enabled - Smart broadcast minimum distance (meters) - Smart broadcast minimum interval (seconds) - Use fixed position Latitude Longitude - Altitude (meters) Set from current phone location GPS Mode (Physical Hardware) - GPS update interval (seconds) - Redefine GPS_RX_PIN - Redefine GPS_TX_PIN - Redefine PIN_GPS_EN Position Flags Power Config Enable power saving mode Shutdown on power loss - Shutdown on battery delay (seconds) ADC multiplier override ADC multiplier override ratio Wait for Bluetooth duration Super deep sleep duration - Light sleep duration Minimum wake time Battery INA_2XX I2C address Range Test Config @@ -731,7 +709,6 @@ Remote Hardware enabled Allow undefined pin access Available pins - Security Direct Message Key Admin Keys Public Key @@ -745,6 +722,8 @@ Serial enabled Echo enabled Serial baud rate + RX + TX Timeout Serial mode Override console serial port @@ -779,8 +758,15 @@ Distance Lux Wind + Wind Speed + Wind Gust + Wind Lull + Wind Dir + Rain (1h) + Rain (24h) Weight Radiation + 1-Wire Temp Indoor Air Quality (IAQ) URL @@ -793,8 +779,6 @@ User ID Uptime Load %1$d - Fetching Channel %1$d/%2$d - Fetching %1$s Disk Free %1$d Timestamp Heading @@ -812,7 +796,6 @@ Press and drag to reorder Unmute Dynamic - Scan QR Code Share Contact Notes Add a private note… @@ -825,13 +808,11 @@ Request Requesting %1$s from %2$s User info - NeighborInfo (2.7.15+) Request Telemetry Device Metrics Environment Metrics Air-Quality Metrics Power Metrics - Local Stats Host Metrics Pax Metrics Metadata @@ -842,7 +823,6 @@ Host Metrics Host Free Memory - Disk Free Load User String Navigate Into @@ -885,8 +865,6 @@ (%1$d online / %2$d shown / %3$d total) React Disconnect - No Network devices found. - No USB Serial devices found. Scroll to bottom Meshtastic Security Status @@ -903,8 +881,6 @@ Clean Node Database Clean up nodes last seen older than %1$d days Clean up only unknown nodes - Clean up nodes with low/no interaction - Clean up ignored nodes Clean Now This will remove %1$d nodes from your database. This action cannot be undone. @@ -928,11 +904,6 @@ Show All Meanings Show Current Status Dismiss - - Are you sure you want to delete this node? - Forget connection - Are you sure you want to forget this connection? - Replying to %1$s Cancel reply Delete Messages? @@ -941,10 +912,15 @@ Type a message PAX Metrics PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s No PAX metrics available. Wi-Fi Provisioning for mPWRD-OS Bluetooth Devices - Paired devices Connected Device Rate Limit Exceeded. Please try again later. @@ -974,7 +950,6 @@ Notifications for newly discovered nodes. Low Battery Notifications for low battery alerts for the connected device. - Select packets sent as critical will ignore the msg switch and Do Not Disturb settings in the OS notification center. Configure notification permissions Phone Location Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings. @@ -997,19 +972,15 @@ Configure Critical Alerts Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings. Next - Grant Permissions %1$d nodes queued for deletion: Caution: This removes nodes from in-app and on-device databases.\nSelections are additive. - Connecting to device Normal Satellite Terrain Hybrid Manage Map Layers Map layers support .kml, .kmz, or GeoJSON formats. - Map Layers No map layers loaded. - Add Layer Hide Layer Show Layer Remove Layer @@ -1048,11 +1019,8 @@ 48 Hours Filter by Last Heard time: %1$s %1$d dBm - No application available to handle link. System Settings No Stats Available - - Analytics are collected to help us improve the Android app (thank you), we will receive anonymized information about user behavior. This includes crash reports, screens used in the app, etc. Analytics platforms: Firebase: https://firebase.google.com/ @@ -1060,7 +1028,6 @@ For more information, see our privacy policy. https://meshtastic.org/docs/legal/privacy/ Unset - 0 - Relayed by: %1$s Heard %1$d relay Heard %1$d relays @@ -1071,7 +1038,6 @@ For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader. Don't show again for this device Preserve Favorites? - USB Devices Firmware Update @@ -1088,16 +1054,12 @@ Update Successful! Done Starting DFU... - Updating... %1$s Enabling DFU mode... Validating firmware... - Disconnecting... Unknown hardware model: %1$d - Connected device is not a valid BLE device or address is unknown (%1$s). No device connected Could not find firmware for %1$s in release. Extracting firmware... - Disconnecting to start DFU service... Update failed Hang tight, we are working on it... Keep your device close to your phone. @@ -1113,7 +1075,6 @@ Chirpy says, "Keep your ladder handy!" Chirpy Rebooting to DFU... - Waiting for DFU device... High-five! Wait, copying firmware... Please save the .uf2 file to your device's DFU drive. Flashing device, please wait... @@ -1129,26 +1090,16 @@ Target: %1$s Release Notes Unknown error - Local update failed - DFU Error: %1$s - DFU Aborted Node user information is missing. Battery too low (%1$d%). Please charge your device before updating. Could not retrieve firmware file. - Nordic DFU Update failed USB Update failed Firmware hash rejected. Device may require hash provisioning or bootloader update. OTA update failed: %1$s - Loading firmware... Waiting for device to reboot into OTA mode... Connecting to device (attempt %1$d/%2$d)... - Checking device version... Starting OTA update... Uploading firmware... - Uploading firmware... %1$d% (%2$s) - Rebooting device... - Firmware Update - Firmware update status Erasing... Back @@ -1181,9 +1132,7 @@ Estimated area: unknown accuracy Mark as read Now - Add Channels The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved. - Replace Channels & Settings This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed. Loading @@ -1197,7 +1146,6 @@ No filter words configured Regex pattern Whole word match - %1$d filtered Show %1$d filtered Hide %1$d filtered Filtered @@ -1219,16 +1167,10 @@ Bluetooth Configure Bluetooth Permissions - Connect to Radio - Scan for and connect to your Meshtastic mesh radio device. Discovery Find and identify Meshtastic devices near you. Configuration Wirelessly manage your device settings and channels. - - Permission granted - Permission denied - Map style selection Battery: %1$d% @@ -1245,20 +1187,15 @@ %1$d / %2$d %1$s Powered - Meshtastic Stats Refresh Updated Add Network Layer https://example.com/map.kml or .geojson - Refresh Layer Local MBTiles File Add Local MBTiles File - Invalid name, URL template, or local URI for custom tile provider. - A custom tile provider with this name already exists. - Failed to copy MBTiles file to internal storage. TAK (ATAK) TAK Configuration @@ -1309,17 +1246,7 @@ Local-only Telemetry (Relays) Local-only Position (Relays) Preserve Router Hops - No messages yet - %1$d unread - Map support is coming soon to Desktop - No device connected - Update Status - Ready for firmware update - Check for Updates - Download Firmware - Update Device Note - Ensure your device is fully charged before starting a firmware update. Do not disconnect or power off the device during the update process. Device Storage & UI (Read-Only) Theme: %1$s, Language: %2$s @@ -1338,17 +1265,31 @@ Scan for Networks Scanning… Applying WiFi configuration… - WiFi configured successfully! - WiFi credentials applied. The device will connect to the network shortly. No networks found - Make sure the device is powered on and within range. Could not connect: %1$s Failed to scan for WiFi networks: %1$s - Refresh %1$d% Available Networks Network Name (SSID) 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/build.gradle.kts b/core/service/build.gradle.kts index ff97a05ec..1c6b56346 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -60,17 +60,11 @@ kotlin { val androidHostTest by getting { dependencies { implementation(projects.core.testing) - implementation(libs.robolectric) - implementation(libs.androidx.test.core) implementation(libs.androidx.test.ext.junit) implementation(libs.androidx.work.testing) } } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt index 91eb97484..8b939fa9b 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt @@ -16,9 +16,11 @@ */ package org.meshtastic.core.service +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.meshtastic.core.di.CoroutineDispatchers import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -27,10 +29,15 @@ import kotlin.test.assertNotNull @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class AndroidFileServiceTest { + private val testDispatchers = + UnconfinedTestDispatcher().let { dispatcher -> + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) + } + @Test fun testInitialization() = runTest { val context = RuntimeEnvironment.getApplication() - val service = AndroidFileService(context) + val service = AndroidFileService(context, testDispatchers) assertNotNull(service) } } diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt index 4791c99bf..d385c5a16 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt @@ -65,10 +65,11 @@ class AndroidNotificationManagerTest { } @Test - fun `init removes legacy node channel and creates canonical node channel`() { + fun `dispatch removes legacy node channel and creates canonical node channel`() { createChannel("NodeEvent") - AndroidNotificationManager(context) + val manager = AndroidNotificationManager(context) + manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent)) assertNull(systemNotificationManager.getNotificationChannel("NodeEvent")) assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES)) diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt similarity index 88% rename from core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt rename to core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt index a2c02427e..c37f63fb4 100644 --- a/core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt @@ -16,12 +16,17 @@ */ package org.meshtastic.core.service +import org.junit.runner.RunWith import org.meshtastic.core.service.testing.FakeIMeshService +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull /** Test to verify that the AIDL contract is correctly implemented by our test harness. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class IMeshServiceContractTest { @Test diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt index 010fcdc89..8924cdcc8 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.service import android.app.Application import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers +import com.eygraber.uri.toAndroidUri import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -26,15 +26,16 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.MeshtasticUri -import org.meshtastic.core.common.util.toAndroidUri +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import java.io.FileOutputStream @Single -class AndroidFileService(private val context: Application) : FileService { - override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(Dispatchers.IO) { +class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) : + FileService { + override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(dispatchers.io) { try { val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt") if (pfd == null) { @@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService { } } - override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(Dispatchers.IO) { + override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(dispatchers.io) { try { val success = context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream -> diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt index f15190c8a..17735e28c 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt @@ -40,11 +40,21 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan private data class ChannelConfig(val id: String, val importance: Int) - init { - initChannels() - } + /** + * Tracks whether notification channels have been created. + * + * Channels are **not** created in the constructor because this singleton is instantiated by Koin during + * [org.meshtastic.core.service.MeshService.onCreate] on the main thread. The CMP [getString] helper uses + * [kotlinx.coroutines.runBlocking] which can fail in that context, crashing the entire service startup chain. + * Instead, channels are lazily ensured before the first [dispatch] call. Note that + * [MeshServiceNotificationsImpl.initChannels] already creates a superset of these channels when the orchestrator + * starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization. + */ + private var channelsInitialized = false - private fun initChannels() { + private fun ensureChannelsInitialized() { + if (channelsInitialized) return + channelsInitialized = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channels = listOf( @@ -91,6 +101,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan } override fun dispatch(notification: Notification) { + ensureChannelsInitialized() val builder = NotificationCompat.Builder(context, notification.category.channelConfig().id) .setContentTitle(notification.title) 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 216d8fb37..af7cb85c2 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,15 +17,29 @@ 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. + * + * All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound, + * commands are silently dropped with a warning log. + */ @Single @Suppress("TooManyFunctions") class AndroidRadioControllerImpl( @@ -34,6 +48,7 @@ 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 @@ -41,8 +56,12 @@ class AndroidRadioControllerImpl( get() = serviceRepository.clientNotification override suspend fun sendMessage(packet: DataPacket) { - // Bridging to the existing flow via IMeshService - serviceRepository.meshService?.send(packet) + val svc = serviceRepository.meshService + if (svc == null) { + Logger.w { "sendMessage: meshService is null, dropping packet" } + return + } + svc.send(packet) } override fun clearClientNotification() { @@ -50,48 +69,44 @@ class AndroidRadioControllerImpl( } override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(nodeNum.toString()) + val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) } override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(nodeNum.toString()) + val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) val contact = - org.meshtastic.proto.SharedContact( - node_num = nodeDef.num, - user = nodeDef.user, - manually_verified = nodeDef.manuallyVerified, - ) + 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: org.meshtastic.proto.Config) { + override suspend fun setLocalConfig(config: Config) { serviceRepository.meshService?.setConfig(config.encode()) } - override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) { + override suspend fun setLocalChannel(channel: Channel) { serviceRepository.meshService?.setChannel(channel.encode()) } - override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) { + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode()) } - override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) { + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode()) } - override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) { + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode()) } - override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) { + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode()) } - override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) { + override suspend fun setFixedPosition(destNum: Int, position: Position) { serviceRepository.meshService?.setFixedPosition(destNum, position) } @@ -159,7 +174,7 @@ class AndroidRadioControllerImpl( serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) } - override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) { + override suspend fun requestPosition(destNum: Int, currentPosition: Position) { serviceRepository.meshService?.requestPosition(destNum, currentPosition) } @@ -187,7 +202,8 @@ class AndroidRadioControllerImpl( serviceRepository.meshService?.commitEditSettings(destNum) } - override fun getPacketId(): Int = serviceRepository.meshService?.getPacketId() ?: 0 + override fun getPacketId(): Int = + serviceRepository.meshService?.getPacketId() ?: error("Cannot generate packet ID: meshService is not bound") override fun startProvideLocation() { serviceRepository.meshService?.startProvideLocation() @@ -201,10 +217,7 @@ 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 = - android.content.Intent().apply { - setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") - } + val intent = 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/AndroidServiceRepository.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt index ec569e27f..cf1eaff25 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.repository.ServiceRepository * in `MeshService`. */ @Single(binds = [ServiceRepository::class, AndroidServiceRepository::class]) +@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding class AndroidServiceRepository : ServiceRepositoryImpl() { var meshService: IMeshService? = null private set diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt index b01475b6d..4e9194f42 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt @@ -19,19 +19,29 @@ package org.meshtastic.core.service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import co.touchlab.kermit.Logger +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.repository.MeshPrefs /** This receiver starts the MeshService on boot if a device was previously connected. */ -class BootCompleteReceiver : BroadcastReceiver() { +class BootCompleteReceiver : + BroadcastReceiver(), + KoinComponent { + + private val meshPrefs: MeshPrefs by inject() override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_BOOT_COMPLETED != intent.action) { return } - val prefs = context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE) - if (!prefs.contains("device_address")) { + val address = meshPrefs.deviceAddress.value + if (address.isNullOrBlank() || address.equals("n", ignoreCase = true)) { + Logger.d { "BootCompleteReceiver: no device previously connected, skipping service start" } return } + Logger.i { "BootCompleteReceiver: starting MeshService for device $address" } MeshService.startService(context) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt index 2007bbcaa..8b57c8c6c 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt @@ -28,24 +28,10 @@ const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS -const val ACTION_RECEIVED_TEXT_MESSAGE_APP = MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP -const val ACTION_RECEIVED_POSITION_APP = MeshtasticIntent.ACTION_RECEIVED_POSITION_APP -const val ACTION_RECEIVED_NODEINFO_APP = MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP -const val ACTION_RECEIVED_TELEMETRY_APP = MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP -const val ACTION_RECEIVED_ATAK_PLUGIN = MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN -const val ACTION_RECEIVED_ATAK_FORWARDER = MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER -const val ACTION_RECEIVED_DETECTION_SENSOR_APP = MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP -const val ACTION_RECEIVED_PRIVATE_APP = MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP - fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" -// -// standard EXTRA bundle definitions -// - +// Standard EXTRA bundle definitions const val EXTRA_CONNECTED = MeshtasticIntent.EXTRA_CONNECTED -const val EXTRA_PROGRESS = "$PREFIX.Progress" -const val EXTRA_PERMANENT = "$PREFIX.Permanent" const val EXTRA_PAYLOAD = MeshtasticIntent.EXTRA_PAYLOAD const val EXTRA_NODEINFO = MeshtasticIntent.EXTRA_NODEINFO diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt index 966569f4f..36c26c879 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt @@ -20,12 +20,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.PacketRepository @@ -38,7 +38,9 @@ class MarkAsReadReceiver : private val serviceNotifications: MeshServiceNotifications by inject() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } companion object { const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index c8b7fdfab..5869ce94f 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -25,11 +25,11 @@ import android.os.IBinder import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.common.util.toRemoteExceptions +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.MeshUser @@ -42,6 +42,7 @@ 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 @@ -49,7 +50,14 @@ import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.PortNum -@Suppress("TooManyFunctions", "LargeClass") +/** + * 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() { private val radioInterfaceService: RadioInterfaceService by inject() @@ -66,12 +74,22 @@ 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 = CoroutineScope(Dispatchers.IO + serviceJob) + private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) } + + private var isServiceInitialized = false private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException() @@ -86,7 +104,6 @@ class MeshService : Service() { fun createIntent(context: Context) = Intent(context, MeshService::class.java) fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { - @Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder service.setDeviceAddress(address) startService(context) } @@ -96,29 +113,40 @@ class MeshService : Service() { } override fun onCreate() { - try { - super.onCreate() - } catch (e: IllegalStateException) { - // Koin can throw IllegalStateException in tests if the component is not created. - // This can happen if the service is started by the system (e.g. after a crash or on boot) - // before the test rule has a chance to create the component. - if (e.message?.contains("HiltAndroidRule") == true || e.message?.contains("Koin") == true) { - Logger.w(e) { "MeshService created before DI component was ready in test, stopping service" } - stopSelf() - return - } - throw e - } + super.onCreate() Logger.i { "Creating mesh service" } - orchestrator.start() + try { + orchestrator.start() + isServiceInitialized = true + } catch (e: IllegalStateException) { + // Koin throws IllegalStateException when the DI graph is not yet initialized. + // This can happen if the system restarts the service (e.g. after a crash or on boot) + // before Application.onCreate() has finished setting up Koin. + // In release builds, R8 may merge Koin's InstanceCreationException with unrelated + // exception classes (observed as io.ktor.http.URLDecodeException), so we cannot rely + // on the exception type alone. We catch IllegalStateException narrowly around the + // orchestrator/DI access — not around super.onCreate() — so framework exceptions + // still propagate normally. + Logger.e(e) { "MeshService: DI not ready, stopping service" } + stopSelf() + return + } } + @Suppress("ReturnCount") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!isServiceInitialized) { + Logger.w { "onStartCommand called but service is not initialized (likely DI failure). Stopping." } + stopSelf() + return START_NOT_STICKY + } + val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != "n" - val notification = connectionManager.updateStatusNotification() as android.app.Notification + connectionManager.updateStatusNotification() + val notification = androidNotifications.getServiceNotification() val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -180,7 +208,9 @@ class MeshService : Service() { override fun onDestroy() { Logger.i { "Destroying mesh service" } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - orchestrator.stop() + if (isServiceInitialized) { + orchestrator.stop() + } serviceJob.cancel() super.onDestroy() } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt index 2114ae784..5933d85b0 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt @@ -29,6 +29,7 @@ import org.meshtastic.core.common.util.SequentialJob /** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */ @Factory +@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding class MeshServiceClient( private val context: Context, private val serviceRepository: AndroidServiceRepository, 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 095010440..211e3b9c4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -37,11 +37,11 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking 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,7 +267,8 @@ class MeshServiceNotificationsImpl( enableLights(true) enableVibration(true) setBypassDnd(true) - val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri() + val alertSoundUri = + "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri() setSound( alertSoundUri, AudioAttributes.Builder() @@ -289,20 +290,29 @@ 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: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ): Notification { + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { val summaryString = when (state) { - is org.meshtastic.core.model.ConnectionState.Connected -> + is ConnectionState.Connected -> getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected) - 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) + is ConnectionState.Disconnected -> getString(Res.string.disconnected) + is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) + is ConnectionState.Connecting -> getString(Res.string.connecting) } // Update caches if telemetry is provided @@ -319,9 +329,9 @@ class MeshServiceNotificationsImpl( val repo = nodeRepository.value val myNodeNum = repo.myNodeInfo.value?.myNodeNum if (myNodeNum != null) { - // We use runBlocking here because this is called from MeshConnectionManager's synchronous methods, - // and we only do this once if the cache is empty. - val nodes = runBlocking { repo.nodeDBbyNum.first() } + // Use .value instead of runBlocking { .first() } to avoid potential deadlock + // if called on the same dispatcher the Flow's upstream coroutine needs. + val nodes = repo.nodeDBbyNum.value nodes[myNodeNum]?.let { node -> if (cachedDeviceMetrics == null) { cachedDeviceMetrics = node.deviceMetrics @@ -358,8 +368,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 7a3e026a7..f4db74403 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -21,21 +21,29 @@ 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 +/** + * Handles inline emoji reaction actions from message notifications. + * + * Uses [goAsync] to keep the process alive while the coroutine dispatches the reaction through [ServiceRepository], + * matching the pattern used by [ReplyReceiver] and [MarkAsReadReceiver]. + */ class ReactionReceiver : BroadcastReceiver(), KoinComponent { private val serviceRepository: ServiceRepository by inject() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) } @Suppress("TooGenericExceptionCaught", "ReturnCount") override fun onReceive(context: Context, intent: Intent) { @@ -45,11 +53,14 @@ class ReactionReceiver : val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0)) + val pendingResult = goAsync() scope.launch { try { serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey)) } catch (e: Exception) { Logger.e(e) { "Error sending reaction" } + } finally { + pendingResult.finish() } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index 4e82a735d..d7a943783 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -21,11 +21,11 @@ import android.content.Context import android.content.Intent import androidx.core.app.RemoteInput import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshServiceNotifications @@ -44,7 +44,9 @@ class ReplyReceiver : private val meshServiceNotifications: MeshServiceNotifications by inject() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } companion object { const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt index 436d2dec7..22bacf43a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt @@ -34,8 +34,10 @@ import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcas @Single class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) : SharedServiceBroadcasts { - // A mapping of receiver class name to package name - used for explicit broadcasts - private val clientPackages = mutableMapOf() + // A mapping of receiver class name to package name - used for explicit broadcasts. + // ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads + // while explicitBroadcast() iterates from coroutine contexts. + private val clientPackages = java.util.concurrent.ConcurrentHashMap() override fun subscribeReceiver(receiverName: String, packageName: String) { clientPackages[receiverName] = packageName @@ -131,7 +133,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED)) } - // Restore legacy action for other consumers (e.g. mesh_service_example) + // Restore legacy action for other consumers (e.g. ATAK plugins) val legacyIntent = Intent(ACTION_CONNECTION_CHANGED).apply { putExtra(EXTRA_CONNECTED, stateStr) @@ -153,7 +155,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit private fun explicitBroadcast(intent: Intent) { context.sendBroadcast( intent, - ) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work + ) // We also do a regular (not explicit broadcast) so any context-registered receivers will work clientPackages.forEach { intent.setClassName(it.value, it.key) context.sendBroadcast(intent) diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt index 6d518f698..720f975d7 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.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:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding + package org.meshtastic.core.service.testing import org.meshtastic.core.model.DataPacket 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 0f645c6e3..a753d2d08 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,6 +63,7 @@ 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 @@ -78,12 +79,12 @@ class DirectRadioControllerImpl( } override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(nodeNum.toString()) + val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) } override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(nodeNum.toString()) + val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) val contact = SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) val action = ServiceAction.SendContact(contact) 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 7e9832b54..ebac9f71b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -19,19 +19,20 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import 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.repository.CommandSender +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications 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 @@ -51,35 +52,30 @@ 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, ) { - 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 + // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors + // launched from start() are torn down cleanly and do not accumulate across start/stop/start cycles. + private var scope: CoroutineScope? = null /** Whether the orchestrator is currently running. */ val isRunning: Boolean - get() = serviceJob?.isActive == true + get() = scope?.isActive == true /** * Starts the mesh service components and wires up data flows. * - * 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. + * 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. */ fun start() { if (isRunning) { @@ -88,35 +84,31 @@ class MeshServiceOrchestrator( } Logger.i { "Starting mesh service orchestrator" } - val job = Job() - serviceJob = job - val scope = CoroutineScope(dispatchers.default + job) - serviceScope = scope + val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) + scope = newScope + + // Drop any bytes that piled up in the service's receivedData channel since the last stop(). The channel + // outlives the orchestrator's per-start scope, so without this drain a stop/start cycle would replay stale + // packets ahead of the fresh session's firmware handshake. + radioInterfaceService.resetReceivedBuffer() serviceNotifications.initChannels() - - packetHandler.start(scope) - router.start(scope) - nodeManager.start(scope) - connectionManager.start(scope) - messageProcessor.start(scope) - commandSender.start(scope) + connectionManager.updateStatusNotification() // Observe TAK server pref to start/stop - takJob = - takPrefs.isTakServerEnabled - .onEach { isEnabled -> - if (isEnabled && !takServerManager.isRunning.value) { - Logger.i { "TAK Server enabled by preference, starting integration" } - takMeshIntegration.start(scope) - } else if (!isEnabled && takServerManager.isRunning.value) { - Logger.i { "TAK Server disabled by preference, stopping integration" } - takMeshIntegration.stop() - } + takPrefs.isTakServerEnabled + .onEach { isEnabled -> + if (isEnabled && !takServerManager.isRunning.value) { + Logger.i { "TAK Server enabled by preference, starting integration" } + takMeshIntegration.start(newScope) + } else if (!isEnabled && takServerManager.isRunning.value) { + Logger.i { "TAK Server disabled by preference, stopping integration" } + takMeshIntegration.stop() } - .launchIn(scope) + } + .launchIn(newScope) - scope.handledLaunch { + newScope.handledLaunch { // Ensure the per-device database is active before the radio connects. // On Android this is handled by MeshUtilApplication.init(); on Desktop (and any // future KMP host) the orchestrator is the first entry point, so it must initialize @@ -130,18 +122,18 @@ class MeshServiceOrchestrator( radioInterfaceService.receivedData .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .launchIn(scope) + .launchIn(newScope) radioInterfaceService.connectionError .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(scope) + .launchIn(newScope) // Each action is dispatched in its own supervised coroutine so that a failure in one // action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently // drop all subsequent service actions for the rest of the session. serviceRepository.serviceAction - .onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } } - .launchIn(scope) + .onEach { action -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } + .launchIn(newScope) nodeManager.loadCachedNodeDB() } @@ -153,14 +145,11 @@ class MeshServiceOrchestrator( */ fun stop() { Logger.i { "Stopping mesh service orchestrator" } - takJob?.cancel() - takJob = null // Guard stop() so we don't emit a spurious "stopped" log when TAK was never started if (takServerManager.isRunning.value) { takMeshIntegration.stop() } - serviceJob?.cancel() - serviceJob = null - serviceScope = null + scope?.cancel() + scope = 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 ad5b92bd5..8671188ef 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 { - // Connection state to our radio device + // Canonical app-level connection state — written exclusively by MeshConnectionManager. 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 309dda937..1bb63971c 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -19,10 +19,15 @@ 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 @@ -32,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -39,7 +45,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ignoreException +import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState @@ -53,6 +59,7 @@ import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory +import kotlin.concurrent.Volatile /** * Shared multiplatform connection orchestrator for Meshtastic radios. @@ -76,20 +83,31 @@ 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() - private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) - override val receivedData: SharedFlow = _receivedData + // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the + // firmware handshake depends on (initial config packet ordering). A SharedFlow with + // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. + // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can + // remain a non-suspend synchronous callback. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = - MutableSharedFlow( - extraBufferCapacity = 64, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, - ) + MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) @@ -99,20 +117,28 @@ class SharedRadioInterfaceService( get() = _serviceScope private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - private var radioIf: RadioTransport? = null - private var runningInterfaceId: InterfaceId? = null + private var radioTransport: RadioTransport? = null + private var runningTransportId: InterfaceId? = null private var isStarted = false - private val listenersInitialized = kotlinx.atomicfu.atomic(false) - private var heartbeatJob: kotlinx.coroutines.Job? = null + private val listenersInitialized = atomic(false) + private var heartbeatJob: Job? = null private var lastHeartbeatMillis = 0L + @Volatile private var lastDataReceivedMillis = 0L + companion object { private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L + + // If we haven't received any data from the radio within this window after sending a + // heartbeat while the connection is nominally "Connected", the connection is likely a + // zombie (BLE stack didn't report disconnect). Two missed heartbeat intervals gives + // the firmware a reasonable window to respond or send telemetry. + private const val LIVENESS_TIMEOUT_MILLIS = HEARTBEAT_INTERVAL_MILLIS * 2 } private val initLock = Mutex() - private val interfaceMutex = Mutex() + private val transportMutex = Mutex() private fun initStateListeners() { if (listenersInitialized.value) return @@ -123,22 +149,23 @@ class SharedRadioInterfaceService( radioPrefs.devAddr .onEach { addr -> - interfaceMutex.withLock { + transportMutex.withLock { if (_currentDeviceAddressFlow.value != addr) { _currentDeviceAddressFlow.value = addr - startInterfaceLocked() + startTransportLocked() } } } + .catch { Logger.e(it) { "devAddr flow crashed" } } .launchIn(processLifecycle.coroutineScope) bluetoothRepository.state .onEach { state -> - interfaceMutex.withLock { + transportMutex.withLock { if (state.enabled) { - startInterfaceLocked() - } else if (runningInterfaceId == InterfaceId.BLUETOOTH) { - stopInterfaceLocked() + startTransportLocked() + } else if (runningTransportId == InterfaceId.BLUETOOTH) { + stopTransportLocked() } } } @@ -147,11 +174,11 @@ class SharedRadioInterfaceService( networkRepository.networkAvailable .onEach { state -> - interfaceMutex.withLock { + transportMutex.withLock { if (state) { - startInterfaceLocked() - } else if (runningInterfaceId == InterfaceId.TCP) { - stopInterfaceLocked() + startTransportLocked() + } else if (runningTransportId == InterfaceId.TCP) { + stopTransportLocked() } } } @@ -162,11 +189,11 @@ class SharedRadioInterfaceService( } override fun connect() { - processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } } + processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } } initStateListeners() } - override fun isMockInterface(): Boolean = transportFactory.isMockInterface() + override fun isMockTransport(): Boolean = transportFactory.isMockTransport() override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = transportFactory.toInterfaceAddress(interfaceId, rest) @@ -197,17 +224,17 @@ class SharedRadioInterfaceService( _currentDeviceAddressFlow.value = sanitized processLifecycle.coroutineScope.launch { - interfaceMutex.withLock { - ignoreException { stopInterfaceLocked() } - startInterfaceLocked() + transportMutex.withLock { + ignoreExceptionSuspend { stopTransportLocked() } + startTransportLocked() } } return true } - /** Must be called under [interfaceMutex]. */ - private fun startInterfaceLocked() { - if (radioIf != null) return + /** Must be called under [transportMutex]. */ + private fun startTransportLocked() { + if (radioTransport != 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 @@ -219,51 +246,83 @@ class SharedRadioInterfaceService( return } - Logger.i { "Starting radio interface for ${address.anonymize}" } + Logger.i { "Starting radio transport for ${address.anonymize}" } isStarted = true - runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - radioIf = transportFactory.createTransport(address, this) + runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } + radioTransport = transportFactory.createTransport(address, this) startHeartbeat() } - /** Must be called under [interfaceMutex]. */ - private fun stopInterfaceLocked() { - val currentIf = radioIf - Logger.i { "Stopping interface $currentIf" } + /** Must be called under [transportMutex]. */ + private suspend fun stopTransportLocked() { + val currentTransport = radioTransport + Logger.i { "Stopping transport $currentTransport" } isStarted = false - radioIf = null - runningInterfaceId = null - currentIf?.close() + radioTransport = null + runningTransportId = null + currentTransport?.close() - _serviceScope.cancel("stopping interface") + _serviceScope.cancel("stopping transport") _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - if (currentIf != null) { + if (currentTransport != null) { onDisconnect(isPermanent = true) } } private fun startHeartbeat() { heartbeatJob?.cancel() + lastDataReceivedMillis = nowMillis heartbeatJob = serviceScope.launch { while (true) { delay(HEARTBEAT_INTERVAL_MILLIS) keepAlive() + checkLiveness() } } } + /** + * Detects zombie connections where the BLE stack didn't report a disconnect. + * + * If we believe we're connected but haven't received any data from the radio within [LIVENESS_TIMEOUT_MILLIS], the + * connection is likely dead. Signal a non-permanent disconnect so the reconnect machinery can take over. + */ + private fun checkLiveness() { + if (_connectionState.value != ConnectionState.Connected) return + + val silenceMs = nowMillis - lastDataReceivedMillis + if (silenceMs > LIVENESS_TIMEOUT_MILLIS) { + Logger.w { + "Liveness check failed: no data received for ${silenceMs}ms " + + "(threshold: ${LIVENESS_TIMEOUT_MILLIS}ms). Treating as disconnect." + } + onDisconnect(isPermanent = false, errorMessage = "Connection timeout — no data received") + } + } + fun keepAlive(now: Long = nowMillis) { if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { - radioIf?.keepAlive() + radioTransport?.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 + ?: run { + Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" } + return + } _serviceScope.handledLaunch { - radioIf?.handleSendToRadio(bytes) + currentTransport.handleSendToRadio(bytes) _meshActivity.tryEmit(MeshActivity.Send) } } @@ -271,18 +330,35 @@ class SharedRadioInterfaceService( @Suppress("TooGenericExceptionCaught") override fun handleFromRadio(bytes: ByteArray) { try { - processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } + 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" } + } _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 // onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck // in Connected while the transport was actually disconnected. + lastDataReceivedMillis = nowMillis if (_connectionState.value != ConnectionState.Connected) { Logger.d { "Broadcasting connection state change to Connected" } _connectionState.value = ConnectionState.Connected 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 d007f1ea3..3fae4287b 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,9 +16,19 @@ */ 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 +class CoreServiceModule { + @Single + @Named("ServiceScope") + fun provideServiceScope(dispatchers: CoroutineDispatchers): CoroutineScope = + CoroutineScope(dispatchers.default + SupervisorJob()) +} 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 611454d05..87109be1e 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -23,8 +23,10 @@ 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 @@ -41,7 +43,6 @@ 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 @@ -57,12 +58,10 @@ 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) @@ -71,9 +70,13 @@ class MeshServiceOrchestratorTest { private val takPrefs: TakPrefs = mock(MockMode.autofill) private val cotHandler: CoTHandler = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) + private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) + @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() - private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) + + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( @@ -107,18 +110,16 @@ 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, - dispatchers = dispatchers, databaseManager = databaseManager, + connectionManager = connectionManager, + dispatchers = dispatchers, ) } @@ -131,7 +132,6 @@ class MeshServiceOrchestratorTest { assertTrue(orchestrator.isRunning) verify { serviceNotifications.initChannels() } - verify { packetHandler.start(any()) } verify { nodeManager.loadCachedNodeDB() } orchestrator.stop() @@ -217,10 +217,84 @@ 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 8f8e08d45..5b3d6df0d 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -25,17 +24,18 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import java.io.File @Single -class JvmFileService : FileService { - override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(Dispatchers.IO) { +class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService { + override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(dispatchers.io) { try { - // Treat uriString as a local file path - val file = File(uri.uriString) + // Treat URI string as a local file path + val file = File(uri.toString()) file.parentFile?.mkdirs() file.sink().buffer().use { sink -> block(sink) } true @@ -45,10 +45,10 @@ class JvmFileService : FileService { } } - override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(Dispatchers.IO) { + override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(dispatchers.io) { try { - val file = File(uri.uriString) + val file = File(uri.toString()) file.source().buffer().use { source -> block(source) } true } catch (e: Exception) { diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceClientTest.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceClientTest.kt deleted file mode 100644 index 4548fe931..000000000 --- a/core/service/src/test/kotlin/org/meshtastic/core/service/ServiceClientTest.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.service - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.os.IInterface -import dev.mokkery.MockMode -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.matcher.capture.Capture -import dev.mokkery.matcher.capture.capture -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verify.exactly -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.concurrent.thread -import kotlin.test.Test -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.fail - -@OptIn(ExperimentalCoroutinesApi::class) -class ServiceClientTest { - - interface MyInterface : IInterface - - private val stubFactory: (IBinder) -> MyInterface = { _ -> mock() } - private val client = ServiceClient(stubFactory) - private val context = mock(MockMode.autofill) - private val intent = mock() - private val binder = mock() - - @Test - fun `connect binds service successfully`() = runTest { - val slot = Capture.slot() - every { context.bindService(any(), capture(slot), any()) } returns true - - client.connect(context, intent, 0) - - verify { context.bindService(intent, any(), 0) } - - // Simulate connection - try { - slot.get().onServiceConnected(ComponentName("pkg", "cls"), binder) - assertNotNull(client.serviceP) - } catch (e: NoSuchElementException) { - fail("ServiceConnection was not captured") - } - } - - @Test - fun `connect retries on failure`() = runTest { - val slot = Capture.slot() - // First attempt fails, second succeeds - every { context.bindService(any(), capture(slot), any()) } sequentially - { - returns(false) - returns(true) - } - - client.connect(context, intent, 0) - - verify(exactly(2)) { context.bindService(intent, any(), 0) } - } - - @Test - fun `connect throws exception after two failures`() = runTest { - every { context.bindService(any(), any(), any()) } returns false - assertFailsWith { client.connect(context, intent, 0) } - } - - @Test - fun `waitConnect blocks until connected`() { - val slot = Capture.slot() - every { context.bindService(any(), capture(slot), any()) } returns true - - // Run connect in a coroutine scope (it's suspend) - runTest { client.connect(context, intent, 0) } - - val latch = CountDownLatch(1) - thread { - client.waitConnect() - latch.countDown() - } - - // Verify it's blocked (wait a bit) - if (latch.await(100, TimeUnit.MILLISECONDS)) { - fail("waitConnect should block until connected") - } - - // Simulate connection - try { - slot.get().onServiceConnected(ComponentName("pkg", "cls"), binder) - } catch (e: NoSuchElementException) { - fail("ServiceConnection was not captured") - } - - // Verify it unblocks - if (!latch.await(1, TimeUnit.SECONDS)) { - fail("waitConnect should unblock after connection") - } - - assertNotNull(client.serviceP) - } - - @Test - fun `close unbinds service`() = runTest { - val slot = Capture.slot() - every { context.bindService(any(), capture(slot), any()) } returns true - - client.connect(context, intent, 0) - - try { - client.close() - verify { context.unbindService(slot.get()) } - assertNull(client.serviceP) - } catch (e: NoSuchElementException) { - fail("ServiceConnection was not captured") - } - } -} diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts index a1b1a7acb..02343cae3 100644 --- a/core/takserver/build.gradle.kts +++ b/core/takserver/build.gradle.kts @@ -56,9 +56,6 @@ kotlin { commonTest.dependencies { implementation(projects.core.testing) implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - implementation(libs.kotest.assertions) - implementation(libs.kotest.property) } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt index cd616417d..732d03064 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt @@ -20,47 +20,41 @@ package org.meshtastic.core.takserver import kotlin.time.Instant -fun CoTMessage.toXml(): String { - val sb = StringBuilder() - sb.append( +fun CoTMessage.toXml(): String = buildString { + append( "", ) contact?.let { - sb.append( + append( "", ) } - group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } + group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } - status?.let { sb.append("") } + status?.let { append("") } - track?.let { sb.append("") } + track?.let { append("") } if (chat != null) { val senderUid = uid.geoChatSenderUid() val messageId = uid.geoChatMessageId() - sb.append( + append( "<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>", ) - sb.append("") - sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") - sb.append( + append("") + append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") + append( "${chat.message.xmlEscaped()}", ) } else if (!remarks.isNullOrEmpty()) { - sb.append("${remarks.xmlEscaped()}") + append("${remarks.xmlEscaped()}") } - rawDetailXml?.let { - if (it.isNotEmpty()) { - sb.append(it) - } - } + rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) } - sb.append("") - return sb.toString() + append("") } private fun Instant.toXmlString(): String = this.toString() diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt index 9a24d6721..16e75481c 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt @@ -52,6 +52,9 @@ class TAKClientConnection( private val writeChannel: ByteWriteChannel = socket.openWriteChannel(autoFlush = true) private val writeMutex = Mutex() + /** Tracks the last time data was received from the client, used for idle timeout detection. */ + @Volatile private var lastDataReceived: Instant = Clock.System.now() + /** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */ @Volatile private var disconnectedEmitted = false @@ -86,6 +89,7 @@ class TAKClientConnection( readChannel.awaitContent() val bytesRead = readChannel.readAvailable(buffer) if (bytesRead > 0) { + lastDataReceived = Clock.System.now() processReceivedData(buffer.copyOfRange(0, bytesRead)) } else if (bytesRead == -1) { break // EOF @@ -102,16 +106,34 @@ class TAKClientConnection( } private suspend fun keepaliveLoop() { + val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER while (scope.coroutineIsActive && !socket.isClosed) { kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS) + + val idleMs = (Clock.System.now() - lastDataReceived).inWholeMilliseconds + if (idleMs > idleTimeoutMs) { + Logger.w { + "TAK client ${currentClientInfo.id} idle for ${idleMs}ms " + + "(threshold ${idleTimeoutMs}ms), closing connection" + } + close() + return + } + sendKeepalive() } } private fun sendKeepalive() { val now = Clock.System.now() - val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds - sendXml(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = "")) + val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds + sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t", now = now, stale = stale, detail = "")) + } + + private fun sendPong() { + val now = Clock.System.now() + val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds + sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = "")) } private fun processReceivedData(newData: ByteArray) { @@ -131,7 +153,7 @@ class TAKClientConnection( return } cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" -> { - // Keepalive / ping — discard silently + sendPong() return } else -> { @@ -201,6 +223,7 @@ class TAKClientConnection( throw e } catch (e: Exception) { Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" } + close() } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt index eef798bf9..8dd76bd05 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt @@ -29,6 +29,8 @@ internal const val DEFAULT_TAK_STALE_MINUTES = 10 internal const val TAK_HEX_RADIX = 16 internal const val TAK_XML_READ_BUFFER_SIZE = 4_096 internal const val TAK_KEEPALIVE_INTERVAL_MS = 30_000L +internal const val TAK_KEEPALIVE_STALE_MULTIPLIER = 3 +internal const val TAK_READ_IDLE_TIMEOUT_MULTIPLIER = 5 internal const val TAK_ACCEPT_LOOP_DELAY_MS = 100L internal const val TAK_COORDINATE_SCALE = 1e7 internal const val TAK_UNKNOWN_POINT_VALUE = 9_999_999.0 diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index 31248ec41..0a47321d6 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -18,12 +18,15 @@ package org.meshtastic.core.takserver import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -58,6 +61,12 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager private val _inboundMessages = MutableSharedFlow() override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() + // Unbounded channel preserves FIFO ordering of inbound CoT messages under load. + // onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED) + // and a single consumer coroutine drains into _inboundMessages in order. + private var inboundChannel: Channel? = null + private var inboundDrainJob: Job? = null + private var lastBroadcastPositions = mutableMapOf() override fun start(scope: CoroutineScope) { @@ -68,8 +77,11 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager } scope.launch { - // Wire up inbound message handler BEFORE starting so no messages are lost - takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } } + // Wire up inbound message handler BEFORE starting so no messages are lost. + val channel = Channel(Channel.UNLIMITED) + inboundChannel = channel + inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } } + takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) } val result = takServer.start(scope) if (result.isSuccess) { @@ -79,6 +91,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" } // Clear onMessage if start failed so we don't hold a reference unnecessarily takServer.onMessage = null + inboundDrainJob?.cancel() + inboundDrainJob = null + channel.close() + inboundChannel = null } } } @@ -86,6 +102,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager override fun stop() { takServer.stop() takServer.onMessage = null + inboundChannel?.close() + inboundChannel = null + inboundDrainJob?.cancel() + inboundDrainJob = null _isRunning.value = false scope = null Logger.i { "TAK Server stopped" } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt index 65d7077f9..48c635560 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt @@ -16,12 +16,16 @@ */ package org.meshtastic.core.takserver.fountain +import okio.ByteString.Companion.toByteString + internal expect object ZlibCodec { fun compress(data: ByteArray): ByteArray? fun decompress(data: ByteArray): ByteArray? } -internal expect object CryptoCodec { - fun sha256Prefix8(data: ByteArray): ByteArray +internal object CryptoCodec { + private const val PREFIX_SIZE = 8 + + fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE) } diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKDefaultsTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKDefaultsTest.kt index d490e2f73..679b5beed 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKDefaultsTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKDefaultsTest.kt @@ -21,6 +21,7 @@ import org.meshtastic.proto.Team import org.meshtastic.proto.User import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class TAKDefaultsTest { @@ -104,4 +105,22 @@ class TAKDefaultsTest { val user = User(id = "!1234", long_name = "", short_name = "") assertEquals("!1234", user.toTakCallsign()) } + + // ── keepalive / idle timeout constants ───────────────────────────────────── + + @Test + fun `keepalive stale window is wider than keepalive interval`() { + val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER + assertTrue( + staleMs > TAK_KEEPALIVE_INTERVAL_MS, + "Stale window ($staleMs ms) must exceed keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms)", + ) + } + + @Test + fun `idle timeout exceeds keepalive stale window`() { + val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER + val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER + assertTrue(idleTimeoutMs > staleMs, "Idle timeout ($idleTimeoutMs ms) must exceed stale window ($staleMs ms)") + } } diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt similarity index 83% rename from core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt index 4473fc521..b0e4f1030 100644 --- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt @@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret import kotlinx.cinterop.usePinned import kotlinx.cinterop.value -import platform.CoreCrypto.CC_SHA256 -import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH import platform.zlib.Z_BUF_ERROR import platform.zlib.Z_OK import platform.zlib.compress @@ -105,20 +103,3 @@ internal actual object ZlibCodec { return null } } - -internal actual object CryptoCodec { - @OptIn(ExperimentalForeignApi::class) - actual fun sha256Prefix8(data: ByteArray): ByteArray { - val digest = ByteArray(CC_SHA256_DIGEST_LENGTH) - if (data.isNotEmpty()) { - data.usePinned { dataPin -> - digest.usePinned { digestPin -> - CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret()) - } - } - } else { - digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) } - } - return digest.copyOf(8) - } -} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt similarity index 90% rename from core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt rename to core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt index 9db28ac66..fca9f0f52 100644 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.takserver.fountain import java.io.ByteArrayOutputStream -import java.security.MessageDigest import java.util.zip.Deflater import java.util.zip.Inflater @@ -66,10 +65,3 @@ internal actual object ZlibCodec { } } } - -internal actual object CryptoCodec { - actual fun sha256Prefix8(data: ByteArray): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - return digest.digest(data).copyOf(8) - } -} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 51e78d566..8d0b5837a 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { android { namespace = "org.meshtastic.core.testing" androidResources.enable = false + withHostTest {} } sourceSets { @@ -31,11 +32,10 @@ kotlin { // Heavy modules (database, data, domain) should depend on core:testing, not vice versa. api(projects.core.model) api(projects.core.repository) - api(projects.core.database) - api(projects.core.ble) + implementation(projects.core.database) + implementation(projects.core.ble) implementation(projects.core.datastore) implementation(libs.androidx.room.runtime) - implementation(libs.jetbrains.lifecycle.runtime) api(libs.kermit) // Testing libraries - these are public API for all test consumers diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 2b9f9918f..0eb120fbe 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -84,6 +84,12 @@ class FakeUiPrefs : UiPrefs { theme.value = value } + override val contrastLevel = MutableStateFlow(0) + + override fun setContrastLevel(value: Int) { + contrastLevel.value = value + } + override val locale = MutableStateFlow("en") override fun setLocale(languageTag: String) { @@ -231,15 +237,6 @@ class FakeMeshPrefs : MeshPrefs { deviceAddress.value = address } - private val provideLocation = mutableMapOf>() - - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = - provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide - } - private val lastRequest = mutableMapOf>() override fun getStoreForwardLastRequest(address: String?): StateFlow = diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index 27dc3facc..e5280ec45 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, timeoutMs: Long): BleConnectionState { + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): 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 new file mode 100644 index 000000000..ef8cac0ba --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.repository.DeviceHardwareRepository + +/** + * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`. + * + * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned + * for a given lookup. By default, lookups return `Result.success(null)`. + */ +class FakeDeviceHardwareRepository : + BaseFake(), + DeviceHardwareRepository { + + private val hardware = mutableMapOf, Result>() + private val calls = mutableListOf>() + + init { + registerResetAction { + hardware.clear() + calls.clear() + } + } + + /** Records every [getDeviceHardwareByModel] invocation for assertion. */ + val recordedCalls: List> + get() = calls.toList() + + override suspend fun getDeviceHardwareByModel( + hwModel: Int, + target: String?, + forceRefresh: Boolean, + ): Result { + calls.add(Triple(hwModel, target, forceRefresh)) + return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null) + } + + /** Seeds a successful lookup for the given model/target pair. */ + fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) { + hardware[hwModel to target] = Result.success(device) + } + + /** Seeds a successful lookup for any target of the given model. */ + fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) { + hardware[hwModel to null] = Result.success(device) + } + + /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */ + fun setResult(hwModel: Int, target: String? = null, result: Result) { + hardware[hwModel to target] = result + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt new file mode 100644 index 000000000..166256764 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.repository.FirmwareReleaseRepository + +/** + * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as + * [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. + */ +class FakeFirmwareReleaseRepository : + BaseFake(), + FirmwareReleaseRepository { + + private val _stableRelease = mutableStateFlow(null) + private val _alphaRelease = mutableStateFlow(null) + + override val stableRelease: Flow = _stableRelease + override val alphaRelease: Flow = _alphaRelease + + var invalidateCacheCalls: Int = 0 + private set + + init { + registerResetAction { invalidateCacheCalls = 0 } + } + + override suspend fun invalidateCache() { + invalidateCacheCalls++ + } + + fun setStableRelease(release: FirmwareRelease?) { + _stableRelease.value = release + } + + fun setAlphaRelease(release: FirmwareRelease?) { + _alphaRelease.value = release + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index c90e69da9..4f0a4b153 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,6 +16,7 @@ */ 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 @@ -28,10 +29,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun initChannels() {} - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ): Any = Any() + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {} 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 new file mode 100644 index 000000000..215542485 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.repository.QuickChatActionRepository + +/** + * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`). + * + * The in-memory list is exposed reactively through [getAllActions]. + */ +class FakeQuickChatActionRepository : + BaseFake(), + QuickChatActionRepository { + + private val actionsFlow = mutableStateFlow>(emptyList()) + + override fun getAllActions(): Flow> = actionsFlow + + override suspend fun upsert(action: QuickChatAction) { + val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid } + actionsFlow.value = + if (existingIndex >= 0) { + actionsFlow.value.toMutableList().also { it[existingIndex] = action } + } else { + actionsFlow.value + action + } + .sortedBy { it.position } + } + + override suspend fun deleteAll() { + actionsFlow.value = emptyList() + } + + override suspend fun delete(action: QuickChatAction) { + actionsFlow.value = + actionsFlow.value + .filterNot { it.uuid == action.uuid } + .map { if (it.position > action.position) it.copy(position = it.position - 1) else it } + } + + override suspend fun setItemPosition(uuid: Long, newPos: Int) { + actionsFlow.value = + actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position } + } + + /** Seeds the current list of actions (useful for test setup). */ + fun setActions(actions: List) { + actionsFlow.value = actions.sortedBy { it.position } + } + + /** Returns the current in-memory snapshot. */ + val currentActions: List + get() = actionsFlow.value +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt new file mode 100644 index 000000000..aa68e9b21 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.FileInfo +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +/** + * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately. + * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set. + */ +@Suppress("TooManyFunctions") +class FakeRadioConfigRepository : + BaseFake(), + RadioConfigRepository { + + private val channelSetBacking = mutableStateFlow(ChannelSet()) + override val channelSetFlow: Flow = channelSetBacking + + private val localConfigBacking = mutableStateFlow(LocalConfig()) + override val localConfigFlow: Flow = localConfigBacking + + private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig()) + override val moduleConfigFlow: Flow = moduleConfigBacking + + private val deviceProfileBacking = mutableStateFlow(DeviceProfile()) + override val deviceProfileFlow: Flow = deviceProfileBacking + val currentDeviceProfile: DeviceProfile + get() = deviceProfileBacking.value + + private val deviceUIConfigBacking = mutableStateFlow(null) + override val deviceUIConfigFlow: Flow = deviceUIConfigBacking + + private val fileManifestBacking = mutableStateFlow>(emptyList()) + override val fileManifestFlow: Flow> = fileManifestBacking + + val currentChannelSet: ChannelSet + get() = channelSetBacking.value + + val currentLocalConfig: LocalConfig + get() = localConfigBacking.value + + val currentModuleConfig: LocalModuleConfig + get() = moduleConfigBacking.value + + val currentDeviceUIConfig: DeviceUIConfig? + get() = deviceUIConfigBacking.value + + val currentFileManifest: List + get() = fileManifestBacking.value + + /** + * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive + * state. + */ + var lastSetLocalConfig: Config? = null + private set + + /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */ + var lastSetModuleConfig: ModuleConfig? = null + private set + + init { + registerResetAction { + lastSetLocalConfig = null + lastSetModuleConfig = null + } + } + + override suspend fun clearChannelSet() { + channelSetBacking.value = ChannelSet() + } + + override suspend fun replaceAllSettings(settingsList: List) { + channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) + } + + override suspend fun updateChannelSettings(channel: Channel) { + val current = channelSetBacking.value.settings.toMutableList() + while (current.size <= channel.index) current.add(ChannelSettings()) + current[channel.index] = channel.settings ?: ChannelSettings() + channelSetBacking.value = channelSetBacking.value.copy(settings = current) + } + + override suspend fun clearLocalConfig() { + localConfigBacking.value = LocalConfig() + } + + override suspend fun setLocalConfig(config: Config) { + lastSetLocalConfig = config + } + + override suspend fun clearLocalModuleConfig() { + moduleConfigBacking.value = LocalModuleConfig() + } + + override suspend fun setLocalModuleConfig(config: ModuleConfig) { + lastSetModuleConfig = config + } + + override suspend fun setDeviceUIConfig(config: DeviceUIConfig) { + deviceUIConfigBacking.value = config + } + + override suspend fun clearDeviceUIConfig() { + deviceUIConfigBacking.value = null + } + + override suspend fun addFileInfo(info: FileInfo) { + fileManifestBacking.value = fileManifestBacking.value + info + } + + override suspend fun clearFileManifest() { + fileManifestBacking.value = emptyList() + } + + /** Directly sets the [LocalConfig] without merging (preferred for test setup). */ + fun setLocalConfigDirect(config: LocalConfig) { + localConfigBacking.value = config + } + + /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */ + fun setLocalModuleConfigDirect(config: LocalModuleConfig) { + moduleConfigBacking.value = config + } + + /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */ + fun setDeviceProfile(profile: DeviceProfile) { + deviceProfileBacking.value = profile + } + + /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */ + fun setChannelSet(channelSet: ChannelSet) { + channelSetBacking.value = channelSet + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index bf83be372..d23a7f1ec 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,8 +19,13 @@ 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. @@ -30,6 +35,7 @@ class FakeRadioController : BaseFake(), RadioController { + /** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */ private val _connectionState = mutableStateFlow(ConnectionState.Connected) override val connectionState: StateFlow = _connectionState @@ -78,19 +84,19 @@ class FakeRadioController : return true } - override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {} + override suspend fun setLocalConfig(config: Config) {} - override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {} + override suspend fun setLocalChannel(channel: Channel) {} - override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {} + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {} - override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {} + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {} - override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {} + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} - override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {} + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {} - override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {} + override suspend fun setFixedPosition(destNum: Int, position: Position) {} override suspend fun setRingtone(destNum: Int, ringtone: String) {} @@ -124,7 +130,7 @@ class FakeRadioController : override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {} - override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {} + override suspend fun requestPosition(destNum: Int, currentPosition: 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 e1a26c6c3..d3f8dc71e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -18,30 +18,43 @@ 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. */ +/** + * 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). + */ @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 - private val _receivedData = MutableSharedFlow() - override val receivedData: SharedFlow = _receivedData + // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would + // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow() override val meshActivity: SharedFlow = _meshActivity @@ -52,7 +65,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main val sentToRadio = mutableListOf() var connectCalled = false - override fun isMockInterface(): Boolean = true + override fun isMockTransport(): Boolean = true override fun sendToRadio(bytes: ByteArray) { sentToRadio.add(bytes) @@ -80,13 +93,18 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main } override fun handleFromRadio(bytes: ByteArray) { - // In a real implementation, this would emit to receivedData + _receivedData.trySend(bytes) + } + + override fun resetReceivedBuffer() { + @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") + while (_receivedData.tryReceive().isSuccess) Unit } // --- Helper methods for testing --- - suspend fun emitFromRadio(bytes: ByteArray) { - _receivedData.emit(bytes) + fun emitFromRadio(bytes: ByteArray) { + _receivedData.trySend(bytes) } fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt index 66afa69be..492802426 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt @@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closeCalled = true } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index 266a0d958..ae06843b6 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,6 +31,7 @@ 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 new file mode 100644 index 000000000..a52b86bd0 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.proto.Position + +/** + * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`. + * + * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log. + */ +class FakeTracerouteSnapshotRepository : + BaseFake(), + TracerouteSnapshotRepository { + + private val snapshots = mutableStateFlow>>(emptyMap()) + private val requestIds = mutableMapOf() + + init { + registerResetAction { requestIds.clear() } + } + + override fun getSnapshotPositions(logUuid: String): Flow> = + snapshots.map { it[logUuid].orEmpty() } + + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { + requestIds[logUuid] = requestId + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ + fun seedSnapshot(logUuid: String, positions: Map) { + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Returns the last request-id recorded for [logUuid], or `null` if none. */ + fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt new file mode 100644 index 000000000..f9a63c712 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepositoryFakesTest { + + @Test + fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { + val repo = FakeDeviceHardwareRepository() + val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") + repo.setHardware(hwModel = 42, target = "tlora", device = hw) + + val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) + val miss = repo.getDeviceHardwareByModel(hwModel = 99) + + assertEquals(hw, hit.getOrNull()) + assertNull(miss.getOrNull()) + assertEquals(2, repo.recordedCalls.size) + assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) + } + + @Test + fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { + val repo = FakeFirmwareReleaseRepository() + val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") + val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") + + repo.setStableRelease(stable) + repo.setAlphaRelease(alpha) + + assertEquals(stable, repo.stableRelease.first()) + assertEquals(alpha, repo.alphaRelease.first()) + + repo.invalidateCache() + repo.invalidateCache() + assertEquals(2, repo.invalidateCacheCalls) + } + + @Test + fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) + + repo.upsert(a) + repo.upsert(b) + assertEquals(listOf(a, b), repo.getAllActions().first()) + + repo.setItemPosition(uuid = 1L, newPos = 5) + assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) + + repo.delete(b) + assertEquals(1, repo.currentActions.size) + + repo.deleteAll() + assertTrue(repo.currentActions.isEmpty()) + } + + @Test + fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) + val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) + repo.upsert(a) + repo.upsert(b) + repo.upsert(c) + + repo.delete(b) + + // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. + assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) + } + + @Test + fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { + val repo = FakeTracerouteSnapshotRepository() + val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) + repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) + + repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } + assertEquals(99, repo.lastRequestId("log-1")) + assertNull(repo.lastRequestId("other")) + } + + @Test + fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { + val repo = FakeRadioConfigRepository() + val a = ChannelSettings(name = "A") + val b = ChannelSettings(name = "B") + + repo.replaceAllSettings(listOf(a, b)) + assertEquals(listOf(a, b), repo.currentChannelSet.settings) + + repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) + assertEquals("B2", repo.currentChannelSet.settings[1].name) + + repo.clearChannelSet() + assertTrue(repo.currentChannelSet.settings.isEmpty()) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index dbbe12db9..44b483c91 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -26,7 +26,6 @@ kotlin { android { namespace = "org.meshtastic.core.ui" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -43,8 +42,8 @@ kotlin { implementation(projects.core.resources) implementation(projects.core.service) + implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) - implementation(libs.compose.multiplatform.materialIconsExtended) implementation(libs.compose.multiplatform.ui) implementation(libs.compose.multiplatform.foundation) api(libs.compose.multiplatform.ui.tooling.preview) @@ -56,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) } } @@ -71,11 +70,9 @@ kotlin { implementation(projects.core.testing) implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - implementation(libs.kotest.assertions) - implementation(libs.kotest.property) + implementation(libs.compose.multiplatform.ui.test) } - val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } } + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/core/ui/src/androidHostTest/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt b/core/ui/src/androidHostTest/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt deleted file mode 100644 index 6d055886a..000000000 --- a/core/ui/src/androidHostTest/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt +++ /dev/null @@ -1,55 +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.timezone - -import kotlinx.datetime.TimeZone -import org.meshtastic.core.model.util.toPosixString -import kotlin.test.Test -import kotlin.test.assertEquals - -class ZoneIdExtensionsTest { - - @Test - fun `test POSIX string generation`() { - val zoneMap = - mapOf( - "US/Hawaii" to "HST10", - "US/Alaska" to "AKST9AKDT,M3.2.0,M11.1.0", - "US/Pacific" to "PST8PDT,M3.2.0,M11.1.0", - "US/Arizona" to "MST7", - "US/Mountain" to "MST7MDT,M3.2.0,M11.1.0", - "US/Central" to "CST6CDT,M3.2.0,M11.1.0", - "US/Eastern" to "EST5EDT,M3.2.0,M11.1.0", - "America/Sao_Paulo" to "BRT3", - "UTC" to "UTC0", - "Europe/London" to "GMT0BST,M3.5.0/1,M10.5.0", - "Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.5.0", - "Europe/Budapest" to "CET-1CEST,M3.5.0,M10.5.0/3", - "Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.5.0/4", - "Africa/Cairo" to "EET-2EEST,M4.5.5/0,M10.5.5/0", - "Asia/Kolkata" to "IST-5:30", - "Asia/Hong_Kong" to "HKT-8", - "Asia/Tokyo" to "JST-9", - "Australia/Perth" to "AWST-8", - "Australia/Adelaide" to "ACST-9:30ACDT,M10.1.0,M4.1.0/3", - "Australia/Sydney" to "AEST-10AEDT,M10.1.0,M4.1.0/3", - "Pacific/Auckland" to "NZST-12NZDT,M9.5.0,M4.1.0/3", - ) - - zoneMap.forEach { (tz, expected) -> assertEquals(expected, TimeZone.of(tz).toPosixString()) } - } -} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index f8b0586f4..aa47539bb 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import org.meshtastic.core.common.util.nowMillis @Composable actual fun rememberTimeTickWithLifecycle(): Long { val context = LocalContext.current - var value by remember { mutableLongStateOf(System.currentTimeMillis()) } + var value by remember { mutableLongStateOf(nowMillis) } DisposableEffect(context) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - value = System.currentTimeMillis() + value = nowMillis } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt index babb05fb3..dda2f2219 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt @@ -16,9 +16,7 @@ */ package org.meshtastic.core.ui.util -import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.provider.Settings import android.widget.Toast @@ -33,13 +31,6 @@ suspend fun Context.showToast(text: String) { Toast.makeText(this, text, Toast.LENGTH_SHORT).show() } -/** Finds the [Activity] from a [Context]. */ -fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - fun Context.openNfcSettings() { val intent = Intent(Settings.ACTION_NFC_SETTINGS) startActivity(intent) 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 97a24d54e..5365ab95e 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,24 +20,29 @@ package org.meshtastic.core.ui.util import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers +import com.eygraber.uri.toAndroidUri +import com.eygraber.uri.toKmpUri import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString +import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.ioDispatcher import java.net.URLEncoder @Composable @@ -102,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit { @Composable @Suppress("Wrapping") actual fun rememberSaveFileLauncher( - onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit, + onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit { val launcher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) }) - } + result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) } } } @@ -132,21 +135,21 @@ actual fun rememberSaveFileLauncher( actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - onUriReceived(uri?.let { CommonUri(it) }) + onUriReceived(uri?.let { it.toKmpUri() }) } return remember(launcher) { { mimeType -> launcher.launch(mimeType) } } } @Suppress("Wrapping") @Composable -actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? { +actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? { val context = LocalContext.current return remember(context) { { uri, maxChars -> - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { @Suppress("TooGenericExceptionCaught") try { - val androidUri = Uri.parse(uri.toString()) + val androidUri = uri.toAndroidUri() context.contentResolver.openInputStream(androidUri)?.use { stream -> stream.bufferedReader().use { reader -> val buffer = CharArray(maxChars) @@ -216,3 +219,67 @@ actual fun rememberOpenLocationSettings(): () -> Unit { } return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } } } + +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { + { + launcher.launch( + arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT), + ) + } + } +} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + // Pre-Android 13, no runtime notification permission required. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } +} + +@Composable +actual fun isLocationPermissionGranted(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } +} + +@Composable +actual fun isGpsDisabled(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { context.gpsDisabled() } +} + +/** + * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh + * when the user returns from a permission dialog or system settings screen. + */ +@Composable +private fun rememberOnResumeState(check: () -> Boolean): Boolean { + val state = remember { mutableStateOf(check()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { state.value = check() } + return state.value +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt index 7330c1aa6..125e1e117 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt @@ -38,6 +38,7 @@ fun ClickableTextField( onClick: () -> Unit, modifier: Modifier = Modifier, isError: Boolean = false, + trailingIconContentDescription: String? = null, ) { val source = remember { MutableInteractionSource() } val isPressed by source.collectIsPressedAsState() @@ -49,7 +50,7 @@ fun ClickableTextField( enabled = enabled, readOnly = true, label = { Text(stringResource(label)) }, - trailingIcon = { Icon(trailingIcon, null) }, + trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) }, isError = isError, interactionSource = source, modifier = modifier, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt index 872a5b82a..de3908c54 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt @@ -17,12 +17,6 @@ package org.meshtastic.core.ui.component import androidx.compose.animation.Crossfade -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Cached -import androidx.compose.material.icons.rounded.Snooze -import androidx.compose.material.icons.rounded.Usb -import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable @@ -35,9 +29,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.ui.icon.Bluetooth import org.meshtastic.core.ui.icon.Device +import org.meshtastic.core.ui.icon.DeviceSleep import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NoDevice +import org.meshtastic.core.ui.icon.Reconnecting +import org.meshtastic.core.ui.icon.Usb +import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -86,14 +85,14 @@ private fun getTint(connectionState: ConnectionState): Color = when (connectionS fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair = when (connectionState) { ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null - ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze - ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached + ConnectionState.DeviceSleep -> MeshtasticIcons.Device to MeshtasticIcons.DeviceSleep + ConnectionState.Connecting -> MeshtasticIcons.Device to MeshtasticIcons.Reconnecting else -> MeshtasticIcons.Device to when (deviceType) { - DeviceType.BLE -> Icons.Rounded.Bluetooth - DeviceType.TCP -> Icons.Rounded.Wifi - DeviceType.USB -> Icons.Rounded.Usb + DeviceType.BLE -> MeshtasticIcons.Bluetooth + DeviceType.TCP -> MeshtasticIcons.Wifi + DeviceType.USB -> MeshtasticIcons.Usb else -> null } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt index 05529c387..2d0172ea8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.ui.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.ContentCopy import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -28,6 +26,8 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy +import org.meshtastic.core.ui.icon.Copy +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.createClipEntry @Composable @@ -47,6 +47,6 @@ fun CopyIconButton( } }, ) { - Icon(imageVector = Icons.TwoTone.ContentCopy, contentDescription = label) + Icon(imageVector = MeshtasticIcons.Copy, contentDescription = label) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index 9d41d5f5a..22c6bfaf5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -62,12 +62,13 @@ fun > DropDownPreference( enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() } } - val items = enumConstants.map { - val label = itemLabel?.invoke(it) ?: it.name - val icon = itemIcon?.invoke(it) - val color = itemColor?.invoke(it) - DropDownItem(it, label, icon, color) - } + val items = + enumConstants.map { + val label = itemLabel?.invoke(it) ?: it.name + val icon = itemIcon?.invoke(it) + val color = itemColor?.invoke(it) + DropDownItem(it, label, icon, color) + } DropDownPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt index 26d2277a6..d62b8af99 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt @@ -21,9 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Close -import androidx.compose.material.icons.twotone.Refresh import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -50,6 +47,9 @@ import org.meshtastic.core.model.util.encodeToString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.error import org.meshtastic.core.resources.reset +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Refresh @Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") @Composable @@ -80,8 +80,8 @@ fun EditBase64Preference( val (icon, description) = when { - isError -> Icons.TwoTone.Close to stringResource(Res.string.error) - onGenerateKey != null && !isFocused -> Icons.TwoTone.Refresh to stringResource(Res.string.reset) + isError -> MeshtasticIcons.Close to stringResource(Res.string.error) + onGenerateKey != null && !isFocused -> MeshtasticIcons.Refresh to stringResource(Res.string.reset) else -> null to null } Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt index 652762dac..c45834638 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,6 +44,8 @@ import org.meshtastic.core.resources.gpio_pin import org.meshtastic.core.resources.ignore_incoming import org.meshtastic.core.resources.name import org.meshtastic.core.resources.type +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.proto.RemoteHardwarePin import org.meshtastic.proto.RemoteHardwarePinType @@ -85,7 +85,7 @@ inline fun EditListPreference( }, ) { Icon( - imageVector = Icons.TwoTone.Close, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.delete), modifier = Modifier.wrapContentSize(), ) 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 e8b71ee01..10b83ce41 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt @@ -18,14 +18,12 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.VisibilityOff import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -37,6 +35,9 @@ import org.jetbrains.compose.resources.stringResource 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 fun EditPasswordPreference( @@ -48,7 +49,7 @@ fun EditPasswordPreference( onValueChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { - var isPasswordVisible by remember { mutableStateOf(false) } + var isPasswordVisible by rememberSaveable { mutableStateOf(false) } EditTextPreference( title = title, @@ -63,9 +64,9 @@ fun EditPasswordPreference( onFocusChanged = {}, visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { + IconToggleButton(checked = isPasswordVisible, onCheckedChange = { isPasswordVisible = it }) { Icon( - imageVector = if (isPasswordVisible) Icons.TwoTone.VisibilityOff else Icons.TwoTone.VisibilityOff, + imageVector = if (isPasswordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, contentDescription = if (isPasswordVisible) { stringResource(Res.string.hide_password) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt index 9f6a59d5f..43a19ef1b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Info import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -45,6 +43,8 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.error +import org.meshtastic.core.ui.icon.Info +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun SignedIntegerEditTextPreference( @@ -234,7 +234,7 @@ fun EditTextPreference( } else if (isError) { { Icon( - imageVector = Icons.TwoTone.Info, + imageVector = MeshtasticIcons.Info, contentDescription = stringResource(Res.string.error), tint = MaterialTheme.colorScheme.error, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt index 42b569094..a7e13e54c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.hops_away -import org.meshtastic.core.ui.icon.Hops +import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme @@ -32,7 +32,7 @@ import org.meshtastic.core.ui.theme.AppTheme fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { IconInfo( modifier = modifier, - icon = MeshtasticIcons.Hops, + icon = MeshtasticIcons.HopCount, contentDescription = stringResource(Res.string.hops_away), label = stringResource(Res.string.hops_away), text = hops.toString(), 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 edda19c65..d8df4101b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -20,16 +20,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Nfc -import androidx.compose.material.icons.twotone.QrCodeScanner import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -52,8 +49,11 @@ import org.meshtastic.core.resources.scan_shared_contact_nfc import org.meshtastic.core.resources.scan_shared_contact_qr import org.meshtastic.core.resources.share_channels_qr import org.meshtastic.core.resources.url +import org.meshtastic.core.ui.icon.LinkIcon import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Nfc import org.meshtastic.core.ui.icon.QrCode2 +import org.meshtastic.core.ui.icon.QrCodeScanner import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported @@ -91,10 +91,10 @@ fun MeshtasticImportFAB( ) { sharedContact?.let { importDialog(it, onDismissSharedContact) } - var expanded by remember { mutableStateOf(false) } - var showUrlDialog by remember { mutableStateOf(false) } - var isNfcScanning by remember { mutableStateOf(false) } - var showNfcDisabledDialog by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } + var showUrlDialog by rememberSaveable { mutableStateOf(false) } + var isNfcScanning by rememberSaveable { mutableStateOf(false) } + var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) } val openNfcSettings = rememberOpenNfcSettings() val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } } @@ -155,7 +155,7 @@ fun MeshtasticImportFAB( stringResource( if (isContactContext) Res.string.scan_shared_contact_nfc else Res.string.scan_channels_nfc, ), - icon = Icons.Rounded.Nfc, + icon = MeshtasticIcons.Nfc, onClick = { isNfcScanning = true }, testTag = "nfc_import", ), @@ -169,7 +169,7 @@ fun MeshtasticImportFAB( stringResource( if (isContactContext) Res.string.scan_shared_contact_qr else Res.string.scan_channels_qr, ), - icon = Icons.TwoTone.QrCodeScanner, + icon = MeshtasticIcons.QrCodeScanner, onClick = { barcodeScanner.startScan() }, testTag = "qr_import", ), @@ -182,7 +182,7 @@ fun MeshtasticImportFAB( stringResource( if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url, ), - icon = Icons.Rounded.Link, + icon = MeshtasticIcons.LinkIcon, onClick = { showUrlDialog = true }, testTag = "url_import", ), diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index b84c11e13..2fa66b468 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,6 +59,7 @@ import org.meshtastic.core.resources.preview_gauge import org.meshtastic.core.resources.preview_gradient import org.meshtastic.core.resources.preview_pill import org.meshtastic.core.resources.preview_text +import org.meshtastic.core.resources.show_iaq_legend import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.ThumbUp import org.meshtastic.core.ui.icon.Warning @@ -120,13 +122,18 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil Column { when (displayMode) { IaqDisplayMode.Pill -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Box( modifier = Modifier.clip(RoundedCornerShape(10.dp)) .background(iaqEnum.color) .width(125.dp) .height(30.dp) - .clickable { isLegendOpen = true }, + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { Row( modifier = Modifier.padding(4.dp).align(Alignment.CenterStart), @@ -144,7 +151,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Dot -> { - Column(modifier = Modifier.clickable { isLegendOpen = true }) { + val legendLabel = stringResource(Res.string.show_iaq_legend) + Column( + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), + ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "$iaq") Spacer(modifier = Modifier.width(4.dp)) @@ -154,17 +169,30 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Text -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Text( text = getIaqDescriptionWithRange(iaqEnum), fontSize = 12.sp, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) } IaqDisplayMode.Gauge -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) CircularProgressIndicator( progress = { iaq / 500f }, - modifier = Modifier.size(60.dp).clickable { isLegendOpen = true }, + modifier = + Modifier.size(60.dp) + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), strokeWidth = 8.dp, color = iaqEnum.color, ) @@ -172,9 +200,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Gradient -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { LinearProgressIndicator( progress = { iaq / 500f }, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ListItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ListItem.kt index e4442f4cd..3f70294ea 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ListItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ListItem.kt @@ -19,9 +19,6 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Android import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -38,6 +35,9 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.meshtastic.core.ui.icon.Android +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.createClipEntry @@ -55,7 +55,7 @@ fun ListItem( enabled: Boolean = true, leadingIcon: ImageVector? = null, leadingIconTint: Color = LocalContentColor.current, - trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + trailingIcon: ImageVector? = MeshtasticIcons.ChevronRight, trailingIconTint: Color = LocalContentColor.current, onClick: (() -> Unit)? = null, ) { @@ -154,25 +154,25 @@ fun ImageVector?.icon(tint: Color = LocalContentColor.current): @Composable (() @Preview(showBackground = true) @Composable private fun ListItemPreview() { - AppTheme { ListItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = true) {} } + AppTheme { ListItem(text = "Text", leadingIcon = MeshtasticIcons.Android, enabled = true) {} } } @Preview(showBackground = true) @Composable private fun ListItemDisabledPreview() { - AppTheme { ListItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = false) {} } + AppTheme { ListItem(text = "Text", leadingIcon = MeshtasticIcons.Android, enabled = false) {} } } @Preview(showBackground = true) @Composable private fun SwitchListItemPreview() { - AppTheme { SwitchListItem(text = "Text", leadingIcon = Icons.Rounded.Android, checked = true, onClick = {}) } + AppTheme { SwitchListItem(text = "Text", leadingIcon = MeshtasticIcons.Android, checked = true, onClick = {}) } } @Preview(showBackground = true) @Composable private fun ListItemPreviewSupportingText() { AppTheme { - ListItem(text = "Text 1", leadingIcon = Icons.Rounded.Android, supportingText = "Text2", trailingIcon = null) + ListItem(text = "Text 1", leadingIcon = MeshtasticIcons.Android, supportingText = "Text2", trailingIcon = null) } } 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 18992c0e7..753468600 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -27,11 +27,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.SignalCellular4Bar -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.SignalCellularAlt1Bar -import androidx.compose.material.icons.rounded.SignalCellularAlt2Bar import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme @@ -41,15 +36,20 @@ import androidx.compose.runtime.Stable 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.unit.dp +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bad import org.meshtastic.core.resources.fair import org.meshtastic.core.resources.good +import org.meshtastic.core.resources.ic_signal_cellular_4_bar +import org.meshtastic.core.resources.ic_signal_cellular_alt +import org.meshtastic.core.resources.ic_signal_cellular_alt_1_bar +import org.meshtastic.core.resources.ic_signal_cellular_alt_2_bar import org.meshtastic.core.resources.none_quality import org.meshtastic.core.resources.rssi import org.meshtastic.core.resources.signal @@ -69,13 +69,13 @@ const val RSSI_FAIR_THRESHOLD = -126 @Stable enum class Quality( @Stable val nameRes: StringResource, - @Stable val imageVector: ImageVector, + @Stable val icon: DrawableResource, @Stable val color: @Composable () -> Color, ) { - NONE(Res.string.none_quality, Icons.Rounded.SignalCellularAlt1Bar, { colorScheme.StatusRed }), - BAD(Res.string.bad, Icons.Rounded.SignalCellularAlt2Bar, { colorScheme.StatusOrange }), - FAIR(Res.string.fair, Icons.Rounded.SignalCellularAlt, { colorScheme.StatusYellow }), - GOOD(Res.string.good, Icons.Rounded.SignalCellular4Bar, { colorScheme.StatusGreen }), + NONE(Res.string.none_quality, Res.drawable.ic_signal_cellular_alt_1_bar, { colorScheme.StatusRed }), + BAD(Res.string.bad, Res.drawable.ic_signal_cellular_alt_2_bar, { colorScheme.StatusOrange }), + FAIR(Res.string.fair, Res.drawable.ic_signal_cellular_alt, { colorScheme.StatusYellow }), + GOOD(Res.string.good, Res.drawable.ic_signal_cellular_4_bar, { colorScheme.StatusGreen }), } /** @@ -100,9 +100,9 @@ fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) { ) Icon( modifier = Modifier.size(SIZE_ICON_DP.dp), - imageVector = quality.imageVector, + imageVector = vectorResource(quality.icon), contentDescription = stringResource(Res.string.signal_quality), - tint = quality.color.invoke(), + tint = quality.color(), ) } } @@ -129,9 +129,9 @@ fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialThe ) { Icon( modifier = Modifier.size(SIZE_ICON_DP.dp), - imageVector = quality.imageVector, + imageVector = vectorResource(quality.icon), contentDescription = stringResource(Res.string.signal_quality), - tint = quality.color.invoke(), + tint = quality.color(), ) Text( text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}", @@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr), + text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}", color = color, style = MaterialTheme.typography.labelSmall, ) @@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) { } Text( modifier = modifier, - text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi), + text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}", color = color, style = MaterialTheme.typography.labelSmall, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt index 650c357b5..2bf85818e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt @@ -20,8 +20,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -39,6 +37,8 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ic_meshtastic import org.meshtastic.core.resources.navigate_back +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.MeshtasticIcons @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -78,7 +78,7 @@ fun MainAppBar( { IconButton(onClick = onNavigateUp) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = MeshtasticIcons.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), ) } 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 4b64052e5..1445bdedf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt @@ -19,8 +19,6 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Power import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -39,18 +37,18 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.icon.BatteryEmpty import org.meshtastic.core.ui.icon.BatteryUnknown import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PowerSupply import org.meshtastic.core.ui.theme.AppTheme 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") @@ -61,7 +59,7 @@ fun MaterialBatteryInfo( voltage: Float? = null, contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { - val levelString = formatString(FORMAT, level) + val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown) Row( modifier = modifier, @@ -78,7 +76,7 @@ fun MaterialBatteryInfo( } else if (level > 100) { Icon( modifier = Modifier.size(SIZE_ICON.dp).rotate(90f), - imageVector = Icons.Rounded.Power, + imageVector = MeshtasticIcons.PowerSupply, tint = contentColor.copy(alpha = 0.65f), contentDescription = levelString, ) @@ -131,7 +129,7 @@ fun MaterialBatteryInfo( ?.takeIf { it > 0 } ?.let { Text( - text = formatString("%.2fV", it), + text = MetricFormatter.voltage(it), color = contentColor.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBluetoothSignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBluetoothSignalInfo.kt index cfc368275..a0663ad86 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBluetoothSignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBluetoothSignalInfo.kt @@ -19,9 +19,6 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.SignalCellularOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -41,12 +38,14 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.dbm_value +import org.meshtastic.core.ui.icon.Bluetooth import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.SignalCellular0Bar import org.meshtastic.core.ui.icon.SignalCellular1Bar import org.meshtastic.core.ui.icon.SignalCellular2Bar import org.meshtastic.core.ui.icon.SignalCellular3Bar import org.meshtastic.core.ui.icon.SignalCellular4Bar +import org.meshtastic.core.ui.icon.SignalOff import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange @@ -84,7 +83,7 @@ fun MaterialSignalInfo( 2 -> MeshtasticIcons.SignalCellular2Bar to MaterialTheme.colorScheme.StatusOrange 3 -> MeshtasticIcons.SignalCellular3Bar to MaterialTheme.colorScheme.StatusYellow 4 -> MeshtasticIcons.SignalCellular4Bar to MaterialTheme.colorScheme.StatusGreen - else -> Icons.Rounded.SignalCellularOff to MaterialTheme.colorScheme.onSurfaceVariant + else -> MeshtasticIcons.SignalOff to MaterialTheme.colorScheme.onSurfaceVariant } val foregroundPainter = typeIcon?.let { rememberVectorPainter(typeIcon) } @@ -117,7 +116,7 @@ fun MaterialBluetoothSignalInfo(rssi: Int, modifier: Modifier = Modifier) { modifier = modifier, signalBars = getBluetoothSignalBars(rssi = rssi), signalStrengthValue = stringResource(Res.string.dbm_value, rssi), - typeIcon = Icons.Rounded.Bluetooth, + typeIcon = MeshtasticIcons.Bluetooth, ) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt index 724e7e0dd..757127d50 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.ui.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.OfflineShare -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButtonMenu import androidx.compose.material3.FloatingActionButtonMenuItem @@ -31,6 +28,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.OfflineShare @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -50,7 +50,7 @@ fun MenuFAB( checked = expanded, onCheckedChange = onExpandedChange, content = { - val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare + val imageVector = if (expanded) MeshtasticIcons.Close else MeshtasticIcons.OfflineShare Icon(imageVector = imageVector, contentDescription = contentDescription) }, containerColor = ToggleFloatingActionButtonDefaults.containerColor(), 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 6ade7e3b2..153f5a058 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 @@ -20,7 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import org.meshtastic.core.navigation.MultiBackstack -import org.meshtastic.core.navigation.NodeDetailRoutes +import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.ui.viewmodel.UIViewModel /** @@ -43,8 +44,11 @@ fun MeshtasticAppShell( MeshtasticCommonAppSetup( uiViewModel = uiViewModel, onNavigateToTracerouteMap = { destNum, requestId, logUuid -> - multiBackstack.activeBackStack.add( - NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), + multiBackstack.handleDeepLink( + listOf( + NodesRoute.NodesGraph, + NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), + ), ) }, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index 39f8fc6b1..9f1f36637 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -47,11 +47,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.navigation.ContactsRoutes +import org.meshtastic.core.navigation.ContactsRoute import org.meshtastic.core.navigation.MultiBackstack -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected @@ -140,7 +141,7 @@ private fun handleNavigation( val currentKey = multiBackstack.activeBackStack.lastOrNull() when (destination) { TopLevelDestination.Nodes -> { - val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes + val onNodesList = currentKey is NodesRoute.NodesGraph || currentKey is NodesRoute.Nodes if (!onNodesList) { multiBackstack.navigateTopLevel(destination.route) } else { @@ -149,7 +150,7 @@ private fun handleNavigation( } TopLevelDestination.Conversations -> { val onConversationsList = - currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts + currentKey is ContactsRoute.ContactsGraph || currentKey is ContactsRoute.Contacts if (!onConversationsList) { multiBackstack.navigateTopLevel(destination.route) } else { @@ -225,7 +226,7 @@ private fun NavigationIconContent( ) { Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState -> Icon( - imageVector = destination.icon, + imageVector = vectorResource(destination.icon), contentDescription = stringResource(destination.label), tint = if (isSelectedState) colorScheme.primary else LocalContentColor.current, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt index ad1110867..9ba911bb0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt @@ -45,14 +45,15 @@ 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.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import okio.ByteString +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.model.Channel import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.config_security_public_key @@ -63,6 +64,9 @@ import org.meshtastic.core.resources.encryption_pkc_text import org.meshtastic.core.resources.encryption_psk import org.meshtastic.core.resources.encryption_psk_text import org.meshtastic.core.resources.error +import org.meshtastic.core.resources.ic_key_off +import org.meshtastic.core.resources.ic_lock +import org.meshtastic.core.resources.ic_lock_open import org.meshtastic.core.resources.security_icon_help_dismiss import org.meshtastic.core.resources.security_icon_help_show_all import org.meshtastic.core.resources.security_icon_help_show_less @@ -136,7 +140,7 @@ fun NodeKeyStatusIcon( */ @Immutable enum class NodeKeySecurityState( - @Stable val icon: ImageVector, + @Stable val icon: DrawableResource, @Stable val color: @Composable () -> Color, val descriptionResId: StringResource, val helpTextResId: StringResource, @@ -144,7 +148,7 @@ enum class NodeKeySecurityState( ) { // State for public key mismatch PKM( - icon = MeshtasticIcons.KeyOff, + icon = Res.drawable.ic_key_off, color = { colorScheme.StatusRed }, descriptionResId = Res.string.encryption_error, helpTextResId = Res.string.encryption_error_text, @@ -153,7 +157,7 @@ enum class NodeKeySecurityState( // State for public key encryption PKC( - icon = MeshtasticIcons.Lock, + icon = Res.drawable.ic_lock, color = { colorScheme.StatusGreen }, title = Res.string.encryption_pkc, helpTextResId = Res.string.encryption_pkc_text, @@ -162,7 +166,7 @@ enum class NodeKeySecurityState( // State for shared key encryption PSK( - icon = MeshtasticIcons.LockOpen, + icon = Res.drawable.ic_lock_open, color = { colorScheme.StatusYellow }, title = Res.string.encryption_psk, helpTextResId = Res.string.encryption_psk_text, @@ -252,14 +256,13 @@ private fun AllKeyStates() { modifier = Modifier.verticalScroll(rememberScrollState()), ) { NodeKeySecurityState.entries.forEach { state -> - // Uses enum entries Row(verticalAlignment = Alignment.CenterVertically) { - when (state) { - NodeKeySecurityState.PKM -> NodeKeyStatusIcon(hasPKC = false, mismatchKey = true) - - NodeKeySecurityState.PKC -> NodeKeyStatusIcon(hasPKC = true, mismatchKey = false) - - else -> NodeKeyStatusIcon(hasPKC = false, mismatchKey = false) + IconButton(onClick = {}, modifier = Modifier) { + Icon( + imageVector = vectorResource(state.icon), + contentDescription = stringResource(state.descriptionResId), + tint = state.color(), + ) } Column(modifier = Modifier.padding(start = 16.dp)) { 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 8a2caf5e3..6bf0065bf 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 @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("detekt:ALL") - package org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Arrangement @@ -25,33 +23,12 @@ 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 import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource - -@Deprecated(message = "Use overload that accepts Strings for button text.") -@Composable -fun PreferenceFooter( - enabled: Boolean, - negativeText: StringResource, - onNegativeClicked: () -> Unit, - positiveText: StringResource, - onPositiveClicked: () -> Unit, - modifier: Modifier = Modifier, -) { - PreferenceFooter( - modifier = modifier, - enabled = enabled, - negativeText = stringResource(negativeText), - onNegativeClicked = onNegativeClicked, - positiveText = stringResource(positiveText), - onPositiveClicked = onPositiveClicked, - ) -} @Composable fun PreferenceFooter( @@ -67,22 +44,28 @@ fun PreferenceFooter( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight if (negativeText != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = ButtonDefaults.filledTonalButtonColors(), onClick = onNegativeClicked, ) { - Text(text = negativeText) + Text(text = negativeText, style = ButtonDefaults.textStyleFor(mediumHeight)) } } if (positiveText != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = ButtonDefaults.buttonColors(), onClick = { if (enabled) onPositiveClicked() }, ) { - Text(text = positiveText) + Text(text = positiveText, style = ButtonDefaults.textStyleFor(mediumHeight)) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt index d72c4cde0..1dd55b78e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt @@ -24,8 +24,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.twotone.ContentCopy import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,6 +44,8 @@ import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.okay import org.meshtastic.core.resources.qr_code import org.meshtastic.core.resources.url +import org.meshtastic.core.ui.icon.Copy +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.SetScreenBrightness import org.meshtastic.core.ui.util.createClipEntry @@ -91,10 +91,7 @@ fun QrDialog(title: String, uriString: String, qrPainter: Painter?, onDismiss: ( coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(uriString)) } }, ) { - Icon( - imageVector = Icons.TwoTone.ContentCopy, - contentDescription = stringResource(Res.string.copy), - ) + Icon(imageVector = MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt index afa82460d..f9f839ea5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -80,7 +81,13 @@ fun RegularPreference( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) } - Column(modifier = modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(all = 16.dp)) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick, role = Role.Button) + .padding(all = 16.dp), + ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt index c5bab9c56..d16beab70 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt @@ -53,11 +53,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +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.model.Channel import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_lock +import org.meshtastic.core.resources.ic_lock_open +import org.meshtastic.core.resources.ic_warning import org.meshtastic.core.resources.security_icon_badge_warning_description import org.meshtastic.core.resources.security_icon_description import org.meshtastic.core.resources.security_icon_help_dismiss @@ -73,10 +78,6 @@ import org.meshtastic.core.resources.security_icon_insecure_no_precise import org.meshtastic.core.resources.security_icon_insecure_precise_only import org.meshtastic.core.resources.security_icon_secure import org.meshtastic.core.resources.security_icon_warning_precise_mqtt -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.LockOpen -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Warning import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @@ -99,16 +100,16 @@ private const val PRECISE_POSITION_BITS = 32 */ @Immutable enum class SecurityState( - @Stable val icon: ImageVector, + @Stable val icon: DrawableResource, @Stable val color: @Composable () -> Color, val descriptionResId: StringResource, val helpTextResId: StringResource, - @Stable val badgeIcon: ImageVector? = null, + @Stable val badgeIcon: DrawableResource? = null, @Stable val badgeIconColor: @Composable () -> Color? = { null }, ) { /** State for a secure channel (green lock). */ SECURE( - icon = MeshtasticIcons.Lock, + icon = Res.drawable.ic_lock, color = { colorScheme.StatusGreen }, descriptionResId = Res.string.security_icon_secure, helpTextResId = Res.string.security_icon_help_green_lock, @@ -119,7 +120,7 @@ enum class SecurityState( * warning. (yellow open lock) */ INSECURE_NO_PRECISE( - icon = MeshtasticIcons.LockOpen, + icon = Res.drawable.ic_lock_open, color = { colorScheme.StatusYellow }, descriptionResId = Res.string.security_icon_insecure_no_precise, helpTextResId = Res.string.security_icon_help_yellow_open_lock, @@ -130,7 +131,7 @@ enum class SecurityState( * lock) */ INSECURE_PRECISE_ONLY( - icon = MeshtasticIcons.LockOpen, + icon = Res.drawable.ic_lock_open, color = { colorScheme.StatusRed }, descriptionResId = Res.string.security_icon_insecure_precise_only, helpTextResId = Res.string.security_icon_help_red_open_lock, @@ -141,11 +142,11 @@ enum class SecurityState( * badge). */ INSECURE_PRECISE_MQTT_WARNING( - icon = MeshtasticIcons.LockOpen, + icon = Res.drawable.ic_lock_open, color = { colorScheme.StatusRed }, descriptionResId = Res.string.security_icon_warning_precise_mqtt, helpTextResId = Res.string.security_icon_help_warning_precise_mqtt, - badgeIcon = MeshtasticIcons.Warning, + badgeIcon = Res.drawable.ic_warning, badgeIconColor = { colorScheme.StatusYellow }, ), } @@ -238,11 +239,11 @@ fun SecurityIcon( }, ) { SecurityIconDisplay( - icon = securityState.icon, - mainIconTint = securityState.color.invoke(), + icon = vectorResource(securityState.icon), + mainIconTint = securityState.color(), contentDescription = fullContentDescription, - badgeIcon = securityState.badgeIcon, - badgeIconColor = securityState.badgeIconColor.invoke(), + badgeIcon = securityState.badgeIcon?.let { vectorResource(it) }, + badgeIconColor = securityState.badgeIconColor(), ) } @@ -453,12 +454,12 @@ private fun SecurityHelpDialog(securityState: SecurityState, onDismiss: () -> Un private fun ContextualSecurityState(securityState: SecurityState) { Column(horizontalAlignment = Alignment.CenterHorizontally) { SecurityIconDisplay( - icon = securityState.icon, - mainIconTint = securityState.color.invoke(), + icon = vectorResource(securityState.icon), + mainIconTint = securityState.color(), contentDescription = stringResource(securityState.descriptionResId), modifier = Modifier.size(48.dp), - badgeIcon = securityState.badgeIcon, - badgeIconColor = securityState.badgeIconColor.invoke(), + badgeIcon = securityState.badgeIcon?.let { vectorResource(it) }, + badgeIconColor = securityState.badgeIconColor(), ) Spacer(Modifier.height(16.dp)) Text(text = stringResource(securityState.helpTextResId), style = MaterialTheme.typography.bodyMedium) @@ -479,12 +480,12 @@ private fun AllSecurityStates() { // Uses enum entries Row(verticalAlignment = Alignment.CenterVertically) { SecurityIconDisplay( - icon = state.icon, - mainIconTint = state.color.invoke(), + icon = vectorResource(state.icon), + mainIconTint = state.color(), contentDescription = stringResource(state.descriptionResId), modifier = Modifier.size(48.dp), - badgeIcon = state.badgeIcon, - badgeIconColor = state.badgeIconColor.invoke(), + badgeIcon = state.badgeIcon?.let { vectorResource(it) }, + badgeIconColor = state.badgeIconColor(), ) Column(modifier = Modifier.padding(start = 16.dp)) { Text(text = stringResource(state.descriptionResId), style = MaterialTheme.typography.titleMedium) 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 0a9c9b7e1..f817ec4e4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -33,7 +33,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality @@ -58,13 +59,16 @@ fun SignalInfo( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - imageVector = quality.imageVector, + imageVector = vectorResource(quality.icon), contentDescription = stringResource(Res.string.signal_quality), modifier = Modifier.size(16.dp), tint = signalColor, ) Text( - text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)), + text = + "${MetricFormatter.snr( + node.snr, + )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt index 26877ab5f..b60cec418 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt @@ -55,15 +55,15 @@ import org.meshtastic.core.resources.temperature import org.meshtastic.core.resources.uptime import org.meshtastic.core.ui.icon.AirQuality import org.meshtastic.core.ui.icon.ArrowCircleUp +import org.meshtastic.core.ui.icon.ElectricPower import org.meshtastic.core.ui.icon.HardwareModel import org.meshtastic.core.ui.icon.Humidity import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NodeId -import org.meshtastic.core.ui.icon.Paxcount -import org.meshtastic.core.ui.icon.Power +import org.meshtastic.core.ui.icon.PeopleCount import org.meshtastic.core.ui.icon.Pressure import org.meshtastic.core.ui.icon.Role -import org.meshtastic.core.ui.icon.Soil +import org.meshtastic.core.ui.icon.SoilMoisture import org.meshtastic.core.ui.icon.Temperature import org.meshtastic.core.ui.icon.role import org.meshtastic.proto.Config @@ -126,7 +126,7 @@ fun SoilTemperatureInfo( ) { OverlayIconInfo( modifier = modifier, - icon = MeshtasticIcons.Soil, + icon = MeshtasticIcons.SoilMoisture, overlayIcon = MeshtasticIcons.Temperature, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.soil_temperature), @@ -143,7 +143,7 @@ fun SoilMoistureInfo( ) { OverlayIconInfo( modifier = modifier, - icon = MeshtasticIcons.Soil, + icon = MeshtasticIcons.SoilMoisture, overlayIcon = MeshtasticIcons.Humidity, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.soil_moisture), @@ -160,7 +160,7 @@ fun PaxcountInfo( ) { IconInfo( modifier = modifier, - icon = MeshtasticIcons.Paxcount, + icon = MeshtasticIcons.PeopleCount, contentDescription = stringResource(Res.string.pax_metrics_log), label = stringResource(Res.string.pax), text = pax, @@ -193,7 +193,7 @@ fun PowerInfo( ) { IconInfo( modifier = modifier, - icon = MeshtasticIcons.Power, + icon = MeshtasticIcons.ElectricPower, contentDescription = stringResource(Res.string.env_metrics_log), label = label, text = value, 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 100c6fecb..a0b87ca6a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -26,9 +26,12 @@ 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 @@ -52,6 +55,7 @@ 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 @@ -83,8 +87,17 @@ 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/component/TransportIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TransportIcon.kt index 538eaf996..92d3df65c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TransportIcon.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TransportIcon.kt @@ -26,9 +26,9 @@ import org.meshtastic.core.resources.via_api import org.meshtastic.core.resources.via_mqtt import org.meshtastic.core.resources.via_udp import org.meshtastic.core.ui.icon.Api -import org.meshtastic.core.ui.icon.Cloud import org.meshtastic.core.ui.icon.Device import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MqttConnected import org.meshtastic.core.ui.icon.Udp import org.meshtastic.proto.MeshPacket @@ -37,7 +37,7 @@ fun TransportIcon(transport: Int, viaMqtt: Boolean, modifier: Modifier = Modifie val (icon, description) = when { viaMqtt || transport == MeshPacket.TransportMechanism.TRANSPORT_MQTT.value -> - MeshtasticIcons.Cloud to stringResource(Res.string.via_mqtt) + MeshtasticIcons.MqttConnected to stringResource(Res.string.via_mqtt) transport == MeshPacket.TransportMechanism.TRANSPORT_MULTICAST_UDP.value -> MeshtasticIcons.Udp to stringResource(Res.string.via_udp) transport == MeshPacket.TransportMechanism.TRANSPORT_API.value -> 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 9a67babc0..4a710b0b3 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt @@ -43,9 +43,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -62,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -80,6 +78,9 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.search_emoji import org.meshtastic.core.ui.component.BottomSheetDialog +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Search // ── Constants ────────────────────────────────────────────────────────────────── @@ -117,8 +118,8 @@ fun EmojiPickerDialog( onConfirm: (String) -> Unit, ) { val viewModel: EmojiPickerViewModel = koinViewModel() - var searchQuery by remember { mutableStateOf("") } - var selectedCategoryIndex by remember { mutableStateOf(0) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) } val recentEmojis by remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } } @@ -218,13 +219,13 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit) { ) }, leadingIcon = { - Icon(imageVector = Icons.Rounded.Search, contentDescription = null, modifier = Modifier.size(20.dp)) + Icon(imageVector = MeshtasticIcons.Search, contentDescription = null, modifier = Modifier.size(20.dp)) }, trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon( - imageVector = Icons.Rounded.Close, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear), modifier = Modifier.size(20.dp), ) @@ -427,7 +428,7 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) { - var showSkinTonePopup by remember { mutableStateOf(false) } + var showSkinTonePopup by rememberSaveable { mutableStateOf(false) } Box { Box( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt index 3506605e3..4c07348dd 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt @@ -16,74 +16,123 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AddReaction -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.CloudDownload -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material.icons.rounded.MarkChatRead -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.QrCode2 -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Save -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.SelectAll -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.SystemUpdate -import androidx.compose.material.icons.rounded.ThumbUp +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_add +import org.meshtastic.core.resources.ic_add_reaction +import org.meshtastic.core.resources.ic_bar_chart +import org.meshtastic.core.resources.ic_check +import org.meshtastic.core.resources.ic_close +import org.meshtastic.core.resources.ic_content_copy +import org.meshtastic.core.resources.ic_delete_fill1 +import org.meshtastic.core.resources.ic_download +import org.meshtastic.core.resources.ic_drag_handle +import org.meshtastic.core.resources.ic_edit +import org.meshtastic.core.resources.ic_file_download +import org.meshtastic.core.resources.ic_filter_alt +import org.meshtastic.core.resources.ic_filter_alt_off +import org.meshtastic.core.resources.ic_folder +import org.meshtastic.core.resources.ic_folder_open +import org.meshtastic.core.resources.ic_list +import org.meshtastic.core.resources.ic_mark_chat_read +import org.meshtastic.core.resources.ic_more_vert +import org.meshtastic.core.resources.ic_offline_share +import org.meshtastic.core.resources.ic_output +import org.meshtastic.core.resources.ic_play_arrow +import org.meshtastic.core.resources.ic_power_settings_new +import org.meshtastic.core.resources.ic_qr_code +import org.meshtastic.core.resources.ic_qr_code_2 +import org.meshtastic.core.resources.ic_qr_code_scanner +import org.meshtastic.core.resources.ic_refresh +import org.meshtastic.core.resources.ic_reply +import org.meshtastic.core.resources.ic_restart_alt +import org.meshtastic.core.resources.ic_restore +import org.meshtastic.core.resources.ic_save +import org.meshtastic.core.resources.ic_search +import org.meshtastic.core.resources.ic_select_all +import org.meshtastic.core.resources.ic_send +import org.meshtastic.core.resources.ic_share +import org.meshtastic.core.resources.ic_sort +import org.meshtastic.core.resources.ic_system_update +import org.meshtastic.core.resources.ic_thumb_up +import org.meshtastic.core.resources.ic_upload val MeshtasticIcons.Add: ImageVector - get() = Icons.Rounded.Add + @Composable get() = vectorResource(Res.drawable.ic_add) val MeshtasticIcons.AddReaction: ImageVector - get() = Icons.Rounded.AddReaction -val MeshtasticIcons.Clear: ImageVector - get() = Icons.Rounded.Clear + @Composable get() = vectorResource(Res.drawable.ic_add_reaction) val MeshtasticIcons.Close: ImageVector - get() = Icons.Rounded.Close + @Composable get() = vectorResource(Res.drawable.ic_close) val MeshtasticIcons.Copy: ImageVector - get() = Icons.Rounded.ContentCopy + @Composable get() = vectorResource(Res.drawable.ic_content_copy) val MeshtasticIcons.Delete: ImageVector - get() = Icons.Rounded.Delete + @Composable get() = vectorResource(Res.drawable.ic_delete_fill1) val MeshtasticIcons.Edit: ImageVector - get() = Icons.Rounded.Edit + @Composable get() = vectorResource(Res.drawable.ic_edit) val MeshtasticIcons.More: ImageVector - get() = Icons.Rounded.MoreVert + @Composable get() = vectorResource(Res.drawable.ic_more_vert) val MeshtasticIcons.Refresh: ImageVector - get() = Icons.Rounded.Refresh + @Composable get() = vectorResource(Res.drawable.ic_refresh) val MeshtasticIcons.Reply: ImageVector - get() = Icons.AutoMirrored.Filled.Reply + @Composable get() = vectorResource(Res.drawable.ic_reply) val MeshtasticIcons.Save: ImageVector - get() = Icons.Rounded.Save + @Composable get() = vectorResource(Res.drawable.ic_save) val MeshtasticIcons.Search: ImageVector - get() = Icons.Rounded.Search + @Composable get() = vectorResource(Res.drawable.ic_search) val MeshtasticIcons.Send: ImageVector - get() = Icons.AutoMirrored.Filled.Send + @Composable get() = vectorResource(Res.drawable.ic_send) val MeshtasticIcons.Share: ImageVector - get() = Icons.Rounded.Share + @Composable get() = vectorResource(Res.drawable.ic_share) val MeshtasticIcons.Sort: ImageVector - get() = Icons.AutoMirrored.Filled.Sort -val MeshtasticIcons.CloudDownload: ImageVector - get() = Icons.Rounded.CloudDownload + @Composable get() = vectorResource(Res.drawable.ic_sort) val MeshtasticIcons.Folder: ImageVector - get() = Icons.Rounded.Folder + @Composable get() = vectorResource(Res.drawable.ic_folder) val MeshtasticIcons.SystemUpdate: ImageVector - get() = Icons.Rounded.SystemUpdate + @Composable get() = vectorResource(Res.drawable.ic_system_update) val MeshtasticIcons.SelectAll: ImageVector - get() = Icons.Rounded.SelectAll + @Composable get() = vectorResource(Res.drawable.ic_select_all) val MeshtasticIcons.ThumbUp: ImageVector - get() = Icons.Rounded.ThumbUp - + @Composable get() = vectorResource(Res.drawable.ic_thumb_up) val MeshtasticIcons.MarkChatRead: ImageVector - get() = Icons.Rounded.MarkChatRead - + @Composable get() = vectorResource(Res.drawable.ic_mark_chat_read) val MeshtasticIcons.QrCode2: ImageVector - get() = Icons.Rounded.QrCode2 + @Composable get() = vectorResource(Res.drawable.ic_qr_code_2) + +val MeshtasticIcons.Download: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_download) +val MeshtasticIcons.Upload: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_upload) +val MeshtasticIcons.DragHandle: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_drag_handle) +val MeshtasticIcons.Check: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_check) +val MeshtasticIcons.QrCode: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_qr_code) +val MeshtasticIcons.FolderOpen: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_folder_open) +val MeshtasticIcons.Output: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_output) +val MeshtasticIcons.FileDownload: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_file_download) +val MeshtasticIcons.PlayArrow: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_play_arrow) +val MeshtasticIcons.FilterAlt: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_filter_alt) +val MeshtasticIcons.FilterAltOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_filter_alt_off) +val MeshtasticIcons.OfflineShare: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_offline_share) +val MeshtasticIcons.QrCodeScanner: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_qr_code_scanner) +val MeshtasticIcons.RestartAlt: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_restart_alt) +val MeshtasticIcons.PowerSettingsNew: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_power_settings_new) +val MeshtasticIcons.FactoryReset: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_restore) +val MeshtasticIcons.BarChart: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bar_chart) +val MeshtasticIcons.List: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_list) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Battery.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Battery.kt index 0ecd42227..6c458be40 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Battery.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Battery.kt @@ -16,168 +16,19 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_battery_alert +import org.meshtastic.core.resources.ic_battery_horiz_000 +import org.meshtastic.core.resources.ic_battery_question_mark -/** - * This is from Material Symbols. - * - * @see - * [battery_android_0](https://fonts.google.com/icons?icon.query=battery+android+0&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded) - */ val MeshtasticIcons.BatteryEmpty: ImageVector - get() { - if (batteryEmpty != null) { - return batteryEmpty!! - } - batteryEmpty = - ImageVector.Builder( - name = "BatteryEmpty", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveTo(160f, 720f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - verticalLineToRelative(-240f) - quadToRelative(0f, -50f, 35f, -85f) - reflectiveQuadToRelative(85f, -35f) - horizontalLineToRelative(540f) - quadToRelative(50f, 0f, 85f, 35f) - reflectiveQuadToRelative(35f, 85f) - verticalLineToRelative(240f) - quadToRelative(0f, 50f, -35f, 85f) - reflectiveQuadToRelative(-85f, 35f) - lineTo(160f, 720f) - close() - moveTo(160f, 640f) - horizontalLineToRelative(540f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(740f, 600f) - verticalLineToRelative(-240f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(700f, 320f) - lineTo(160f, 320f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(120f, 360f) - verticalLineToRelative(240f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(160f, 640f) - close() - moveTo(860f, 580f) - verticalLineToRelative(-200f) - horizontalLineToRelative(20f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(920f, 420f) - verticalLineToRelative(120f) - quadToRelative(0f, 17f, -11.5f, 28.5f) - reflectiveQuadTo(880f, 580f) - horizontalLineToRelative(-20f) - close() - moveTo(120f, 640f) - verticalLineToRelative(-320f) - verticalLineToRelative(320f) - close() - } - } - .build() + @Composable get() = vectorResource(Res.drawable.ic_battery_horiz_000) - return batteryEmpty!! - } - -private var batteryEmpty: ImageVector? = null - -/** - * This is from Material Symbols. - * - * @see - * [battery_android_question](https://fonts.google.com/icons?icon.query=battery+android+question&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded) - */ val MeshtasticIcons.BatteryUnknown: ImageVector - get() { - if (batteryUnknown != null) { - return batteryUnknown!! - } - batteryUnknown = - ImageVector.Builder( - name = "BatteryUnknown", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveTo(120f, 640f) - verticalLineToRelative(-320f) - verticalLineToRelative(320f) - close() - moveTo(726f, 720f) - lineTo(160f, 720f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - verticalLineToRelative(-240f) - quadToRelative(0f, -50f, 35f, -85f) - reflectiveQuadToRelative(85f, -35f) - horizontalLineToRelative(521f) - quadToRelative(-20f, 16f, -35f, 36f) - reflectiveQuadToRelative(-25f, 44f) - lineTo(160f, 320f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(120f, 360f) - verticalLineToRelative(240f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(160f, 640f) - horizontalLineToRelative(520f) - quadToRelative(2f, 25f, 14.5f, 45.5f) - reflectiveQuadTo(726f, 720f) - close() - moveTo(800f, 660f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(840f, 620f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(800f, 580f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(760f, 620f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(800f, 660f) - close() - moveTo(772f, 538f) - horizontalLineToRelative(57f) - verticalLineToRelative(-21f) - quadToRelative(0f, -10f, 5f, -19f) - quadToRelative(6f, -13f, 15.5f, -22f) - reflectiveQuadToRelative(19.5f, -19f) - quadToRelative(17f, -17f, 28.5f, -37f) - reflectiveQuadToRelative(11.5f, -43f) - quadToRelative(0f, -42f, -32.5f, -69.5f) - reflectiveQuadTo(800f, 280f) - quadToRelative(-38f, 0f, -68f, 22f) - reflectiveQuadToRelative(-40f, 58f) - lineToRelative(51f, 21f) - quadToRelative(6f, -20f, 21.5f, -33f) - reflectiveQuadToRelative(35.5f, -13f) - quadToRelative(21f, 0f, 36.5f, 12f) - reflectiveQuadToRelative(15.5f, 32f) - quadToRelative(0f, 17f, -10f, 30.5f) - reflectiveQuadTo(820f, 434f) - quadToRelative(-11f, 11f, -22.5f, 21.5f) - reflectiveQuadTo(779f, 480f) - quadToRelative(-6f, 14f, -6.5f, 28.5f) - reflectiveQuadTo(772f, 538f) - close() - } - } - .build() + @Composable get() = vectorResource(Res.drawable.ic_battery_question_mark) - return batteryUnknown!! - } - -private var batteryUnknown: ImageVector? = null +val MeshtasticIcons.BatteryAlert: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_battery_alert) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Counter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Counter.kt index 4bf0b6a97..cdad51fd1 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Counter.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Counter.kt @@ -18,6 +18,8 @@ package org.meshtastic.core.ui.icon import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ic_counter_0 import org.meshtastic.core.resources.ic_counter_1 import org.meshtastic.core.resources.ic_counter_2 @@ -28,39 +30,21 @@ import org.meshtastic.core.resources.ic_counter_6 import org.meshtastic.core.resources.ic_counter_7 import org.meshtastic.core.resources.ic_counter_8 -/** These are from Material Symbols drawables. */ val MeshtasticIcons.Counter0: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_0) - + @Composable get() = vectorResource(Res.drawable.ic_counter_0) val MeshtasticIcons.Counter1: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_1) - + @Composable get() = vectorResource(Res.drawable.ic_counter_1) val MeshtasticIcons.Counter2: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_2) - + @Composable get() = vectorResource(Res.drawable.ic_counter_2) val MeshtasticIcons.Counter3: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_3) - + @Composable get() = vectorResource(Res.drawable.ic_counter_3) val MeshtasticIcons.Counter4: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_4) - + @Composable get() = vectorResource(Res.drawable.ic_counter_4) val MeshtasticIcons.Counter5: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_5) - + @Composable get() = vectorResource(Res.drawable.ic_counter_5) val MeshtasticIcons.Counter6: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_6) - + @Composable get() = vectorResource(Res.drawable.ic_counter_6) val MeshtasticIcons.Counter7: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_7) - + @Composable get() = vectorResource(Res.drawable.ic_counter_7) val MeshtasticIcons.Counter8: ImageVector - @Composable - get() = org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_counter_8) + @Composable get() = vectorResource(Res.drawable.ic_counter_8) 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 1c44b9a13..6bf669ab6 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt @@ -16,176 +16,64 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.MilitaryTech -import androidx.compose.material.icons.rounded.MyLocation -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PersonOff -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Sensors -import androidx.compose.material.icons.rounded.VisibilityOff -import androidx.compose.material.icons.rounded.Work import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_android +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 +import org.meshtastic.core.resources.ic_numbers +import org.meshtastic.core.resources.ic_person +import org.meshtastic.core.resources.ic_person_off +import org.meshtastic.core.resources.ic_phone_android +import org.meshtastic.core.resources.ic_router +import org.meshtastic.core.resources.ic_search +import org.meshtastic.core.resources.ic_sensors +import org.meshtastic.core.resources.ic_visibility_off +import org.meshtastic.core.resources.ic_work import org.meshtastic.proto.Config -val MeshtasticIcons.HardwareModel: ImageVector - get() = Icons.Rounded.Router val MeshtasticIcons.Role: ImageVector - get() = Icons.Rounded.Work + @Composable get() = vectorResource(Res.drawable.ic_work) val MeshtasticIcons.NodeId: ImageVector - get() = Icons.Rounded.Fingerprint + @Composable get() = vectorResource(Res.drawable.ic_fingerprint) /** Returns a specific icon for a given [Config.DeviceConfig.Role]. */ @Composable fun MeshtasticIcons.role(role: Config.DeviceConfig.Role?): ImageVector = when (role) { - Config.DeviceConfig.Role.CLIENT -> Icons.Rounded.Person - Config.DeviceConfig.Role.CLIENT_MUTE -> Icons.Rounded.PersonOff + Config.DeviceConfig.Role.CLIENT -> vectorResource(Res.drawable.ic_person) + Config.DeviceConfig.Role.CLIENT_MUTE -> vectorResource(Res.drawable.ic_person_off) Config.DeviceConfig.Role.ROUTER -> vectorResource(Res.drawable.ic_mountain_flag) - Config.DeviceConfig.Role.TRACKER -> Icons.Rounded.MyLocation - Config.DeviceConfig.Role.SENSOR -> Icons.Rounded.Sensors - Config.DeviceConfig.Role.TAK -> Icons.Rounded.MilitaryTech - Config.DeviceConfig.Role.TAK_TRACKER -> Icons.Rounded.MyLocation - Config.DeviceConfig.Role.CLIENT_HIDDEN -> Icons.Rounded.VisibilityOff - Config.DeviceConfig.Role.LOST_AND_FOUND -> Icons.Rounded.Search - Config.DeviceConfig.Role.CLIENT_BASE -> Icons.Rounded.Home - Config.DeviceConfig.Role.ROUTER_LATE -> Icons.Rounded.Router - else -> Icons.Rounded.Work + Config.DeviceConfig.Role.TRACKER -> vectorResource(Res.drawable.ic_my_location) + Config.DeviceConfig.Role.SENSOR -> vectorResource(Res.drawable.ic_sensors) + Config.DeviceConfig.Role.TAK -> vectorResource(Res.drawable.ic_military_tech) + Config.DeviceConfig.Role.TAK_TRACKER -> vectorResource(Res.drawable.ic_my_location) + Config.DeviceConfig.Role.CLIENT_HIDDEN -> vectorResource(Res.drawable.ic_visibility_off) + Config.DeviceConfig.Role.LOST_AND_FOUND -> vectorResource(Res.drawable.ic_search) + Config.DeviceConfig.Role.CLIENT_BASE -> vectorResource(Res.drawable.ic_home) + Config.DeviceConfig.Role.ROUTER_LATE -> vectorResource(Res.drawable.ic_router) + else -> vectorResource(Res.drawable.ic_work) } -/** - * This is from Material Symbols. - * - * @see - * [router](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:router:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=router&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded) - */ val MeshtasticIcons.Device: ImageVector - get() { - if (device != null) { - return device!! - } - device = - ImageVector.Builder( - name = "Outlined.Device", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(200f, 840f) - quadToRelative(-33f, 0f, -56.5f, -23.5f) - reflectiveQuadTo(120f, 760f) - verticalLineToRelative(-160f) - quadToRelative(0f, -33f, 23.5f, -56.5f) - reflectiveQuadTo(200f, 520f) - horizontalLineToRelative(400f) - verticalLineToRelative(-120f) - quadToRelative(0f, -17f, 11.5f, -28.5f) - reflectiveQuadTo(640f, 360f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(680f, 400f) - verticalLineToRelative(120f) - horizontalLineToRelative(80f) - quadToRelative(33f, 0f, 56.5f, 23.5f) - reflectiveQuadTo(840f, 600f) - verticalLineToRelative(160f) - quadToRelative(0f, 33f, -23.5f, 56.5f) - reflectiveQuadTo(760f, 840f) - lineTo(200f, 840f) - close() - moveTo(200f, 760f) - horizontalLineToRelative(560f) - verticalLineToRelative(-160f) - lineTo(200f, 600f) - verticalLineToRelative(160f) - close() - moveTo(280f, 720f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(320f, 680f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(280f, 640f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(240f, 680f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(280f, 720f) - close() - moveTo(420f, 720f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(460f, 680f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(420f, 640f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(380f, 680f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(420f, 720f) - close() - moveTo(560f, 720f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(600f, 680f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(560f, 640f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(520f, 680f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(560f, 720f) - close() - moveTo(640f, 300f) - quadToRelative(-11f, 0f, -20f, 2f) - reflectiveQuadToRelative(-18f, 6f) - quadToRelative(-16f, 7f, -32.5f, 6f) - reflectiveQuadTo(541f, 301f) - quadToRelative(-12f, -12f, -11.5f, -29f) - reflectiveQuadToRelative(14.5f, -25f) - quadToRelative(21f, -13f, 45.5f, -20f) - reflectiveQuadToRelative(50.5f, -7f) - quadToRelative(27f, 0f, 51f, 7f) - reflectiveQuadToRelative(45f, 20f) - quadToRelative(14f, 8f, 14.5f, 25f) - reflectiveQuadTo(739f, 301f) - quadToRelative(-12f, 12f, -29f, 13f) - reflectiveQuadToRelative(-33f, -6f) - quadToRelative(-8f, -4f, -17.5f, -6f) - reflectiveQuadToRelative(-19.5f, -2f) - close() - moveTo(640f, 160f) - quadToRelative(-39f, 0f, -74.5f, 11.5f) - reflectiveQuadTo(500f, 205f) - quadToRelative(-14f, 10f, -30.5f, 9f) - reflectiveQuadTo(442f, 202f) - quadToRelative(-12f, -12f, -12f, -28f) - reflectiveQuadToRelative(13f, -26f) - quadToRelative(41f, -32f, 91f, -50f) - reflectiveQuadToRelative(106f, -18f) - quadToRelative(56f, 0f, 106f, 18f) - reflectiveQuadToRelative(91f, 50f) - quadToRelative(13f, 10f, 13f, 26f) - reflectiveQuadToRelative(-12f, 28f) - quadToRelative(-11f, 11f, -27.5f, 12f) - reflectiveQuadToRelative(-30.5f, -9f) - quadToRelative(-30f, -22f, -65.5f, -33.5f) - reflectiveQuadTo(640f, 160f) - close() - moveTo(200f, 760f) - verticalLineToRelative(-160f) - verticalLineToRelative(160f) - close() - } - } - .build() + @Composable get() = vectorResource(Res.drawable.ic_router) - return device!! - } - -private var device: ImageVector? = null +val MeshtasticIcons.PhoneAndroid: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_phone_android) +val MeshtasticIcons.ForkLeft: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_fork_left) +val MeshtasticIcons.Icecream: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_icecream) +val MeshtasticIcons.DeviceNumbers: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_numbers) +val MeshtasticIcons.Android: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_android) +val MeshtasticIcons.HardwareModel: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_memory) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Elevation.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Elevation.kt index 79287b612..3443e3213 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Elevation.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Elevation.kt @@ -16,84 +16,11 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_elevation -/** - * This is from Material Symbols. - * - * @see - * [elevation](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:elevation:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=elevation&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded) - */ val MeshtasticIcons.Elevation: ImageVector - get() { - if (elevation != null) { - return elevation!! - } - elevation = - ImageVector.Builder( - name = "Rounded.Elevation", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(760f, 840f) - lineTo(160f, 840f) - quadToRelative(-25f, 0f, -35.5f, -21.5f) - reflectiveQuadTo(128f, 777f) - lineToRelative(188f, -264f) - quadToRelative(11f, -16f, 28f, -24.5f) - reflectiveQuadToRelative(37f, -8.5f) - horizontalLineToRelative(161f) - lineToRelative(228f, -266f) - quadToRelative(18f, -21f, 44f, -11.5f) - reflectiveQuadToRelative(26f, 37.5f) - verticalLineToRelative(520f) - quadToRelative(0f, 33f, -23.5f, 56.5f) - reflectiveQuadTo(760f, 840f) - close() - moveTo(300f, 400f) - lineTo(176f, 575f) - quadToRelative(-10f, 14f, -26f, 16.5f) - reflectiveQuadToRelative(-30f, -7.5f) - quadToRelative(-14f, -10f, -16.5f, -26f) - reflectiveQuadToRelative(7.5f, -30f) - lineToRelative(125f, -174f) - quadToRelative(11f, -16f, 28f, -25f) - reflectiveQuadToRelative(37f, -9f) - horizontalLineToRelative(161f) - lineToRelative(162f, -189f) - quadToRelative(11f, -13f, 27f, -14f) - reflectiveQuadToRelative(29f, 10f) - quadToRelative(13f, 11f, 14f, 27f) - reflectiveQuadToRelative(-10f, 29f) - lineTo(522f, 372f) - quadToRelative(-11f, 14f, -27f, 21f) - reflectiveQuadToRelative(-33f, 7f) - lineTo(300f, 400f) - close() - moveTo(238f, 760f) - horizontalLineToRelative(522f) - verticalLineToRelative(-412f) - lineTo(602f, 532f) - quadToRelative(-11f, 14f, -27f, 21f) - reflectiveQuadToRelative(-33f, 7f) - lineTo(380f, 560f) - lineTo(238f, 760f) - close() - moveTo(760f, 760f) - close() - } - } - .build() - - return elevation!! - } - -private var elevation: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_elevation) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt index ad1c1dfb4..0a04d47fe 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt @@ -16,15 +16,47 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Usb -import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_bluetooth +import org.meshtastic.core.resources.ic_bluetooth_connected +import org.meshtastic.core.resources.ic_bluetooth_searching +import org.meshtastic.core.resources.ic_cached +import org.meshtastic.core.resources.ic_display_settings +import org.meshtastic.core.resources.ic_memory +import org.meshtastic.core.resources.ic_nfc +import org.meshtastic.core.resources.ic_settings_input_antenna +import org.meshtastic.core.resources.ic_speaker_phone +import org.meshtastic.core.resources.ic_terminal +import org.meshtastic.core.resources.ic_usb +import org.meshtastic.core.resources.ic_usb_off +import org.meshtastic.core.resources.ic_wifi +val MeshtasticIcons.BluetoothConnected: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bluetooth_connected) +val MeshtasticIcons.BluetoothSearching: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bluetooth_searching) +val MeshtasticIcons.UsbOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_usb_off) +val MeshtasticIcons.Antenna: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_settings_input_antenna) +val MeshtasticIcons.Speaker: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_speaker_phone) +val MeshtasticIcons.Reconnecting: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cached) +val MeshtasticIcons.Nfc: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_nfc) val MeshtasticIcons.Bluetooth: ImageVector - get() = Icons.Rounded.Bluetooth -val MeshtasticIcons.Usb: ImageVector - get() = Icons.Rounded.Usb + @Composable get() = vectorResource(Res.drawable.ic_bluetooth) val MeshtasticIcons.Wifi: ImageVector - get() = Icons.Rounded.Wifi + @Composable get() = vectorResource(Res.drawable.ic_wifi) +val MeshtasticIcons.Usb: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_usb) +val MeshtasticIcons.Serial: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_terminal) +val MeshtasticIcons.Memory: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_memory) +val MeshtasticIcons.DisplaySettings: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_display_settings) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Map.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Map.kt index 1b4c04a99..16f00ac3b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Map.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Map.kt @@ -16,94 +16,48 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_calendar_month +import org.meshtastic.core.resources.ic_layers +import org.meshtastic.core.resources.ic_lens +import org.meshtastic.core.resources.ic_location_disabled +import org.meshtastic.core.resources.ic_location_on +import org.meshtastic.core.resources.ic_map +import org.meshtastic.core.resources.ic_my_location +import org.meshtastic.core.resources.ic_navigation +import org.meshtastic.core.resources.ic_pin_drop +import org.meshtastic.core.resources.ic_place +import org.meshtastic.core.resources.ic_route +import org.meshtastic.core.resources.ic_trip_origin +import org.meshtastic.core.resources.ic_tune -/** - * This is from Material Symbols. - * - * @see - * [map](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:map:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=map&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded) - */ +// Map control icons +val MeshtasticIcons.Layers: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_layers) +val MeshtasticIcons.MyLocation: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_my_location) +val MeshtasticIcons.LocationDisabled: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_location_disabled) +val MeshtasticIcons.PinDrop: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_pin_drop) +val MeshtasticIcons.TripOrigin: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_trip_origin) +val MeshtasticIcons.CalendarMonth: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_calendar_month) +val MeshtasticIcons.MapCompass: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_navigation) +val MeshtasticIcons.Tune: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_tune) +val MeshtasticIcons.Place: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_place) +val MeshtasticIcons.Lens: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_lens) val MeshtasticIcons.Map: ImageVector - get() { - if (map != null) { - return map!! - } - map = - ImageVector.Builder( - name = "Outlined.Map", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveToRelative(574f, 831f) - lineToRelative(-214f, -75f) - lineToRelative(-186f, 72f) - quadToRelative(-10f, 4f, -19.5f, 2.5f) - reflectiveQuadTo(137f, 824f) - quadToRelative(-8f, -5f, -12.5f, -13.5f) - reflectiveQuadTo(120f, 791f) - verticalLineToRelative(-561f) - quadToRelative(0f, -13f, 7.5f, -23f) - reflectiveQuadToRelative(20.5f, -15f) - lineToRelative(186f, -63f) - quadToRelative(6f, -2f, 12.5f, -3f) - reflectiveQuadToRelative(13.5f, -1f) - quadToRelative(7f, 0f, 13.5f, 1f) - reflectiveQuadToRelative(12.5f, 3f) - lineToRelative(214f, 75f) - lineToRelative(186f, -72f) - quadToRelative(10f, -4f, 19.5f, -2.5f) - reflectiveQuadTo(823f, 136f) - quadToRelative(8f, 5f, 12.5f, 13.5f) - reflectiveQuadTo(840f, 169f) - verticalLineToRelative(561f) - quadToRelative(0f, 13f, -7.5f, 23f) - reflectiveQuadTo(812f, 768f) - lineToRelative(-186f, 63f) - quadToRelative(-6f, 2f, -12.5f, 3f) - reflectiveQuadToRelative(-13.5f, 1f) - quadToRelative(-7f, 0f, -13.5f, -1f) - reflectiveQuadToRelative(-12.5f, -3f) - close() - moveTo(560f, 742f) - verticalLineToRelative(-468f) - lineToRelative(-160f, -56f) - verticalLineToRelative(468f) - lineToRelative(160f, 56f) - close() - moveTo(640f, 742f) - lineTo(760f, 702f) - verticalLineToRelative(-474f) - lineToRelative(-120f, 46f) - verticalLineToRelative(468f) - close() - moveTo(200f, 732f) - lineTo(320f, 686f) - verticalLineToRelative(-468f) - lineToRelative(-120f, 40f) - verticalLineToRelative(474f) - close() - moveTo(640f, 274f) - verticalLineToRelative(468f) - verticalLineToRelative(-468f) - close() - moveTo(320f, 218f) - verticalLineToRelative(468f) - verticalLineToRelative(-468f) - close() - } - } - .build() - - return map!! - } - -private var map: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_map) +val MeshtasticIcons.LocationOn: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_location_on) +val MeshtasticIcons.Route: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_route) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Messages.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Messages.kt index 899c65f19..f2f6d26cf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Messages.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Messages.kt @@ -16,85 +16,42 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_add_link +import org.meshtastic.core.resources.ic_chat_bubble_outline +import org.meshtastic.core.resources.ic_fast_forward +import org.meshtastic.core.resources.ic_filter_list +import org.meshtastic.core.resources.ic_filter_list_off +import org.meshtastic.core.resources.ic_format_quote +import org.meshtastic.core.resources.ic_forum +import org.meshtastic.core.resources.ic_link +import org.meshtastic.core.resources.ic_message +import org.meshtastic.core.resources.ic_visibility +import org.meshtastic.core.resources.ic_visibility_off -/** - * This is from Material Symbols. - * - * @see - * [forum](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:forum:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=forum&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded) - */ +// Messaging UI icons +val MeshtasticIcons.ChatBubbleOutline: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_chat_bubble_outline) +val MeshtasticIcons.FormatQuote: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_format_quote) +val MeshtasticIcons.FilterList: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_filter_list) +val MeshtasticIcons.FilterListOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_filter_list_off) +val MeshtasticIcons.FastForward: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_fast_forward) +val MeshtasticIcons.Visibility: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_visibility) +val MeshtasticIcons.VisibilityOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_visibility_off) +val MeshtasticIcons.AddLink: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_add_link) +val MeshtasticIcons.LinkIcon: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_link) +val MeshtasticIcons.Message: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_message) val MeshtasticIcons.Conversations: ImageVector - get() { - if (conversations != null) { - return conversations!! - } - conversations = - ImageVector.Builder( - name = "Outlined.Conversations", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveTo(840f, 824f) - quadToRelative(-8f, 0f, -15f, -3f) - reflectiveQuadToRelative(-13f, -9f) - lineToRelative(-92f, -92f) - lineTo(320f, 720f) - quadToRelative(-33f, 0f, -56.5f, -23.5f) - reflectiveQuadTo(240f, 640f) - verticalLineToRelative(-40f) - horizontalLineToRelative(440f) - quadToRelative(33f, 0f, 56.5f, -23.5f) - reflectiveQuadTo(760f, 520f) - verticalLineToRelative(-280f) - horizontalLineToRelative(40f) - quadToRelative(33f, 0f, 56.5f, 23.5f) - reflectiveQuadTo(880f, 320f) - verticalLineToRelative(463f) - quadToRelative(0f, 18f, -12f, 29.5f) - reflectiveQuadTo(840f, 824f) - close() - moveTo(160f, 487f) - lineToRelative(47f, -47f) - horizontalLineToRelative(393f) - verticalLineToRelative(-280f) - lineTo(160f, 160f) - verticalLineToRelative(327f) - close() - moveTo(120f, 624f) - quadToRelative(-16f, 0f, -28f, -11.5f) - reflectiveQuadTo(80f, 583f) - verticalLineToRelative(-423f) - quadToRelative(0f, -33f, 23.5f, -56.5f) - reflectiveQuadTo(160f, 80f) - horizontalLineToRelative(440f) - quadToRelative(33f, 0f, 56.5f, 23.5f) - reflectiveQuadTo(680f, 160f) - verticalLineToRelative(280f) - quadToRelative(0f, 33f, -23.5f, 56.5f) - reflectiveQuadTo(600f, 520f) - lineTo(240f, 520f) - lineToRelative(-92f, 92f) - quadToRelative(-6f, 6f, -13f, 9f) - reflectiveQuadToRelative(-15f, 3f) - close() - moveTo(160f, 440f) - verticalLineToRelative(-280f) - verticalLineToRelative(280f) - close() - } - } - .build() - - return conversations!! - } - -private var conversations: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_forum) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Navigation.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Navigation.kt new file mode 100644 index 000000000..544b56c09 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Navigation.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.icon + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_arrow_back +import org.meshtastic.core.resources.ic_arrow_downward +import org.meshtastic.core.resources.ic_chevron_right +import org.meshtastic.core.resources.ic_expand_less +import org.meshtastic.core.resources.ic_expand_more +import org.meshtastic.core.resources.ic_keyboard_arrow_down +import org.meshtastic.core.resources.ic_keyboard_arrow_up + +val MeshtasticIcons.ArrowBack: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_arrow_back) +val MeshtasticIcons.ChevronRight: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_chevron_right) +val MeshtasticIcons.KeyboardArrowDown: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_keyboard_arrow_down) +val MeshtasticIcons.KeyboardArrowUp: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_keyboard_arrow_up) +val MeshtasticIcons.ArrowDownward: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_arrow_downward) +val MeshtasticIcons.ExpandMore: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_expand_more) +val MeshtasticIcons.ExpandLess: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_expand_less) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/NoDevice.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/NoDevice.kt index 503fc3289..2c2b1ea51 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/NoDevice.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/NoDevice.kt @@ -16,149 +16,11 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_no_device -/** - * This is from Material Symbols. - * - * @see - * [router_off](https://fonts.google.com/icons?icon.query=router+off&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded) - */ val MeshtasticIcons.NoDevice: ImageVector - get() { - if (noDevice != null) { - return noDevice!! - } - noDevice = - ImageVector.Builder( - name = "Outlined.NoDevice", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveTo(806f, 692f) - lineTo(600f, 486f) - verticalLineToRelative(-86f) - quadToRelative(0f, -17f, 11.5f, -28.5f) - reflectiveQuadTo(640f, 360f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(680f, 400f) - verticalLineToRelative(120f) - horizontalLineToRelative(80f) - quadToRelative(33f, 0f, 56.5f, 23.5f) - reflectiveQuadTo(840f, 600f) - verticalLineToRelative(78f) - quadToRelative(0f, 14f, -12f, 19f) - reflectiveQuadToRelative(-22f, -5f) - close() - moveTo(200f, 760f) - horizontalLineToRelative(446f) - lineTo(486f, 600f) - lineTo(200f, 600f) - verticalLineToRelative(160f) - close() - moveTo(200f, 840f) - quadToRelative(-33f, 0f, -56.5f, -23.5f) - reflectiveQuadTo(120f, 760f) - verticalLineToRelative(-160f) - quadToRelative(0f, -33f, 23.5f, -56.5f) - reflectiveQuadTo(200f, 520f) - horizontalLineToRelative(206f) - lineTo(83f, 197f) - quadToRelative(-12f, -12f, -12f, -28.5f) - reflectiveQuadTo(83f, 140f) - quadToRelative(12f, -12f, 28.5f, -12f) - reflectiveQuadToRelative(28.5f, 12f) - lineToRelative(680f, 680f) - quadToRelative(12f, 12f, 12f, 28f) - reflectiveQuadToRelative(-12f, 28f) - quadToRelative(-12f, 12f, -28.5f, 12f) - reflectiveQuadTo(763f, 876f) - lineToRelative(-37f, -36f) - lineTo(200f, 840f) - close() - moveTo(280f, 720f) - quadToRelative(-17f, 0f, -28.5f, -11.5f) - reflectiveQuadTo(240f, 680f) - quadToRelative(0f, -17f, 11.5f, -28.5f) - reflectiveQuadTo(280f, 640f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(320f, 680f) - quadToRelative(0f, 17f, -11.5f, 28.5f) - reflectiveQuadTo(280f, 720f) - close() - moveTo(420f, 720f) - quadToRelative(-17f, 0f, -28.5f, -11.5f) - reflectiveQuadTo(380f, 680f) - quadToRelative(0f, -17f, 11.5f, -28.5f) - reflectiveQuadTo(420f, 640f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(460f, 680f) - quadToRelative(0f, 17f, -11.5f, 28.5f) - reflectiveQuadTo(420f, 720f) - close() - moveTo(560f, 720f) - quadToRelative(-17f, 0f, -28.5f, -11.5f) - reflectiveQuadTo(520f, 680f) - quadToRelative(0f, -17f, 11.5f, -28.5f) - reflectiveQuadTo(560f, 640f) - quadToRelative(17f, 0f, 28.5f, 11.5f) - reflectiveQuadTo(600f, 680f) - quadToRelative(0f, 17f, -11.5f, 28.5f) - reflectiveQuadTo(560f, 720f) - close() - moveTo(200f, 760f) - verticalLineToRelative(-160f) - verticalLineToRelative(160f) - close() - moveTo(640f, 300f) - quadToRelative(-11f, 0f, -20f, 2f) - reflectiveQuadToRelative(-18f, 6f) - quadToRelative(-16f, 7f, -32.5f, 6f) - reflectiveQuadTo(541f, 301f) - quadToRelative(-12f, -12f, -11.5f, -29f) - reflectiveQuadToRelative(14.5f, -25f) - quadToRelative(21f, -13f, 45.5f, -20f) - reflectiveQuadToRelative(50.5f, -7f) - quadToRelative(27f, 0f, 51f, 7f) - reflectiveQuadToRelative(45f, 20f) - quadToRelative(14f, 8f, 14.5f, 25f) - reflectiveQuadTo(739f, 301f) - quadToRelative(-12f, 12f, -29f, 13f) - reflectiveQuadToRelative(-33f, -6f) - quadToRelative(-8f, -4f, -17.5f, -6f) - reflectiveQuadToRelative(-19.5f, -2f) - close() - moveTo(640f, 160f) - quadToRelative(-39f, 0f, -74.5f, 11.5f) - reflectiveQuadTo(500f, 205f) - quadToRelative(-14f, 10f, -30.5f, 9f) - reflectiveQuadTo(442f, 202f) - quadToRelative(-12f, -12f, -12f, -28f) - reflectiveQuadToRelative(13f, -26f) - quadToRelative(41f, -32f, 91f, -50f) - reflectiveQuadToRelative(106f, -18f) - quadToRelative(56f, 0f, 106f, 18f) - reflectiveQuadToRelative(91f, 50f) - quadToRelative(13f, 10f, 13f, 26f) - reflectiveQuadToRelative(-12f, 28f) - quadToRelative(-11f, 11f, -27.5f, 12f) - reflectiveQuadToRelative(-30.5f, -9f) - quadToRelative(-30f, -22f, -65.5f, -33.5f) - reflectiveQuadTo(640f, 160f) - close() - } - } - .build() - - return noDevice!! - } - -private var noDevice: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_no_device) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Nodes.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Nodes.kt index 9f1fd8caa..fda3bad78 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Nodes.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Nodes.kt @@ -16,150 +16,20 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_delete_fill0 +import org.meshtastic.core.resources.ic_do_not_disturb_on +import org.meshtastic.core.resources.ic_nodes +import org.meshtastic.core.resources.ic_notes -/** - * This is from Material Symbols. - * - * @see - * [graph_3](https://fonts.google.com/icons?icon.query=graph+3&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded) - */ +val MeshtasticIcons.Notes: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_notes) +val MeshtasticIcons.DoDisturb: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_do_not_disturb_on) +val MeshtasticIcons.DeleteNode: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_delete_fill0) val MeshtasticIcons.Nodes: ImageVector - get() { - if (nodes != null) { - return nodes!! - } - nodes = - ImageVector.Builder( - name = "Outlined.Nodes", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color.Black)) { - moveTo(480f, 880f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - quadToRelative(0f, -5f, 0.5f, -11f) - reflectiveQuadToRelative(1.5f, -11f) - lineToRelative(-83f, -47f) - quadToRelative(-16f, 14f, -36f, 21.5f) - reflectiveQuadToRelative(-43f, 7.5f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - quadToRelative(0f, -50f, 35f, -85f) - reflectiveQuadToRelative(85f, -35f) - quadToRelative(24f, 0f, 45f, 9f) - reflectiveQuadToRelative(38f, 25f) - lineToRelative(119f, -60f) - quadToRelative(-3f, -23f, 2.5f, -45f) - reflectiveQuadToRelative(19.5f, -41f) - lineToRelative(-34f, -52f) - quadToRelative(-7f, 2f, -14.5f, 3f) - reflectiveQuadToRelative(-15.5f, 1f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - quadToRelative(0f, -50f, 35f, -85f) - reflectiveQuadToRelative(85f, -35f) - quadToRelative(50f, 0f, 85f, 35f) - reflectiveQuadToRelative(35f, 85f) - quadToRelative(0f, 20f, -6.5f, 38.5f) - reflectiveQuadTo(456f, 272f) - lineToRelative(35f, 52f) - quadToRelative(8f, -2f, 15f, -3f) - reflectiveQuadToRelative(15f, -1f) - quadToRelative(17f, 0f, 32f, 4f) - reflectiveQuadToRelative(29f, 12f) - lineToRelative(66f, -54f) - quadToRelative(-4f, -10f, -6f, -20.5f) - reflectiveQuadToRelative(-2f, -21.5f) - quadToRelative(0f, -50f, 35f, -85f) - reflectiveQuadToRelative(85f, -35f) - quadToRelative(50f, 0f, 85f, 35f) - reflectiveQuadToRelative(35f, 85f) - quadToRelative(0f, 50f, -35f, 85f) - reflectiveQuadToRelative(-85f, 35f) - quadToRelative(-17f, 0f, -32f, -4.5f) - reflectiveQuadTo(699f, 343f) - lineToRelative(-66f, 55f) - quadToRelative(4f, 10f, 6f, 20.5f) - reflectiveQuadToRelative(2f, 21.5f) - quadToRelative(0f, 50f, -35f, 85f) - reflectiveQuadToRelative(-85f, 35f) - quadToRelative(-24f, 0f, -45.5f, -9f) - reflectiveQuadTo(437f, 526f) - lineToRelative(-118f, 59f) - quadToRelative(2f, 9f, 1.5f, 18f) - reflectiveQuadToRelative(-2.5f, 18f) - lineToRelative(84f, 48f) - quadToRelative(16f, -14f, 35.5f, -21.5f) - reflectiveQuadTo(480f, 640f) - quadToRelative(50f, 0f, 85f, 35f) - reflectiveQuadToRelative(35f, 85f) - quadToRelative(0f, 50f, -35f, 85f) - reflectiveQuadToRelative(-85f, 35f) - close() - moveTo(200f, 640f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(240f, 600f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(200f, 560f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(160f, 600f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(200f, 640f) - close() - moveTo(360f, 240f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(400f, 200f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(360f, 160f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(320f, 200f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(360f, 240f) - close() - moveTo(480f, 800f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(520f, 760f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(480f, 720f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(440f, 760f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(480f, 800f) - close() - moveTo(520f, 480f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(560f, 440f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(520f, 400f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(480f, 440f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(520f, 480f) - close() - moveTo(760f, 280f) - quadToRelative(17f, 0f, 28.5f, -11.5f) - reflectiveQuadTo(800f, 240f) - quadToRelative(0f, -17f, -11.5f, -28.5f) - reflectiveQuadTo(760f, 200f) - quadToRelative(-17f, 0f, -28.5f, 11.5f) - reflectiveQuadTo(720f, 240f) - quadToRelative(0f, 17f, 11.5f, 28.5f) - reflectiveQuadTo(760f, 280f) - close() - } - } - .build() - - return nodes!! - } - -private var nodes: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_nodes) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Person.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Person.kt index 016eab9d0..130650114 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Person.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Person.kt @@ -16,24 +16,32 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AccountCircle -import androidx.compose.material.icons.rounded.Group -import androidx.compose.material.icons.rounded.Groups -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PersonOff -import androidx.compose.material.icons.rounded.PersonSearch +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_account_circle +import org.meshtastic.core.resources.ic_group +import org.meshtastic.core.resources.ic_groups +import org.meshtastic.core.resources.ic_person +import org.meshtastic.core.resources.ic_person_add +import org.meshtastic.core.resources.ic_person_off +import org.meshtastic.core.resources.ic_person_search -val MeshtasticIcons.Person: ImageVector - get() = Icons.Rounded.Person val MeshtasticIcons.PersonOff: ImageVector - get() = Icons.Rounded.PersonOff -val MeshtasticIcons.Groups: ImageVector - get() = Icons.Rounded.Groups + @Composable get() = vectorResource(Res.drawable.ic_person_off) val MeshtasticIcons.Group: ImageVector - get() = Icons.Rounded.Group + @Composable get() = vectorResource(Res.drawable.ic_group) val MeshtasticIcons.AccountCircle: ImageVector - get() = Icons.Rounded.AccountCircle + @Composable get() = vectorResource(Res.drawable.ic_account_circle) val MeshtasticIcons.PersonSearch: ImageVector - get() = Icons.Rounded.PersonSearch + @Composable get() = vectorResource(Res.drawable.ic_person_search) + +val MeshtasticIcons.PersonAdd: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_person_add) +val MeshtasticIcons.Person: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_person) +val MeshtasticIcons.Groups: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_groups) +val MeshtasticIcons.PeopleCount: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_group) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Security.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Security.kt index 136b58e5e..e545cee5e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Security.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Security.kt @@ -16,24 +16,23 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material.icons.rounded.KeyOff -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.LockOpen -import androidx.compose.material.icons.rounded.Verified -import androidx.compose.material.icons.rounded.Warning +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_key_off +import org.meshtastic.core.resources.ic_lock +import org.meshtastic.core.resources.ic_lock_open +import org.meshtastic.core.resources.ic_security +import org.meshtastic.core.resources.ic_verified -val MeshtasticIcons.Lock: ImageVector - get() = Icons.Rounded.Lock -val MeshtasticIcons.LockOpen: ImageVector - get() = Icons.Rounded.LockOpen -val MeshtasticIcons.Warning: ImageVector - get() = Icons.Rounded.Warning -val MeshtasticIcons.KeyOff: ImageVector - get() = Icons.Rounded.KeyOff val MeshtasticIcons.Verified: ImageVector - get() = Icons.Rounded.Verified -val MeshtasticIcons.Fingerprint: ImageVector - get() = Icons.Rounded.Fingerprint + @Composable get() = vectorResource(Res.drawable.ic_verified) +val MeshtasticIcons.Lock: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_lock) +val MeshtasticIcons.LockOpen: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_lock_open) +val MeshtasticIcons.KeyOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_key_off) +val MeshtasticIcons.SecurityShield: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_security) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt index 741273259..936d5748a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt @@ -16,144 +16,57 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_abc +import org.meshtastic.core.resources.ic_admin_panel_settings +import org.meshtastic.core.resources.ic_app_settings_alt +import org.meshtastic.core.resources.ic_bug_report +import org.meshtastic.core.resources.ic_cleaning_services +import org.meshtastic.core.resources.ic_data_usage +import org.meshtastic.core.resources.ic_format_paint +import org.meshtastic.core.resources.ic_language +import org.meshtastic.core.resources.ic_list +import org.meshtastic.core.resources.ic_notifications +import org.meshtastic.core.resources.ic_perm_scan_wifi +import org.meshtastic.core.resources.ic_sensors +import org.meshtastic.core.resources.ic_settings +import org.meshtastic.core.resources.ic_settings_remote +import org.meshtastic.core.resources.ic_storage +import org.meshtastic.core.resources.ic_waving_hand -/** - * This is from Material Symbols. - * - * @see - * [settings](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:settings:FILL@0;wght@400;GRAD@0;opsz@24&icon.style=Rounded&icon.query=settings&icon.set=Material+Symbols&icon.size=24&icon.color=%23e3e3e3&icon.platform=android) - */ +// Config route icons +val MeshtasticIcons.AdminPanelSettings: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_admin_panel_settings) +val MeshtasticIcons.AppSettingsAlt: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_app_settings_alt) +val MeshtasticIcons.BugReport: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bug_report) +val MeshtasticIcons.CleaningServices: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cleaning_services) +val MeshtasticIcons.FormatPaint: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_format_paint) +val MeshtasticIcons.Language: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_language) +val MeshtasticIcons.WavingHand: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_waving_hand) +val MeshtasticIcons.Abc: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_abc) val MeshtasticIcons.Settings: ImageVector - get() { - if (settings != null) { - return settings!! - } - settings = - ImageVector.Builder( - name = "Settings", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(433f, 880f) - quadToRelative(-27f, 0f, -46.5f, -18f) - reflectiveQuadTo(363f, 818f) - lineToRelative(-9f, -66f) - quadToRelative(-13f, -5f, -24.5f, -12f) - reflectiveQuadTo(307f, 725f) - lineToRelative(-62f, 26f) - quadToRelative(-25f, 11f, -50f, 2f) - reflectiveQuadToRelative(-39f, -32f) - lineToRelative(-47f, -82f) - quadToRelative(-14f, -23f, -8f, -49f) - reflectiveQuadToRelative(27f, -43f) - lineToRelative(53f, -40f) - quadToRelative(-1f, -7f, -1f, -13.5f) - verticalLineToRelative(-27f) - quadToRelative(0f, -6.5f, 1f, -13.5f) - lineToRelative(-53f, -40f) - quadToRelative(-21f, -17f, -27f, -43f) - reflectiveQuadToRelative(8f, -49f) - lineToRelative(47f, -82f) - quadToRelative(14f, -23f, 39f, -32f) - reflectiveQuadToRelative(50f, 2f) - lineToRelative(62f, 26f) - quadToRelative(11f, -8f, 23f, -15f) - reflectiveQuadToRelative(24f, -12f) - lineToRelative(9f, -66f) - quadToRelative(4f, -26f, 23.5f, -44f) - reflectiveQuadToRelative(46.5f, -18f) - horizontalLineToRelative(94f) - quadToRelative(27f, 0f, 46.5f, 18f) - reflectiveQuadToRelative(23.5f, 44f) - lineToRelative(9f, 66f) - quadToRelative(13f, 5f, 24.5f, 12f) - reflectiveQuadToRelative(22.5f, 15f) - lineToRelative(62f, -26f) - quadToRelative(25f, -11f, 50f, -2f) - reflectiveQuadToRelative(39f, 32f) - lineToRelative(47f, 82f) - quadToRelative(14f, 23f, 8f, 49f) - reflectiveQuadToRelative(-27f, 43f) - lineToRelative(-53f, 40f) - quadToRelative(1f, 7f, 1f, 13.5f) - verticalLineToRelative(27f) - quadToRelative(0f, 6.5f, -2f, 13.5f) - lineToRelative(53f, 40f) - quadToRelative(21f, 17f, 27f, 43f) - reflectiveQuadToRelative(-8f, 49f) - lineToRelative(-48f, 82f) - quadToRelative(-14f, 23f, -39f, 32f) - reflectiveQuadToRelative(-50f, -2f) - lineToRelative(-60f, -26f) - quadToRelative(-11f, 8f, -23f, 15f) - reflectiveQuadToRelative(-24f, 12f) - lineToRelative(-9f, 66f) - quadToRelative(-4f, 26f, -23.5f, 44f) - reflectiveQuadTo(527f, 880f) - horizontalLineToRelative(-94f) - close() - moveTo(440f, 800f) - horizontalLineToRelative(79f) - lineToRelative(14f, -106f) - quadToRelative(31f, -8f, 57.5f, -23.5f) - reflectiveQuadTo(639f, 633f) - lineToRelative(99f, 41f) - lineToRelative(39f, -68f) - lineToRelative(-86f, -65f) - quadToRelative(5f, -14f, 7f, -29.5f) - reflectiveQuadToRelative(2f, -31.5f) - quadToRelative(0f, -16f, -2f, -31.5f) - reflectiveQuadToRelative(-7f, -29.5f) - lineToRelative(86f, -65f) - lineToRelative(-39f, -68f) - lineToRelative(-99f, 42f) - quadToRelative(-22f, -23f, -48.5f, -38.5f) - reflectiveQuadTo(533f, 266f) - lineToRelative(-13f, -106f) - horizontalLineToRelative(-79f) - lineToRelative(-14f, 106f) - quadToRelative(-31f, 8f, -57.5f, 23.5f) - reflectiveQuadTo(321f, 327f) - lineToRelative(-99f, -41f) - lineToRelative(-39f, 68f) - lineToRelative(86f, 64f) - quadToRelative(-5f, 15f, -7f, 30f) - reflectiveQuadToRelative(-2f, 32f) - quadToRelative(0f, 16f, 2f, 31f) - reflectiveQuadToRelative(7f, 30f) - lineToRelative(-86f, 65f) - lineToRelative(39f, 68f) - lineToRelative(99f, -42f) - quadToRelative(22f, 23f, 48.5f, 38.5f) - reflectiveQuadTo(427f, 694f) - lineToRelative(13f, 106f) - close() - moveTo(482f, 620f) - quadToRelative(58f, 0f, 99f, -41f) - reflectiveQuadToRelative(41f, -99f) - quadToRelative(0f, -58f, -41f, -99f) - reflectiveQuadToRelative(-99f, -41f) - quadToRelative(-59f, 0f, -99.5f, 41f) - reflectiveQuadTo(342f, 480f) - quadToRelative(0f, 58f, 40.5f, 99f) - reflectiveQuadToRelative(99.5f, 41f) - close() - moveTo(480f, 480f) - close() - } - } - .build() - - return settings!! - } - -private var settings: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_settings) +val MeshtasticIcons.ConfigChannels: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_list) +val MeshtasticIcons.Notifications: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_notifications) +val MeshtasticIcons.DataUsage: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_data_usage) +val MeshtasticIcons.PermScanWifi: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_perm_scan_wifi) +val MeshtasticIcons.DetectionSensor: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_sensors) +val MeshtasticIcons.SettingsRemote: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_settings_remote) +val MeshtasticIcons.Storage: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_storage) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Signal.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Signal.kt index bd77cf8db..805eebdbc 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Signal.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Signal.kt @@ -16,239 +16,71 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CrueltyFree -import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.SsidChart -import androidx.compose.material.icons.rounded.WifiChannel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_cell_tower +import org.meshtastic.core.resources.ic_cruelty_free +import org.meshtastic.core.resources.ic_graphic_eq +import org.meshtastic.core.resources.ic_hub +import org.meshtastic.core.resources.ic_near_me +import org.meshtastic.core.resources.ic_podcasts +import org.meshtastic.core.resources.ic_signal_cellular_0_bar +import org.meshtastic.core.resources.ic_signal_cellular_1_bar +import org.meshtastic.core.resources.ic_signal_cellular_2_bar +import org.meshtastic.core.resources.ic_signal_cellular_3_bar +import org.meshtastic.core.resources.ic_signal_cellular_4_bar +import org.meshtastic.core.resources.ic_signal_cellular_alt +import org.meshtastic.core.resources.ic_signal_cellular_alt_1_bar +import org.meshtastic.core.resources.ic_signal_cellular_alt_2_bar +import org.meshtastic.core.resources.ic_signal_cellular_off +import org.meshtastic.core.resources.ic_ssid_chart +import org.meshtastic.core.resources.ic_tsunami +import org.meshtastic.core.resources.ic_wifi_channel -val MeshtasticIcons.Hops: ImageVector - get() = Icons.Rounded.CrueltyFree -val MeshtasticIcons.Route: ImageVector - get() = Icons.Rounded.Route +val MeshtasticIcons.HopCount: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cruelty_free) val MeshtasticIcons.Channel: ImageVector - get() = Icons.Rounded.WifiChannel -val MeshtasticIcons.ChannelUtilization: ImageVector - get() = Icons.Rounded.SignalCellularAlt + @Composable get() = vectorResource(Res.drawable.ic_wifi_channel) val MeshtasticIcons.AirUtilization: ImageVector - get() = Icons.Rounded.SsidChart + @Composable get() = vectorResource(Res.drawable.ic_ssid_chart) + +// Signal measurement metrics +val MeshtasticIcons.Snr: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_graphic_eq) +val MeshtasticIcons.Rssi: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_podcasts) val MeshtasticIcons.SignalCellular0Bar: ImageVector - get() { - if (signalCellular0Bar != null) { - return signalCellular0Bar!! - } - signalCellular0Bar = - ImageVector.Builder( - name = "SignalCellular0Bar", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(177f, 880f) - quadToRelative(-27f, 0f, -37.5f, -24.5f) - reflectiveQuadTo(148f, 812f) - lineToRelative(664f, -664f) - quadToRelative(19f, -19f, 43.5f, -8.5f) - reflectiveQuadTo(880f, 177f) - verticalLineToRelative(643f) - quadToRelative(0f, 25f, -17.5f, 42.5f) - reflectiveQuadTo(820f, 880f) - lineTo(177f, 880f) - close() - moveTo(273f, 800f) - horizontalLineToRelative(527f) - verticalLineToRelative(-526f) - lineTo(273f, 800f) - close() - } - } - .build() - - return signalCellular0Bar!! - } - -private var signalCellular0Bar: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_0_bar) val MeshtasticIcons.SignalCellular1Bar: ImageVector - get() { - if (signalCellular1Bar != null) { - return signalCellular1Bar!! - } - signalCellular1Bar = - ImageVector.Builder( - name = "SignalCellular1Bar", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(177f, 880f) - quadToRelative(-18f, 0f, -29.5f, -12f) - reflectiveQuadTo(136f, 840f) - quadToRelative(0f, -8f, 3f, -15f) - reflectiveQuadToRelative(9f, -13f) - lineToRelative(664f, -664f) - quadToRelative(6f, -6f, 13f, -9f) - reflectiveQuadToRelative(15f, -3f) - quadToRelative(16f, 0f, 28f, 11.5f) - reflectiveQuadToRelative(12f, 29.5f) - verticalLineToRelative(643f) - quadToRelative(0f, 25f, -17.5f, 42.5f) - reflectiveQuadTo(820f, 880f) - lineTo(177f, 880f) - close() - moveTo(400f, 800f) - horizontalLineToRelative(400f) - verticalLineToRelative(-526f) - lineTo(400f, 674f) - verticalLineToRelative(126f) - close() - } - } - .build() - - return signalCellular1Bar!! - } - -private var signalCellular1Bar: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_1_bar) val MeshtasticIcons.SignalCellular2Bar: ImageVector - get() { - if (signalCellular2Bar != null) { - return signalCellular2Bar!! - } - signalCellular2Bar = - ImageVector.Builder( - name = "SignalCellular2Bar", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(177f, 880f) - quadToRelative(-18f, 0f, -29.5f, -12f) - reflectiveQuadTo(136f, 840f) - quadToRelative(0f, -8f, 3f, -15f) - reflectiveQuadToRelative(9f, -13f) - lineToRelative(664f, -664f) - quadToRelative(6f, -6f, 13f, -9f) - reflectiveQuadToRelative(15f, -3f) - quadToRelative(16f, 0f, 28f, 11.5f) - reflectiveQuadToRelative(12f, 29.5f) - verticalLineToRelative(643f) - quadToRelative(0f, 25f, -17.5f, 42.5f) - reflectiveQuadTo(820f, 880f) - lineTo(177f, 880f) - close() - moveTo(520f, 800f) - horizontalLineToRelative(280f) - verticalLineToRelative(-526f) - lineTo(520f, 554f) - verticalLineToRelative(246f) - close() - } - } - .build() - - return signalCellular2Bar!! - } - -private var signalCellular2Bar: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_2_bar) val MeshtasticIcons.SignalCellular3Bar: ImageVector - get() { - if (signalCellular3Bar != null) { - return signalCellular3Bar!! - } - signalCellular3Bar = - ImageVector.Builder( - name = "SignalCellular3Bar", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(177f, 880f) - quadToRelative(-18f, 0f, -29.5f, -12f) - reflectiveQuadTo(136f, 840f) - quadToRelative(0f, -8f, 3f, -15f) - reflectiveQuadToRelative(9f, -13f) - lineToRelative(664f, -664f) - quadToRelative(6f, -6f, 13f, -9f) - reflectiveQuadToRelative(15f, -3f) - quadToRelative(16f, 0f, 28f, 11.5f) - reflectiveQuadToRelative(12f, 29.5f) - verticalLineToRelative(643f) - quadToRelative(0f, 25f, -17.5f, 42.5f) - reflectiveQuadTo(820f, 880f) - lineTo(177f, 880f) - close() - moveTo(600f, 800f) - horizontalLineToRelative(200f) - verticalLineToRelative(-526f) - lineTo(600f, 474f) - verticalLineToRelative(326f) - close() - } - } - .build() - - return signalCellular3Bar!! - } - -private var signalCellular3Bar: ImageVector? = null + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_3_bar) val MeshtasticIcons.SignalCellular4Bar: ImageVector - get() { - if (signalCellular4Bar != null) { - return signalCellular4Bar!! - } - signalCellular4Bar = - ImageVector.Builder( - name = "SignalCellular4Bar", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ) - .apply { - path(fill = SolidColor(Color(0xFFE3E3E3))) { - moveTo(177f, 880f) - quadToRelative(-18f, 0f, -29.5f, -12f) - reflectiveQuadTo(136f, 840f) - quadToRelative(0f, -8f, 3f, -15f) - reflectiveQuadToRelative(9f, -13f) - lineToRelative(664f, -664f) - quadToRelative(6f, -6f, 13f, -9f) - reflectiveQuadToRelative(15f, -3f) - quadToRelative(16f, 0f, 28f, 11.5f) - reflectiveQuadToRelative(12f, 29.5f) - verticalLineToRelative(643f) - quadToRelative(0f, 25f, -17.5f, 42.5f) - reflectiveQuadTo(820f, 880f) - lineTo(177f, 880f) - close() - } - } - .build() + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_4_bar) - return signalCellular4Bar!! - } +val MeshtasticIcons.MeshHub: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_hub) +val MeshtasticIcons.NearMe: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_near_me) +val MeshtasticIcons.Tsunami: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_tsunami) -private var signalCellular4Bar: ImageVector? = null +val MeshtasticIcons.SignalOff: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_off) +val MeshtasticIcons.SignalAlt1Bar: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_alt_1_bar) +val MeshtasticIcons.SignalAlt2Bar: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_alt_2_bar) +val MeshtasticIcons.CellTower: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cell_tower) +val MeshtasticIcons.ChannelUtilization: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_signal_cellular_alt) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Status.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Status.kt index a0f02f209..14266a660 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Status.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Status.kt @@ -16,81 +16,122 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.SpeakerNotes -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.automirrored.twotone.VolumeMute -import androidx.compose.material.icons.automirrored.twotone.VolumeUp -import androidx.compose.material.icons.rounded.ArrowCircleUp -import androidx.compose.material.icons.rounded.CheckCircleOutline -import androidx.compose.material.icons.rounded.Cloud -import androidx.compose.material.icons.rounded.CloudOff -import androidx.compose.material.icons.rounded.Dangerous -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.NoCell -import androidx.compose.material.icons.rounded.SettingsEthernet -import androidx.compose.material.icons.rounded.SpeakerNotesOff -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.rounded.StarBorder -import androidx.compose.material.icons.rounded.Terminal -import androidx.compose.material.icons.twotone.Cloud -import androidx.compose.material.icons.twotone.CloudDone -import androidx.compose.material.icons.twotone.CloudOff -import androidx.compose.material.icons.twotone.CloudSync -import androidx.compose.material.icons.twotone.HowToReg +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_arrow_circle_up +import org.meshtastic.core.resources.ic_bedtime +import org.meshtastic.core.resources.ic_check_circle_fill0 +import org.meshtastic.core.resources.ic_check_circle_fill1 +import org.meshtastic.core.resources.ic_cloud +import org.meshtastic.core.resources.ic_cloud_done +import org.meshtastic.core.resources.ic_cloud_download +import org.meshtastic.core.resources.ic_cloud_sync +import org.meshtastic.core.resources.ic_cloud_upload +import org.meshtastic.core.resources.ic_dangerous +import org.meshtastic.core.resources.ic_error_fill0 +import org.meshtastic.core.resources.ic_error_fill1 +import org.meshtastic.core.resources.ic_history +import org.meshtastic.core.resources.ic_how_to_reg +import org.meshtastic.core.resources.ic_info +import org.meshtastic.core.resources.ic_lan +import org.meshtastic.core.resources.ic_link_off +import org.meshtastic.core.resources.ic_no_cell +import org.meshtastic.core.resources.ic_radio_button_unchecked +import org.meshtastic.core.resources.ic_schedule +import org.meshtastic.core.resources.ic_settings_ethernet +import org.meshtastic.core.resources.ic_speaker_notes +import org.meshtastic.core.resources.ic_speaker_notes_off +import org.meshtastic.core.resources.ic_star +import org.meshtastic.core.resources.ic_star_border +import org.meshtastic.core.resources.ic_terminal +import org.meshtastic.core.resources.ic_volume_mute +import org.meshtastic.core.resources.ic_volume_off +import org.meshtastic.core.resources.ic_warning +// Favorites val MeshtasticIcons.Favorite: ImageVector - get() = Icons.Rounded.Star + @Composable get() = vectorResource(Res.drawable.ic_star) val MeshtasticIcons.NotFavorite: ImageVector - get() = Icons.Rounded.StarBorder + @Composable get() = vectorResource(Res.drawable.ic_star_border) + +// Mute state val MeshtasticIcons.Muted: ImageVector - get() = Icons.Rounded.SpeakerNotesOff + @Composable get() = vectorResource(Res.drawable.ic_speaker_notes_off) val MeshtasticIcons.Unmuted: ImageVector - get() = Icons.AutoMirrored.Filled.SpeakerNotes + @Composable get() = vectorResource(Res.drawable.ic_speaker_notes) + +// Volume val MeshtasticIcons.VolumeOff: ImageVector - get() = Icons.AutoMirrored.Filled.VolumeOff -val MeshtasticIcons.VolumeUp: ImageVector - get() = Icons.AutoMirrored.Filled.VolumeUp + @Composable get() = vectorResource(Res.drawable.ic_volume_off) +val MeshtasticIcons.VolumeMute: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_volume_mute) + +// Time val MeshtasticIcons.History: ImageVector - get() = Icons.Rounded.History -val MeshtasticIcons.Cloud: ImageVector - get() = Icons.Rounded.Cloud -val MeshtasticIcons.CloudOff: ImageVector - get() = Icons.Rounded.CloudOff + @Composable get() = vectorResource(Res.drawable.ic_history) + +// MQTT status +val MeshtasticIcons.MqttDelivered: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cloud_done) +val MeshtasticIcons.MqttSyncing: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cloud_sync) + +// Connectivity val MeshtasticIcons.Unmessageable: ImageVector - get() = Icons.Rounded.NoCell - -val MeshtasticIcons.CloudDone: ImageVector - get() = Icons.TwoTone.CloudDone -val MeshtasticIcons.CloudSync: ImageVector - get() = Icons.TwoTone.CloudSync -val MeshtasticIcons.CloudOffTwoTone: ImageVector - get() = Icons.TwoTone.CloudOff -val MeshtasticIcons.CloudTwoTone: ImageVector - get() = Icons.TwoTone.Cloud - -val MeshtasticIcons.ArrowCircleUp: ImageVector - get() = Icons.Rounded.ArrowCircleUp -val MeshtasticIcons.Dangerous: ImageVector - get() = Icons.Rounded.Dangerous - -val MeshtasticIcons.VolumeUpTwoTone: ImageVector - get() = Icons.AutoMirrored.TwoTone.VolumeUp -val MeshtasticIcons.VolumeMuteTwoTone: ImageVector - get() = Icons.AutoMirrored.TwoTone.VolumeMute - -val MeshtasticIcons.CheckCircle: ImageVector - get() = Icons.Rounded.CheckCircleOutline - -val MeshtasticIcons.Acknowledged: ImageVector - get() = Icons.TwoTone.HowToReg - + @Composable get() = vectorResource(Res.drawable.ic_no_cell) val MeshtasticIcons.Udp: ImageVector - get() = Icons.Rounded.Lan + @Composable get() = vectorResource(Res.drawable.ic_lan) val MeshtasticIcons.Api: ImageVector - get() = Icons.Rounded.Terminal + @Composable get() = vectorResource(Res.drawable.ic_terminal) val MeshtasticIcons.Ethernet: ImageVector - get() = Icons.Rounded.SettingsEthernet + @Composable get() = vectorResource(Res.drawable.ic_settings_ethernet) + +// Update & lifecycle +val MeshtasticIcons.ArrowCircleUp: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_arrow_circle_up) +val MeshtasticIcons.Dangerous: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_dangerous) + +// Result states +val MeshtasticIcons.CheckCircle: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_check_circle_fill0) +val MeshtasticIcons.Success: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_check_circle_fill1) +val MeshtasticIcons.Error: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_error_fill1) +val MeshtasticIcons.ErrorOutline: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_error_fill0) +val MeshtasticIcons.Info: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_info) + +// Acknowledgment +val MeshtasticIcons.Acknowledged: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_how_to_reg) + +// Selection state +val MeshtasticIcons.RadioButtonUnchecked: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_radio_button_unchecked) + +// Device sleep +val MeshtasticIcons.DeviceSleep: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bedtime) + +// Node connection state (non-MQTT) +val MeshtasticIcons.Disconnected: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_link_off) + +// Message delivery status +val MeshtasticIcons.MessageEnroute: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_schedule) +val MeshtasticIcons.MessageError: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_error_fill0) +val MeshtasticIcons.Warning: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_warning) +val MeshtasticIcons.MqttConnected: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cloud) +val MeshtasticIcons.CloudUpload: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cloud_upload) +val MeshtasticIcons.CloudDownload: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_cloud_download) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt index 56f51bd8a..983e07bbf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt @@ -16,45 +16,78 @@ */ package org.meshtastic.core.ui.icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Air -import androidx.compose.material.icons.rounded.DataArray -import androidx.compose.material.icons.rounded.ElectricBolt -import androidx.compose.material.icons.rounded.Grass -import androidx.compose.material.icons.rounded.LineAxis -import androidx.compose.material.icons.rounded.People -import androidx.compose.material.icons.rounded.SocialDistance -import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material.icons.rounded.StackedLineChart -import androidx.compose.material.icons.rounded.Thermostat -import androidx.compose.material.icons.rounded.WaterDrop -import androidx.compose.material.icons.twotone.SatelliteAlt +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_air +import org.meshtastic.core.resources.ic_alt_route +import org.meshtastic.core.resources.ic_blur_on +import org.meshtastic.core.resources.ic_bolt +import org.meshtastic.core.resources.ic_charging_station +import org.meshtastic.core.resources.ic_compress +import org.meshtastic.core.resources.ic_data_array +import org.meshtastic.core.resources.ic_electric_bolt +import org.meshtastic.core.resources.ic_explore +import org.meshtastic.core.resources.ic_grass +import org.meshtastic.core.resources.ic_height +import org.meshtastic.core.resources.ic_light_mode +import org.meshtastic.core.resources.ic_line_axis +import org.meshtastic.core.resources.ic_navigation +import org.meshtastic.core.resources.ic_power +import org.meshtastic.core.resources.ic_satellite_alt +import org.meshtastic.core.resources.ic_scale +import org.meshtastic.core.resources.ic_social_distance +import org.meshtastic.core.resources.ic_speed +import org.meshtastic.core.resources.ic_stacked_line_chart +import org.meshtastic.core.resources.ic_thermostat +import org.meshtastic.core.resources.ic_volume_up +import org.meshtastic.core.resources.ic_water_drop -val MeshtasticIcons.Temperature: ImageVector - get() = Icons.Rounded.Thermostat val MeshtasticIcons.Humidity: ImageVector - get() = Icons.Rounded.WaterDrop + @Composable get() = vectorResource(Res.drawable.ic_water_drop) val MeshtasticIcons.Pressure: ImageVector - get() = Icons.Rounded.Speed -val MeshtasticIcons.Soil: ImageVector - get() = Icons.Rounded.Grass -val MeshtasticIcons.Paxcount: ImageVector - get() = Icons.Rounded.People -val MeshtasticIcons.AirQuality: ImageVector - get() = Icons.Rounded.Air -val MeshtasticIcons.Power: ImageVector - get() = Icons.Rounded.ElectricBolt + @Composable get() = vectorResource(Res.drawable.ic_compress) +val MeshtasticIcons.SoilMoisture: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_grass) +val MeshtasticIcons.ElectricPower: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_electric_bolt) val MeshtasticIcons.Distance: ImageVector - get() = Icons.Rounded.SocialDistance + @Composable get() = vectorResource(Res.drawable.ic_social_distance) val MeshtasticIcons.Satellites: ImageVector - get() = Icons.TwoTone.SatelliteAlt + @Composable get() = vectorResource(Res.drawable.ic_satellite_alt) val MeshtasticIcons.DataArray: ImageVector - get() = Icons.Rounded.DataArray -val MeshtasticIcons.Speed: ImageVector - get() = Icons.Rounded.Speed + @Composable get() = vectorResource(Res.drawable.ic_data_array) val MeshtasticIcons.Chart: ImageVector - get() = Icons.Rounded.StackedLineChart - + @Composable get() = vectorResource(Res.drawable.ic_stacked_line_chart) val MeshtasticIcons.LineAxis: ImageVector - get() = Icons.Rounded.LineAxis + @Composable get() = vectorResource(Res.drawable.ic_line_axis) + +val MeshtasticIcons.Altitude: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_height) +val MeshtasticIcons.Weight: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_scale) +val MeshtasticIcons.Particulate: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_blur_on) +val MeshtasticIcons.WindDirection: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_navigation) +val MeshtasticIcons.Voltage: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_bolt) +val MeshtasticIcons.Compass: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_explore) +val MeshtasticIcons.Temperature: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_thermostat) +val MeshtasticIcons.PowerSupply: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_power) +val MeshtasticIcons.AirQuality: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_air) +val MeshtasticIcons.Speed: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_speed) +val MeshtasticIcons.LightMode: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_light_mode) +val MeshtasticIcons.ChargingStation: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_charging_station) +val MeshtasticIcons.TrafficManagement: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_alt_route) +val MeshtasticIcons.VolumeUp: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_volume_up) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/navigation/TopLevelDestinationExt.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/navigation/TopLevelDestinationExt.kt index e53ef7771..437c6ad3b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/navigation/TopLevelDestinationExt.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/navigation/TopLevelDestinationExt.kt @@ -16,22 +16,22 @@ */ package org.meshtastic.core.ui.navigation -import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource import org.meshtastic.core.navigation.TopLevelDestination -import org.meshtastic.core.ui.icon.Conversations -import org.meshtastic.core.ui.icon.Map -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Nodes -import org.meshtastic.core.ui.icon.Settings -import org.meshtastic.core.ui.icon.Wifi +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_forum +import org.meshtastic.core.resources.ic_map +import org.meshtastic.core.resources.ic_nodes +import org.meshtastic.core.resources.ic_settings +import org.meshtastic.core.resources.ic_wifi -/** Maps a shared [TopLevelDestination] to its corresponding icon from [MeshtasticIcons]. */ -val TopLevelDestination.icon: ImageVector +/** Maps a shared [TopLevelDestination] to its corresponding icon [DrawableResource]. */ +val TopLevelDestination.icon: DrawableResource get() = when (this) { - TopLevelDestination.Conversations -> MeshtasticIcons.Conversations - TopLevelDestination.Nodes -> MeshtasticIcons.Nodes - TopLevelDestination.Map -> MeshtasticIcons.Map - TopLevelDestination.Settings -> MeshtasticIcons.Settings - TopLevelDestination.Connections -> MeshtasticIcons.Wifi + TopLevelDestination.Conversations -> Res.drawable.ic_forum + TopLevelDestination.Nodes -> Res.drawable.ic_nodes + TopLevelDestination.Map -> Res.drawable.ic_map + TopLevelDestination.Settings -> Res.drawable.ic_settings + TopLevelDestination.Connections -> Res.drawable.ic_wifi } 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 632c8abb4..7e5271148 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -29,6 +29,7 @@ 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 @@ -39,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -88,7 +90,7 @@ fun ScannedQrCodeDialog( onDismiss: () -> Unit, onConfirm: (ChannelSet) -> Unit, ) { - var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) } + var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) } val channelSet = remember(shouldReplace, channels, incoming) { @@ -240,21 +242,33 @@ fun ScannedQrCodeDialog( val unselectedColors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = false }, - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = if (!shouldReplace) selectedColors else unselectedColors, ) { - Text(text = stringResource(Res.string.add)) + Text( + text = stringResource(Res.string.add), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = true }, - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), enabled = incoming.lora_config != null, colors = if (shouldReplace) selectedColors else unselectedColors, ) { - Text(text = stringResource(Res.string.replace)) + Text( + text = stringResource(Res.string.replace), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 2c10206aa..db23f1d77 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -40,7 +39,7 @@ class ScannedQrCodeViewModel( private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -51,11 +50,11 @@ class ScannedQrCodeViewModel( } private fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt new file mode 100644 index 000000000..cd68cd12c --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Application-wide contrast level for accessibility. + * + * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and + * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in + * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders. + */ +enum class ContrastLevel(val value: Int) { + STANDARD(0), + MEDIUM(1), + HIGH(2), + ; + + companion object { + fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD + } +} + +/** + * Composition local providing the current [ContrastLevel]. + * + * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators). + */ +val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 38338a555..d2047b603 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -55,6 +55,15 @@ object GraphColors { val Red = Color(0xFFE91E63) val Blue = Color(0xFF2196F3) val Green = Color(0xFF4CAF50) + val Teal = Color(0xFF009688) + val Amber = Color(0xFFFFC107) + val Lime = Color(0xFFCDDC39) + val Indigo = Color(0xFF3F51B5) + val DeepOrange = Color(0xFFFF5722) + val Magenta = Color(0xFFE040FB) + val SkyBlue = Color(0xFF03A9F4) + val Chartreuse = Color(0xFF76FF03) + val Coral = Color(0xFFFF6E40) } object StatusColors { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt index eb40222af..07c6ab3ad 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("UnusedPrivateProperty") +@file:Suppress("MatchingDeclarationName") package org.meshtastic.core.ui.theme @@ -25,6 +25,7 @@ import androidx.compose.material3.MotionScheme.Companion.expressive import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -272,19 +273,33 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + contrastLevel: ContrastLevel = ContrastLevel.STANDARD, content: @Composable() () -> Unit, ) { - val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null - val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme + val dynamicScheme = + if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) { + dynamicColorScheme(darkTheme) + } else { + null + } + val colorScheme = + dynamicScheme + ?: when (contrastLevel) { + ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + else -> if (darkTheme) darkScheme else lightScheme + } - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = AppTypography, - motionScheme = expressive(), - content = content, - ) + CompositionLocalProvider(LocalContrastLevel provides contrastLevel) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = AppTypography, + motionScheme = expressive(), + content = content, + ) + } } const val MODE_DYNAMIC = 6969420 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt index bc4937fd5..a53b82637 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/AlertPreviews.kt @@ -20,8 +20,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -31,6 +29,8 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.preview_custom_composable_line_one import org.meshtastic.core.resources.preview_custom_composable_line_two import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Warning import org.meshtastic.core.ui.theme.AppTheme /** A helper component that renders an [AlertManager.AlertData] using the same logic as MainScreen. */ @@ -79,7 +79,7 @@ fun PreviewIconAlert() { AlertManager.AlertData( title = "Warning", message = "This action cannot be undone.", - icon = Icons.Rounded.Warning, + icon = MeshtasticIcons.Warning, ), ) } 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 new file mode 100644 index 000000000..d0901f0f9 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.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.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import org.meshtastic.core.ui.component.PlaceholderScreen +import org.meshtastic.proto.Position + +/** + * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. + * 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") } + } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt new file mode 100644 index 000000000..139992c54 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt @@ -0,0 +1,51 @@ +/* + * 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.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.ui.component.PlaceholderScreen +import org.meshtastic.proto.Position + +/** + * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a + * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location + * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s + * scaffold. + * + * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. + * + * Parameters: + * - `tracerouteOverlay`: The overlay with forward/return route node nums. + * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. + * - `onMappableCountChanged`: Callback with (shown, total) node counts. + * - `modifier`: Compose modifier for the map. + */ +@Suppress("Wrapping") +val LocalTracerouteMapProvider = + compositionLocalOf< + @Composable ( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (Int, Int) -> Unit, + modifier: Modifier, + ) -> Unit, + > { + { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } + } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt index 4561886e2..10d975f3d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt @@ -22,23 +22,10 @@ import androidx.compose.ui.Modifier /** * Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map - * implementations (Google Maps vs osmdroid). + * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin. */ interface MapViewProvider { - @Composable - fun MapView( - modifier: Modifier, - // We use Any here to avoid circular dependency with feature:map - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - // Using List to avoid dependency on proto.Position if needed - nodeTracks: List? = null, - tracerouteOverlay: Any? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> }, - waypointId: Int? = null, - ) + @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null) } val LocalMapViewProvider = compositionLocalOf { null } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index d5910168b..9d3169c1a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri /** Returns a function to open the platform's NFC settings. */ @Composable expect fun rememberOpenNfcSettings(): () -> Unit @@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri /** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */ @Composable expect fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit /** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */ @@ -64,3 +63,21 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher to open the platform's location settings. */ @Composable expect fun rememberOpenLocationSettings(): () -> Unit + +/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */ +@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */ +@Composable +expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** + * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions. + */ +@Composable expect fun isLocationPermissionGranted(): Boolean + +/** + * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where + * this concept doesn't apply. + */ +@Composable expect fun isGpsDisabled(): Boolean diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 95bf4365c..edfda074c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -18,6 +18,7 @@ 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 @@ -34,7 +35,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.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MyNodeInfo @@ -43,6 +44,7 @@ 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 @@ -84,7 +86,7 @@ class UIViewModel( val snackbarManager: SnackbarManager, ) : ViewModel() { - private val _navigationDeepLink = MutableSharedFlow>(replay = 1) + private val _navigationDeepLink = MutableSharedFlow>(replay = 1) val navigationDeepLink = _navigationDeepLink.asSharedFlow() /** @@ -96,18 +98,16 @@ class UIViewModel( * 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations. */ - fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) { - val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString) - + fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) { // Try navigation routing first - val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) + val navKeys = DeepLinkRouter.route(uri) if (navKeys != null) { _navigationDeepLink.tryEmit(navKeys) return } // Fallback to channel/contact importing - commonUri.dispatchMeshtasticUri( + uri.dispatchMeshtasticUri( onContact = { setSharedContactRequested(it) }, onChannel = { setRequestChannelSet(it) }, onInvalid = onInvalid, @@ -115,6 +115,7 @@ class UIViewModel( } val theme: StateFlow = uiPrefs.theme + val contrastLevel: StateFlow = uiPrefs.contrastLevel val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } @@ -236,12 +237,12 @@ class UIViewModel( _sharedContactRequested.value = contact } - /** Called immediately after activity observes requestChannelUrl */ + /** Clears the pending shared contact request. */ fun clearSharedContactRequested() { _sharedContactRequested.value = null } - // Connection state to our radio device + /** Canonical app-level connection state, sourced from [ServiceRepository.connectionState]. */ val connectionState get() = serviceRepository.connectionState @@ -255,7 +256,7 @@ class UIViewModel( val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() } - /** Called immediately after activity observes requestChannelUrl */ + /** Clears the pending channel set import request. */ 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 2201d70bd..905d50c2b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -14,16 +14,30 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon") +@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon", "TooGenericExceptionCaught") package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.unknown_error +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -40,3 +54,82 @@ fun Flow.stateInWhileSubscribed(initialValue: T, stopTimeout: Duration = started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeout.inWholeMilliseconds), initialValue = initialValue, ) + +// --------------------------------------------------------------------------- +// UiState: shared Loading / Content / Error wrapper +// --------------------------------------------------------------------------- + +/** + * Lightweight tri-state wrapper for UI data. Prefer this over bare nullable initial values when the UI needs to + * distinguish "still loading" from "genuinely empty." + */ +sealed interface UiState { + /** Data has not yet arrived. */ + data object Loading : UiState + + /** Data is available. */ + data class Content(val data: T) : UiState + + /** An error occurred while loading. */ + data class Error(val message: UiText) : UiState +} + +/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */ +fun UiState.dataOrNull(): T? = (this as? UiState.Content)?.data + +/** + * Wraps this [Flow] into a `StateFlow>`, emitting [UiState.Loading] until the first value, then + * [UiState.Content] for each emission. Upstream errors are caught and mapped to [UiState.Error]. + */ +context(viewModel: ViewModel) +fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow> = + this.map> { UiState.Content(it) } + .onStart { emit(UiState.Loading) } + .catch { e -> + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + emit(UiState.Error(message)) + } + .stateInWhileSubscribed(initialValue = UiState.Loading, stopTimeout = stopTimeout) + +// --------------------------------------------------------------------------- +// safeLaunch: CancellationException-safe coroutine launcher with error routing +// --------------------------------------------------------------------------- + +/** + * Launches a coroutine in [viewModelScope] that catches all exceptions except [CancellationException]. Non-cancellation + * errors are logged and emitted to [errorEvents] (if provided) for one-shot UI consumption (e.g. snackbar / toast). + * + * @param context optional [CoroutineContext] element (typically a dispatcher) merged into the launch. Defaults to + * [EmptyCoroutineContext], inheriting [viewModelScope]'s dispatcher. + * + * ``` + * // In a ViewModel: + * safeLaunch(errorEvents = _errors) { + * repository.saveData(data) + * } + * ``` + */ +context(viewModel: ViewModel) +fun safeLaunch( + context: CoroutineContext = EmptyCoroutineContext, + errorEvents: MutableSharedFlow? = null, + tag: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job = viewModel.viewModelScope.launch(context) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val label = tag ?: "safeLaunch" + Logger.e(e) { "[$label] Unhandled exception" } + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + errorEvents?.tryEmit(message) + } +} + +/** + * Creates and returns a [MutableSharedFlow] intended for one-shot error events. Expose as `SharedFlow` via + * [asSharedFlow] in the ViewModel, and collect in the UI to show snackbars or toasts. + */ +fun errorEventFlow(): MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt similarity index 54% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index 5afc97a6f..7a442980f 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -16,28 +16,46 @@ */ package org.meshtastic.core.ui.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText -import org.junit.Rule -import org.junit.Test +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.meshtastic.core.ui.util.AlertManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) class AlertHostTest { - @get:Rule val composeTestRule = createComposeRule() + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } @Test - fun alertHost_showsDialog_whenAlertIsTriggered() { + fun alertHost_showsDialog_whenAlertIsTriggered() = runComposeUiTest { val alertManager = AlertManager() val title = "Alert Title" val message = "Alert Message" - composeTestRule.setContent { AlertHost(alertManager = alertManager) } + setContent { AlertHost(alertManager = alertManager) } alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt similarity index 63% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index 460a96bc7..8380aabcb 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -18,27 +18,25 @@ package org.meshtastic.core.ui.component import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact 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() { + fun importFab_expands_onButtonClick_whenSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, @@ -48,18 +46,18 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertIsDisplayed() + onNodeWithTag("qr_import").assertIsDisplayed() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_hidesNfcAndQr_whenNotSupported() { + fun importFab_hidesNfcAndQr_whenNotSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides false, LocalNfcScannerSupported provides false, @@ -69,41 +67,41 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertDoesNotExist() + onNodeWithTag("qr_import").assertDoesNotExist() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_showsUrlDialog_whenUrlItemClicked() { + fun importFab_showsUrlDialog_whenUrlItemClicked() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } + setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("url_import").performClick() + onNodeWithTag(testTag).performClick() + 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() { + fun importFab_showsShareChannels_whenCallbackProvided() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed() + onNodeWithTag(testTag).performClick() + onNodeWithTag("share_channels").assertIsDisplayed() } @Test - fun importFab_showsSharedContactDialog_whenProvided() { + fun importFab_showsSharedContactDialog_whenProvided() = runComposeUiTest { val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1) - composeTestRule.setContent { + setContent { MeshtasticImportFAB( onImport = {}, sharedContact = contact, @@ -113,6 +111,6 @@ class ImportFabUiTest { } // Check if goddess is here - composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() + onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() } } 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 d221aeb39..db0560e90 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,4 +68,27 @@ 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/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt similarity index 61% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index d2a13ff38..2090736b1 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -18,22 +18,21 @@ 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.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue +@OptIn(ExperimentalTestApi::class) class AlertManagerUiTest { - @get:Rule val composeTestRule = createComposeRule() - - private val alertManager = AlertManager() - @Test - fun alertManager_showsAlert_whenRequested() { - composeTestRule.setContent { + fun alertManager_showsAlert_whenRequested() = runComposeUiTest { + val alertManager = AlertManager() + setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } @@ -43,29 +42,24 @@ class AlertManagerUiTest { alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } @Test - fun alertManager_confirmButton_triggersCallbackAndDismisses() { + fun alertManager_confirmButton_triggersCallbackAndDismisses() = runComposeUiTest { + val alertManager = AlertManager() var confirmClicked = false - composeTestRule.setContent { + 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 }) - composeTestRule.onNodeWithText("Yes").performClick() + onNodeWithText("Yes").performClick() - assert(confirmClicked) - composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist() + assertTrue(confirmClicked) + onNodeWithText("Confirm Title").assertDoesNotExist() } } diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 590bd1fe9..ebe791f8e 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLinkStyles import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri actual fun createClipEntry(text: String, label: String): ClipEntry = throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") @@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> } @Composable @@ -57,4 +56,13 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} +@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +@Composable actual fun isGpsDisabled(): Boolean = false + @Composable actual fun SetScreenBrightness(brightness: Float) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index 22f84b217..165262170 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ui.component import androidx.compose.runtime.Composable +import org.meshtastic.core.common.util.nowMillis -/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */ -@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis() +/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */ +@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 0e06fc398..a938f92ea 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,11 +20,10 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.ioDispatcher import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame @@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> /** JVM — Opens a native file dialog to save a file. */ @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ -> val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE) dialog.file = defaultFilename @@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher( val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(MeshtasticUri(path.toURI().toString())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } @@ -83,14 +82,14 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(CommonUri(path.toURI())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } /** JVM — Reads text from a file URI. */ @Composable -actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { uri, maxChars -> - withContext(Dispatchers.IO) { +actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> + withContext(ioDispatcher) { @Suppress("TooGenericExceptionCaught") try { val file = File(URI(uri.toString())) @@ -130,3 +129,19 @@ actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () @Composable actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } + +/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */ +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() } + +/** JVM no-op — Desktop does not require runtime notification permissions. */ +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { + onGranted() +} + +/** JVM — location permission is always considered granted on Desktop. */ +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */ +@Composable actual fun isGpsDisabled(): Boolean = false diff --git a/desktop/README.md b/desktop/README.md index 129f49e94..975cd59e2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,14 +25,18 @@ A Compose Desktop application target — the first full non-Android target for t ## ProGuard / Minification -Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. +Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. Rules are aligned with the Android R8 rules in `app/proguard-rules.pro` — both targets share the same anti-class-merging philosophy. **Configuration:** - `build.gradle.kts` — `buildTypes.release.proguard` block enables ProGuard with `optimize.set(true)` and `obfuscate.set(false)`. -- `proguard-rules.pro` — Comprehensive keep-rules for all reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources). +- `proguard-rules.pro` — Keep-rules for reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP `androidx.room3`, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources) and an anti-merge rule for Compose animation classes. + +**Key rules:** +- **Compose animation anti-merge** (`-keep class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from incorrectly tree-shaking or merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. +- **Room KMP** — Uses `androidx.room3` package path (Room KMP 3.x). **Troubleshooting ProGuard issues:** -- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro`. +- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro` **and** the corresponding rule in `app/proguard-rules.pro` to keep both targets aligned. - To debug which classes ProGuard removes, temporarily add `-printusage proguard-usage.txt` to the rules file and inspect the output in `desktop/proguard-usage.txt`. - To see the full mapping of optimizations applied, add `-printseeds proguard-seeds.txt`. - Run `./gradlew :desktop:runRelease` for a quick smoke-test of the minified app before packaging. diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index f1976bc11..58caf800b 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -20,6 +20,8 @@ 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) @@ -32,6 +34,71 @@ 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)) @@ -60,7 +127,10 @@ compose.desktop { isEnabled.set(true) obfuscate.set(false) // Open-source project — obfuscation adds no value optimize.set(true) - configurationFiles.from(project.file("proguard-rules.pro")) + configurationFiles.from( + rootProject.file("config/proguard/shared-rules.pro"), + project.file("proguard-rules.pro"), + ) } nativeDistributions { @@ -70,6 +140,7 @@ 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 @@ -90,11 +161,27 @@ 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() } @@ -125,14 +212,9 @@ compose.desktop { else -> targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) } - // 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" + // 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" packageVersion = sanitizedVersion description = "Meshtastic Desktop Application" @@ -182,8 +264,8 @@ dependencies { // Compose Desktop implementation(compose.desktop.currentOs) + implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) - implementation(libs.compose.multiplatform.materialIconsExtended) implementation(libs.compose.multiplatform.runtime) implementation(libs.compose.multiplatform.foundation) implementation(libs.compose.multiplatform.resources) @@ -212,6 +294,7 @@ 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) @@ -228,16 +311,16 @@ dependencies { } aboutLibraries { - // Fetch full license text + funding info from GitHub API when on CI with a token - val isCi = - providers - .gradleProperty("ci") - .map { it.toBoolean() } - .getOrElse(providers.environmentVariable("CI").map { it.toBoolean() }.getOrElse(false)) + // Run offline by default to avoid burning GitHub API calls on every build. + // Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info. + val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false) val ghToken = providers.environmentVariable("GITHUB_TOKEN") + + offlineMode = !isReleaseBuild + collect { - fetchRemoteLicense = isCi && ghToken.isPresent - fetchRemoteFunding = isCi && ghToken.isPresent + fetchRemoteLicense = isReleaseBuild && ghToken.isPresent + fetchRemoteFunding = isReleaseBuild && ghToken.isPresent if (ghToken.isPresent) { gitHubApiToken = ghToken.get() } diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist new file mode 100644 index 000000000..f799a66e9 --- /dev/null +++ b/desktop/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.bluetooth + + + diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index a73c347d1..280214b2e 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -4,201 +4,57 @@ # Open-source project: we rely on tree-shaking (unused code removal) for size # reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). # -# Key libraries requiring keep-rules (reflection, JNI, code generation): -# Koin (DI via reflection), kotlinx-serialization (generated serializers), -# Wire protobuf (ADAPTER reflection), Room KMP (generated DB + converters), -# Ktor (Java engine + ServiceLoader), Kable BLE, Coil, Compose Multiplatform -# resources, SQLite bundled (JNI), AboutLibraries. +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by this module's +# build.gradle.kts. This file holds only desktop/JVM-specific rules. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful stack traces --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions - # Suppress notes about duplicate resource files (common in fat JARs) -dontnote ** +# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even +# when the target classes are preserved by -keep rules. The result is that the +# Compose recomposer/frame-clock/animation state machines silently freeze on +# their first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. The desktop compose +# build sets optimize.set(true), so this applies here as well as to R8. See #5146. +-dontoptimize + # Do not parse/rewrite Kotlin metadata during shrinking/optimization. # ProGuard's KotlinShrinker cannot handle the metadata produced by Compose # Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. # Since we disable obfuscation (class names remain stable), metadata references # stay valid and do not need rewriting. The annotations themselves are preserved # by -keepattributes *Annotation*. +# +# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not +# recognize it, which is why it lives in the desktop-only file. -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ -keep class org.meshtastic.desktop.MainKt { *; } -# ---- Kotlin / Coroutines --------------------------------------------------- +# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- +# io.ktor.client.engine.java ships consumer rules; the shared +# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the +# reflective discovery path. -# Keep Kotlin metadata for reflection-dependent libraries --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } - -# Coroutines internals --dontwarn kotlinx.coroutines.** --keep class kotlinx.coroutines.** { *; } --keep class kotlin.coroutines.Continuation { *; } - -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Koin core — uses reflection to instantiate definitions --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep all Koin-annotated @Module / @ComponentScan classes and their generated -# counterparts so Koin K2 plugin output survives tree-shaking. --keep @org.koin.core.annotation.Module class * { *; } --keep @org.koin.core.annotation.ComponentScan class * { *; } --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (K2 plugin output) --keep class org.meshtastic.**.di.** { *; } - -# ---- kotlinx-serialization -------------------------------------------------- - -# The serialization plugin generates companion $serializer classes and -# serializer() factory methods that are invoked reflectively. --keepattributes RuntimeVisibleAnnotations --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated serializers --keepclassmembers @kotlinx.serialization.Serializable class ** { - # Companion object that holds the serializer() factory - static ** Companion; - kotlinx.serialization.KSerializer serializer(...); -} --keepclassmembers class **.$serializer { *; } --keep class **.$serializer { *; } --keepclasseswithmembers class ** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ---- Wire protobuf ---------------------------------------------------------- - -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# All generated proto message classes --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } - -# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs) --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** - -# ---- Room KMP --------------------------------------------------------------- - -# Preserve generated database constructors (required for Room's reflective init) --keep class * extends androidx.room3.RoomDatabase { (); } --keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor --keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } --keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } - -# ---- SQLite bundled (JNI) --------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** - -# ---- Ktor (Java engine + ServiceLoader + content negotiation) --------------- - -# Ktor uses ServiceLoader and reflection for engine/plugin discovery --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# Java HTTP client engine --keep class io.ktor.client.engine.java.** { *; } - -# ---- Coil (image loading) --------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** - -# ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** - -# ---- Compose Multiplatform resources ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.) --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } - -# ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** - -# ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging ---------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** - -# ---- Okio ------------------------------------------------------------------- - --dontwarn okio.** --keep class okio.** { *; } - -# ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** - -# ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** - -# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) ------------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** - -# ---- Meshtastic application code -------------------------------------------- +# ---- Meshtastic desktop host shell ------------------------------------------ # Keep all desktop module classes (thin host shell — not worth tree-shaking) -keep class org.meshtastic.desktop.** { *; } -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } - # ---- JVM runtime suppression ------------------------------------------------ -dontwarn java.lang.reflect.** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 86b1fb4db..e3c7f8b19 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -16,22 +16,32 @@ */ package org.meshtastic.desktop +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.koin.core.annotation.Single import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.NotificationPrefs import androidx.compose.ui.window.Notification as ComposeNotification -@Single +/** + * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications. + * + * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user + * preferences for message, node-event, and low-battery categories. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. + */ class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { init { - co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" } + Logger.i { "DesktopNotificationManager initialized" } } private val _notifications = MutableSharedFlow(extraBufferCapacity = 10) + + /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */ val notifications: SharedFlow = _notifications.asSharedFlow() override fun dispatch(notification: Notification) { @@ -44,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific Notification.Category.Service -> true } - co.touchlab.kermit.Logger.d { - "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" - } + Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" } if (!enabled) return @@ -59,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific } val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType)) - co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } + Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } } override fun cancel(id: Int) { - // Desktop Tray notifications cannot be cancelled once sent via TrayState + // Desktop tray notifications cannot be cancelled once sent via TrayState. } override fun cancelAll() { - // Desktop Tray notifications cannot be cleared once sent via TrayState + // Desktop tray notifications cannot be cleared once sent via TrayState. } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index bc0d3a144..026f0a100 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -18,7 +18,6 @@ package org.meshtastic.desktop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState @@ -52,20 +51,30 @@ import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder +import coil3.util.DebugLogger import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath -import org.jetbrains.skia.Image +import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.core.context.startKoin -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.MultiBackstack +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_tray_quit +import org.meshtastic.core.resources.desktop_tray_show +import org.meshtastic.core.resources.desktop_tray_tooltip import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -75,33 +84,51 @@ import org.meshtastic.desktop.di.desktopPlatformModule import org.meshtastic.desktop.ui.DesktopMainScreen import java.awt.Desktop import java.util.Locale +import coil3.util.Logger as CoilLogger /** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */ -private val LocalAppLocale = staticCompositionLocalOf { "" } - private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB +/** + * Loads an SVG from JVM classpath resources and returns a [Painter]. + * + * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair. + * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and + * raster images — not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe. + */ @Composable -private fun classpathPainterResource(path: String): Painter { - val bitmap: ImageBitmap = - remember(path) { - val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes() - Image.makeFromEncoded(bytes).toComposeImageBitmap() +private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) { + val classLoader = + requireNotNull(Thread.currentThread().contextClassLoader) { + "Missing context class loader while loading resource: $path" } - return remember(bitmap) { BitmapPainter(bitmap) } + val bytes = + requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" } + .use { it.readAllBytes() } + bytes.decodeToSvgPainter(density) } -@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application(exitProcessOnExit = false) { - Logger.i { "Meshtastic Desktop — Starting" } - - val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } + val koinApp = remember { + Logger.i { "Meshtastic Desktop — Starting" } + startKoin { modules(desktopPlatformModule(), desktopModule()) } + } val systemLocale = remember { Locale.getDefault() } val uiViewModel = remember { koinApp.koin.get() } val httpClient = remember { koinApp.koin.get() } + DeepLinkHandler(args, uiViewModel) + MeshServiceLifecycle() + ThemeAndLocaleProvider(uiViewModel) +} + +// ----- Deep link handling ----- + +/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */ +@Composable +private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) { LaunchedEffect(args) { args.forEach { arg -> if ( @@ -109,7 +136,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { arg.startsWith("http://meshtastic.org") || arg.startsWith("https://meshtastic.org") ) { - uiViewModel.handleDeepLink(MeshtasticUri(arg)) { + uiViewModel.handleDeepLink(CommonUri.parse(arg)) { Logger.e { "Invalid Meshtastic URI passed via args: $arg" } } } @@ -120,21 +147,36 @@ fun main(args: Array) = application(exitProcessOnExit = false) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) { Desktop.getDesktop().setOpenURIHandler { event -> val uriStr = event.uri.toString() - uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } + uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } } } } +} - val meshServiceController = remember { koinApp.koin.get() } +// ----- Mesh service lifecycle ----- + +/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */ +@Composable +private fun MeshServiceLifecycle() { + val meshServiceController = koinInject() DisposableEffect(Unit) { meshServiceController.start() onDispose { meshServiceController.stop() } } +} - val uiPrefs = remember { koinApp.koin.get() } +// ----- Theme, locale, and application shell ----- + +/** Resolves the user's theme/locale preferences and renders the full application UI. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { + val systemLocale = remember { Locale.getDefault() } + val uiPrefs = koinInject() val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - + val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0) + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = @@ -144,25 +186,63 @@ fun main(args: Array) = application(exitProcessOnExit = false) { else -> isSystemInDarkTheme() } + MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel) +} + +// ----- Application chrome (tray, window, navigation) ----- + +/** Composes the system tray, window, and Coil image loader. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticDesktopApp( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, +) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() - val appIcon = classpathPainterResource("icon.png") + val density = LocalDensity.current + val appIcon = svgPainterResource("tray_icon_black.svg", density) - @Suppress("DEPRECATION") val trayIcon = - androidx.compose.ui.res.painterResource( - if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", - ) + svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density) - val notificationManager = remember { koinApp.koin.get() } - val desktopPrefs = remember { koinApp.koin.get() } + val notificationManager = koinInject() + val desktopPrefs = koinInject() val windowState = rememberWindowState() LaunchedEffect(Unit) { notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } } + WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true } + + Tray( + state = trayState, + icon = trayIcon, + tooltip = stringResource(Res.string.desktop_tray_tooltip), + onAction = { isAppVisible = true }, + menu = { + Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true }) + Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication) + }, + ) + + if (isWindowReady && isAppVisible) { + MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false } + } +} + +// ----- Window bounds persistence ----- + +/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */ +@Composable +private fun WindowBoundsManager( + desktopPrefs: DesktopPreferencesDataSource, + windowState: WindowState, + onReady: () -> Unit, +) { LaunchedEffect(Unit) { val initialWidth = desktopPrefs.windowWidth.first() val initialHeight = desktopPrefs.windowHeight.first() @@ -177,7 +257,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { WindowPosition(Alignment.Center) } - isWindowReady = true + onReady() snapshotFlow { val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN @@ -188,86 +268,107 @@ fun main(args: Array) = application(exitProcessOnExit = false) { desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3]) } } +} - Tray( - state = trayState, - icon = trayIcon, - tooltip = "Meshtastic Desktop", - onAction = { isAppVisible = true }, - menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item("Quit", onClick = ::exitApplication) - }, - ) +// ----- Main window with keyboard shortcuts and Coil ----- - if (isWindowReady && isAppVisible) { - val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - val backStack = multiBackstack.activeBackStack +/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticWindow( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, + appIcon: Painter, + windowState: WindowState, + onCloseRequest: () -> Unit, +) { + val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - Window( - onCloseRequest = { isAppVisible = false }, - title = "Meshtastic Desktop", - icon = appIcon, - state = windowState, - onPreviewKeyEvent = { event -> - if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false - when { - event.key == Key.Q -> { - exitApplication() - true - } - event.key == Key.Comma -> { - if ( - TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) - ) { - multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) - } - true - } - event.key == Key.One -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) - true - } - event.key == Key.Two -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) - true - } - event.key == Key.Three -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) - true - } - event.key == Key.Four -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) - true - } - event.key == Key.Slash -> { - backStack.add(SettingsRoutes.About) - true - } - else -> false - } - }, - ) { - setSingletonImageLoaderFactory { context -> - val cacheDir = desktopDataDir() + "/image_cache_v3" - ImageLoader.Builder(context) - .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) - // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts - // that show up as solid/black hardware images. - add(SvgDecoder.Factory(renderToBitmap = true)) - } - .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } - .diskCache { - DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() - } - .crossfade(true) - .build() - } - - CompositionLocalProvider(LocalAppLocale provides localePref) { - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } - } + Window( + onCloseRequest = onCloseRequest, + title = "Meshtastic Desktop", + icon = appIcon, + state = windowState, + onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, + ) { + CoilImageLoaderSetup() + AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) { + DesktopMainScreen(uiViewModel, multiBackstack) } } } + +/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun CoilImageLoaderSetup() { + val httpClient = koinInject() + val buildConfigProvider = koinInject() + + setSingletonImageLoaderFactory { context -> + val cacheDir = desktopDataDir() + "/image_cache_v3" + ImageLoader.Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = httpClient, + concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), + ), + ) + // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts + // that show up as solid/black hardware images. + add(SvgDecoder.Factory(renderToBitmap = true)) + } + .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } + .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() } + .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null) + .crossfade(true) + .build() + } +} + +// ----- Keyboard shortcuts ----- + +/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */ +private fun handleKeyboardShortcut( + event: androidx.compose.ui.input.key.KeyEvent, + multiBackstack: MultiBackstack, + exitApplication: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false + val backStack = multiBackstack.activeBackStack + return when (event.key) { + Key.Q -> { + exitApplication() + true + } + Key.Comma -> { + if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) { + multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) + } + true + } + Key.One -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) + true + } + Key.Two -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) + true + } + Key.Three -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) + true + } + Key.Four -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) + true + } + Key.Slash -> { + backStack.add(SettingsRoute.About) + true + } + else -> false + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt index 9af34f28d..6dd562bd4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt @@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -30,16 +29,21 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers -const val KEY_WINDOW_WIDTH = "window_width" -const val KEY_WINDOW_HEIGHT = "window_height" -const val KEY_WINDOW_X = "window_x" -const val KEY_WINDOW_Y = "window_y" - +/** + * Persists and restores desktop window geometry (position and size) across application restarts. + * + * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via + * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption. + */ @Single -class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) { +class DesktopPreferencesDataSource( + @Named("CorePreferencesDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f) val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f) @@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va ): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default) companion object { - val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH) - val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT) - val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X) - val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y) + val WINDOW_WIDTH = floatPreferencesKey("window_width") + val WINDOW_HEIGHT = floatPreferencesKey("window_height") + val WINDOW_X = floatPreferencesKey("window_x") + val WINDOW_Y = floatPreferencesKey("window_y") } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt index 0bb5311aa..d27f6d5d9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt @@ -19,6 +19,10 @@ package org.meshtastic.desktop.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +/** + * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`, + * `@KoinViewModel`). + */ @Module @ComponentScan("org.meshtastic.desktop") class DesktopDiModule diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index b4b47736e..8ac634112 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,12 +14,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress( + "ktlint:standard:no-unused-imports", +) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule() + package org.meshtastic.desktop.di // Generated Koin module extensions from core KMP modules import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.koin.dsl.module @@ -30,17 +40,28 @@ 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 @@ -52,6 +73,9 @@ 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 @@ -121,7 +145,7 @@ fun desktopModule() = module { */ @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { - single { org.meshtastic.core.service.ServiceRepositoryImpl() } + single { ServiceRepositoryImpl() } single { DesktopRadioTransportFactory( dispatchers = get(), @@ -131,7 +155,7 @@ private fun desktopPlatformStubsModule() = module { ) } single { - org.meshtastic.core.service.DirectRadioControllerImpl( + DirectRadioControllerImpl( serviceRepository = get(), nodeRepository = get(), commandSender = get(), @@ -141,29 +165,46 @@ private fun desktopPlatformStubsModule() = module { locationManager = get(), ) } - single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) } - single { - get() - } - single { - org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) - } + single { DesktopNotificationManager(prefs = get()) } + single { get() } + single { DesktopMeshServiceNotifications(notificationManager = get()) } single { NoopPlatformAnalytics() } single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } - single { - org.meshtastic.desktop.radio.DesktopMessageQueue(packetRepository = get(), radioController = get()) - } + single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = 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()) } // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) - single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } } } + 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 + } + } + } + } // 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 e2fe40da4..743c2065d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okio.FileSystem import okio.Path.Companion.toPath @@ -35,10 +34,13 @@ 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 @@ -48,10 +50,10 @@ import org.meshtastic.proto.LocalStats private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore { val dir = desktopDataDir() + "/datastore" FileSystem.SYSTEM.createDirectories(dir.toPath()) - return PreferenceDataStoreFactory.create( + return PreferenceDataStoreFactory.createWithPath( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), scope = scope, - produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() }, + produceFile = { "$dir/$name.preferences_pb".toPath() }, ) } @@ -79,26 +81,25 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner { * - [Lifecycle] (`ProcessLifecycle`) * - [BuildConfigProvider] */ -@Suppress("InjectDispatcher") fun desktopPlatformModule() = module { // Application-lifetime scope shared by all DataStore instances. Per the DataStore docs: // "The Job within this context dictates the lifecycle of the DataStore's internal operations. // Ensure it is an application-scoped context that is not canceled by UI lifecycle events." // DataStore has no close() API — the in-memory cache is released only when this Job is cancelled // (at process exit). Using SupervisorJob so a single store's failure doesn't cascade. - val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + single(named(DATASTORE_SCOPE)) { CoroutineScope(get().io + SupervisorJob()) } - includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope)) + includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule()) - // -- Build config -- + // -- Build config (values generated at build time by generateDesktopBuildConfig) -- single { object : BuildConfigProvider { - 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" + 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 } } @@ -107,30 +108,50 @@ fun desktopPlatformModule() = module { } /** Named [DataStore]<[Preferences]> instances for all preference domains. */ -private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module { - single>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) } +private fun desktopPreferencesDataStoreModule() = module { + single>(named("AnalyticsDataStore")) { + createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE))) + } single>(named("HomoglyphEncodingDataStore")) { - createPreferencesDataStore("homoglyph_encoding", scope) + createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE))) + } + single>(named("AppDataStore")) { + createPreferencesDataStore("app", get(named(DATASTORE_SCOPE))) + } + single>(named("CustomEmojiDataStore")) { + createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE))) + } + single>(named("MapDataStore")) { + createPreferencesDataStore("map", get(named(DATASTORE_SCOPE))) + } + single>(named("MapConsentDataStore")) { + createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE))) } - single>(named("AppDataStore")) { createPreferencesDataStore("app", scope) } - single>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) } - single>(named("MapDataStore")) { createPreferencesDataStore("map", scope) } - single>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) } single>(named("MapTileProviderDataStore")) { - createPreferencesDataStore("map_tile_provider", scope) + createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshDataStore")) { + createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE))) + } + single>(named("RadioDataStore")) { + createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE))) + } + single>(named("UiDataStore")) { + createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshLogDataStore")) { + createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE))) + } + single>(named("FilterDataStore")) { + createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE))) } - single>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) } - single>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) } - single>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) } - single>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) } - single>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) } single>(named("CorePreferencesDataStore")) { - createPreferencesDataStore("core_preferences", scope) + createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE))) } } /** Proto [DataStore] instances (OkioStorage-backed). */ -private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { +private fun desktopProtoDataStoreModule() = module { val protoDir = desktopDataDir() + "/datastore" single>(named("CoreLocalConfigDataStore")) { @@ -142,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -155,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/module_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -168,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/channel_set.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -181,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_stats.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index f30ecb66b..594a62bc4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph @@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph /** - * Registers entry providers for all top-level desktop destinations. + * Registers [NavKey] entry providers for every desktop destination. * - * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from - * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via - * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their - * shared composables are wired. + * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping + * the desktop shell free of screen-level composable knowledge. */ -fun EntryProviderScope.desktopNavGraph( - backStack: NavBackStack, - uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel, -) { - // Nodes — real composables from feature:node +fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) { nodesGraph( backStack = backStack, scrollToTopEvents = uiViewModel.scrollToTopEventFlow, onHandleDeepLink = uiViewModel::handleDeepLink, ) - - // Conversations — real composables from feature:messaging contactsGraph(backStack, uiViewModel.scrollToTopEventFlow) - - // Map — placeholder for now, will be replaced with feature:map real implementation mapGraph(backStack) - - // Firmware — in-flow destination (for example from Settings), not a top-level rail tab firmwareGraph(backStack) - - // Settings — real composables from feature:settings settingsGraph(backStack) - - // Channels channelsGraph(backStack) - - // Connections — shared screen connectionsGraph(backStack) - - // WiFi Provisioning — nymea-networkmanager BLE protocol wifiProvisionGraph(backStack) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 36648d54d..4cda00251 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -16,12 +16,13 @@ */ package org.meshtastic.desktop.notification -import org.koin.core.annotation.Single +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_notification_title import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title @@ -29,7 +30,17 @@ import org.meshtastic.core.resources.new_node_seen import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry -@Single +/** + * Desktop implementation of [MeshServiceNotifications]. + * + * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through + * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications. + * + * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. + */ @Suppress("TooManyFunctions") class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { override fun clearNotifications() { @@ -37,15 +48,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat } override fun initChannels() { - // no-op for desktop + // No-op: desktop has no Android notification channels. } - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ): Any { - // We don't have a foreground service on desktop - return Unit + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { + // No-op: desktop has no foreground service notification. } override suspend fun updateMessageNotification( @@ -105,16 +112,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat ) } - @Suppress("ktlint:standard:max-line-length") override fun showAlertNotification(contactKey: String, name: String, alert: String) { - notificationManager.dispatch( - Notification( - title = name, - message = alert, - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) + val notification = + Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey) + notificationManager.dispatch(notification) } override fun showNewNodeSeenNotification(node: Node) { @@ -141,7 +142,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun showClientNotification(clientNotification: ClientNotification) { notificationManager.dispatch( Notification( - title = "Meshtastic", + title = getString(Res.string.desktop_notification_title), message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index f69d103cc..3888b0af3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -18,8 +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 @@ -35,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository class DesktopMessageQueue( private val packetRepository: PacketRepository, private val radioController: RadioController, + dispatchers: CoroutineDispatchers, ) : MessageQueue { - private val scope = CoroutineScope(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 484e2294e..ffaa0553b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.desktop.radio -import org.koin.core.annotation.Single import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BluetoothRepository @@ -25,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.TCPInterface +import org.meshtastic.core.network.radio.TcpRadioTransport import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory @@ -33,8 +32,10 @@ import org.meshtastic.core.repository.RadioTransportFactory /** * Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing * platform-specific transports (USB/Serial) via jSerialComm. + * + * Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with + * the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. */ -@Single(binds = [RadioTransportFactory::class]) class DesktopRadioTransportFactory( scanner: BleScanner, bluetoothRepository: BluetoothRepository, @@ -44,16 +45,23 @@ class DesktopRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB) - override fun isMockInterface(): Boolean = false + override fun isMockTransport(): Boolean = false override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when { address.startsWith(InterfaceId.TCP.id) -> { - TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString())) + TcpRadioTransport( + callback = service, + scope = service.serviceScope, + dispatchers = dispatchers, + address = address.removePrefix(InterfaceId.TCP.id.toString()), + ) } address.startsWith(InterfaceId.SERIAL.id) -> { SerialTransport.open( portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), - service = service, + callback = service, + scope = service.serviceScope, + dispatchers = dispatchers, ) } else -> error("Unsupported transport for address: $address") diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt index 5e223ed67..b0761522d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.feature.node.compass.PhoneLocationState +/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */ class NoopCompassHeadingProvider : CompassHeadingProvider { override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false)) } +/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */ class NoopPhoneLocationProvider : PhoneLocationProvider { override fun locationUpdates(): Flow = flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false)) } +/** No-op [MagneticFieldProvider] — always returns zero declination. */ class NoopMagneticFieldProvider : MagneticFieldProvider { override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index adaea22f0..707dfaf03 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,12 +20,15 @@ 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 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 @@ -36,15 +39,12 @@ 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.MessageQueue 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.proto.Telemetry +import org.meshtastic.mqtt.ConnectionState as MqttConnectionState 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 isMockInterface(): Boolean = false + override fun isMockTransport(): Boolean = false override val receivedData = MutableSharedFlow() override val meshActivity = MutableSharedFlow() @@ -81,6 +81,10 @@ class NoopRadioInterfaceService : RadioInterfaceService { logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)") } + override fun resetReceivedBuffer() { + // No-op: this stub never buffers bytes. + } + override fun connect() { logWarn("NoopRadioInterfaceService.connect()") } @@ -98,66 +102,13 @@ class NoopRadioInterfaceService : RadioInterfaceService { override fun handleFromRadio(bytes: ByteArray) {} @Suppress("InjectDispatcher") - override val serviceScope: CoroutineScope - get() = CoroutineScope(kotlinx.coroutines.Dispatchers.Default) + override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + 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) {} @@ -190,10 +141,6 @@ class NoopMeshWorkerManager : MeshWorkerManager { override fun enqueueSendMessage(packetId: Int) {} } -class NoopMessageQueue : MessageQueue { - override suspend fun enqueue(packetId: Int) {} -} - class NoopMeshLocationManager : MeshLocationManager { override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {} @@ -216,6 +163,8 @@ class NoopMQTTRepository : MQTTRepository { override val proxyMessageFlow: Flow = emptyFlow() override fun publish(topic: String, data: ByteArray, retained: Boolean) {} + + override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle) } // endregion diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 00b2e82c7..a55bf902f 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph -/** Desktop main screen — uses shared navigation components. */ +/** + * Desktop main screen — assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and + * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider. + */ @Composable fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) { val backStack = multiBackstack.activeBackStack diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt index 01fec03b2..d14c2fe98 100644 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt @@ -16,13 +16,13 @@ */ package org.meshtastic.desktop.ui -import org.meshtastic.core.navigation.ConnectionsRoutes -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.core.navigation.FirmwareRoutes -import org.meshtastic.core.navigation.MapRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.ConnectionsRoute +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.FirmwareRoute +import org.meshtastic.core.navigation.MapRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import kotlin.reflect.KClass import kotlin.test.Test @@ -41,11 +41,11 @@ class DesktopTopLevelDestinationParityTest { val androidParityRoutes: Set> = setOf( - ContactsRoutes.ContactsGraph::class, - NodesRoutes.NodesGraph::class, - MapRoutes.Map::class, - SettingsRoutes.SettingsGraph::class, - ConnectionsRoutes.ConnectionsGraph::class, + ContactsRoute.ContactsGraph::class, + NodesRoute.NodesGraph::class, + MapRoute.Map::class, + SettingsRoute.SettingsGraph::class, + ConnectionsRoute.ConnectionsGraph::class, ) assertEquals( @@ -60,7 +60,7 @@ class DesktopTopLevelDestinationParityTest { val desktopRoutes: Set> = TopLevelDestination.entries.map { it.route::class }.toSet() assertFalse( - actual = desktopRoutes.contains(FirmwareRoutes.FirmwareGraph::class), + actual = desktopRoutes.contains(FirmwareRoute.FirmwareGraph::class), message = "Firmware must stay in-flow and not appear in the desktop top-level rail", ) } diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md index 17b152f4a..d3dd5ad93 100644 --- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md +++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md @@ -121,35 +121,25 @@ kotlin { ``` **What the plugin provides automatically:** -- `commonMain`: `compose-multiplatform-material3`, `compose-multiplatform-materialIconsExtended`, `jetbrains-lifecycle-viewmodel-compose`, `koin-compose-viewmodel`, `kermit` -- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-material-iconsExtended`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` +- `commonMain`: `compose-multiplatform-material3`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-runtime-compose`, `koin-compose-viewmodel`, `kermit` +- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` - `commonTest`: `core:testing` **Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin`). ### Example: Adding Android-specific test config -**Pattern:** Add to `AndroidLibraryConventionPlugin.kt`: +**Pattern:** Test options (`animationsDisabled`, `testInstrumentationRunner`, `unitTests.isReturnDefaultValues`) are centralized in `configureKotlinAndroid()` via `CommonExtension`, so they apply to both app and library modules automatically. To add new test config, update `KotlinAndroid.kt::configureKotlinAndroid()`: ```kotlin -extensions.configure { - configureKotlinAndroid(this) - testOptions.apply { - animationsDisabled = true - // NEW: Android-specific test config - unitTests.isIncludeAndroidResources = true - } -} -``` - -**Alternative:** If it applies to both app and library, consider extracting a function: - -```kotlin -internal fun Project.configureAndroidTestOptions() { - extensions.configure { - testOptions.apply { +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension<*, *, *, *, *, *>, +) { + commonExtension.apply { + testOptions { animationsDisabled = true - // Shared test options + unitTests.isReturnDefaultValues = true + // NEW: Add shared test options here } } } @@ -177,6 +167,8 @@ internal fun Project.configureAndroidTestOptions() { | `AndroidApplicationFlavorsConventionPlugin` ≈ `AndroidLibraryFlavorsConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent | | `configureKmpTestDependencies()` (7 modules) | **Consolidated** | Large duplication; single source of truth; all KMP modules benefit | | `jvmAndroidMain` hierarchy setup (4 modules) | **Consolidated** | Shared KMP hierarchy pattern; avoids manual `dependsOn(...)` edges and hierarchy warnings | +| `PUBLISHED_MODULES` set (4 usages) | **Consolidated** | Was repeated as `listOf(...)` in 4 places; now a single `setOf(...)` constant in `KotlinAndroid.kt` | +| `SHARED_COMPILER_ARGS` list (2 code paths) | **Consolidated** | Eliminates duplicated `-opt-in` flags between KMP target compilations and `KotlinCompile` task configuration | ## Testing Convention Changes diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md deleted file mode 100644 index 5d25a5509..000000000 --- a/docs/agent-playbooks/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 550fd2079..000000000 --- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md +++ /dev/null @@ -1,58 +0,0 @@ -# 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 deleted file mode 100644 index e5e11da0b..000000000 --- a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.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 deleted file mode 100644 index 4a32623fb..000000000 --- a/docs/agent-playbooks/task-playbooks.md +++ /dev/null @@ -1,109 +0,0 @@ -# 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` | -| 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: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.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 deleted file mode 100644 index a7f0796df..000000000 --- a/docs/agent-playbooks/testing-and-ci-playbook.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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 deleted file mode 100644 index e8916d8a3..000000000 --- a/docs/decisions/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Decision Records - -Architectural decision records and reviews. Each captures context, decision, and consequences. - -| Decision | File | Status | -|---|---|---| -| Architecture review (March 2026) | [`architecture-review-2026-03.md`](./architecture-review-2026-03.md) | Active | -| Navigation 3 parity strategy (Android + Desktop) | [`navigation3-parity-2026-03.md`](./navigation3-parity-2026-03.md) | Active | -| Navigation 3 API alignment audit | [`navigation3-api-alignment-2026-03.md`](./navigation3-api-alignment-2026-03.md) | Active | -| BLE KMP strategy (Kable) | [`ble-strategy.md`](./ble-strategy.md) | Decided | -| Hilt → Koin migration | [`koin-migration.md`](./koin-migration.md) | Complete | -| Testing consolidation (`core:testing`) | [`testing-consolidation-2026-03.md`](./testing-consolidation-2026-03.md) | Complete | - -For the current KMP migration status, see [`docs/kmp-status.md`](../kmp-status.md). -For the forward-looking roadmap, see [`docs/roadmap.md`](../roadmap.md). diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md deleted file mode 100644 index ce5becbb2..000000000 --- a/docs/decisions/architecture-review-2026-03.md +++ /dev/null @@ -1,255 +0,0 @@ -# Architecture Review — March 2026 - -> Status: **Active** -> Last updated: 2026-03-31 - -Re-evaluation of project modularity and architecture against modern KMP and Android best practices. Identifies gaps and actionable improvements across modularity, reusability, clean abstractions, DI, and testing. - -## Executive Summary - -The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. - -Of the five structural gaps originally identified, four are resolved and one remains in progress: - -1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 6 files: `MainActivity`, `MeshUtilApplication`, Nav shell, and DI config)* -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` | 6 | ~300 | Android app shell (target achieved) | -| `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. Originally it held **90 files / ~11K LOC**, now completely reduced to a **6-file shell**: - -| 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 deleted file mode 100644 index 6a0925152..000000000 --- a/docs/decisions/navigation3-api-alignment-2026-03.md +++ /dev/null @@ -1,124 +0,0 @@ - - -# Navigation 3 & Material 3 Adaptive — API Alignment Audit - -**Date:** 2026-03-26 -**Status:** Active -**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration. -**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated). - -## Current Dependency Baseline - -| Library | Version | Group | -|---|---|---| -| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` | -| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` | -| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` | -| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` | -| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` | -| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` | - -## API Audit: What's Available vs. What We Use - -### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`) - -**Available APIs we're NOT using:** - -| API | Purpose | Status in project | -|---|---|---| -| `sceneStrategies: List>` | Allows NavDisplay to render multi-pane Scenes | ✅ Used — `DialogSceneStrategy`, `ListDetailSceneStrategy`, `SupportingPaneSceneStrategy`, `SinglePaneSceneStrategy` | -| `SceneStrategy` interface | Custom scene calculation from backstack entries | ✅ Used via built-in strategies | -| `DialogSceneStrategy` | Renders `entry(metadata = dialog())` entries as overlay Dialogs | ✅ Adopted | -| `SceneDecoratorStrategy` | Wraps/decorates scenes with additional UI | ❌ Not used | -| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ✅ Used — `ListDetailSceneStrategy.listPane()`, `.detailPane()`, `.extraPane()` | -| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used | -| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ✅ Used — 350 ms crossfade | -| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used | -| `entryDecorators: List>` | Wraps entry content with additional behavior | ✅ Used — `SaveableStateHolderNavEntryDecorator` + `ViewModelStoreNavEntryDecorator` | - -**APIs we ARE using correctly:** - -| API | Usage | -|---|---| -| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` | -| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence | -| `entryProvider { entry { ... } }` | All feature graph registrations | -| `NavigationBackHandler` from `navigationevent-compose` | Used with `ListDetailSceneStrategy` | - -### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`) - -**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project passes it as an `entryDecorator` to `NavDisplay` via `MeshtasticNavDisplay` in `core:ui/commonMain`. - -ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and automatically cleared when the entry is popped. - -### 3. Material 3 Adaptive — Nav3 Scene Integration - -**Key finding:** The JetBrains `adaptive-navigation3` artifact at `1.3.0-alpha06` includes `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy`. The project uses both via `rememberListDetailSceneStrategy` and `rememberSupportingPaneSceneStrategy` in `MeshtasticNavDisplay`, with draggable pane dividers via `VerticalDragHandle` + `paneExpansionDraggable`. - -This means the project **successfully** uses the M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. Feature entries annotate themselves with `ListDetailSceneStrategy.listPane()`, `.detailPane()`, or `.extraPane()` metadata. - -**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set. - -### 4. NavigationSuiteScaffold (`1.11.0-alpha05`) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed. - -## Prioritized Opportunities - -### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`. - -**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time. - -### P1: Add default NavDisplay transitions (medium-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition. - -**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions. - -### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support. - -**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected. - -### Consolidation: `MeshtasticNavDisplay` shared wrapper - -**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration: -- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator` -- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy` -- Transition specs: 350 ms crossfade (forward + pop) - -Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`. - -### P3: Per-entry transition metadata (low-value until Scene adoption) - -Individual entries can declare custom transitions via `entry(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade). - -**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption. - -### Deferred: Custom Scene strategies - -The `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy` are adopted and working. Consider writing additional custom `SceneStrategy` implementations for specialized layouts (e.g., three-pane "Power User" scenes) as the Navigation 3 Scene API matures. - -## Decision - -~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~ - -**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred. - -## References - -- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache) -- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream) -- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream) -- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06` diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md deleted file mode 100644 index 1d1a8c7ed..000000000 --- a/docs/decisions/navigation3-parity-2026-03.md +++ /dev/null @@ -1,167 +0,0 @@ - - -# Navigation 3 Parity Strategy (Android + Desktop) - -**Date:** 2026-03-11 -**Status:** Implemented (2026-03-21) -**Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes - -## Context - -Desktop and Android both use Navigation 3 typed routes from `core:navigation`. Previously graph wiring had diverged — desktop used a separate `DesktopDestination` enum with 6 entries (including a top-level Firmware tab) while Android used 5 entries. - -This has been resolved. Both shells now use the shared `TopLevelDestination` enum from `core:navigation/commonMain` with 5 entries (Conversations, Nodes, Map, Settings, Connections). Firmware is an in-flow route on both platforms. - -Both modules still define separate graph-builder files (`app/navigation/*.kt`, `desktop/navigation/*.kt`) with different destination coverage and placeholder behavior, but the **top-level shell structure is unified**. - -## Current-State Findings - -1. **Top-level destinations are unified.** - - Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`. - - Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`). - - Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -2. **Feature coverage is unified via `commonMain` feature graphs.** - - The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets. - - Desktop acts as a thin shell, delegating directly to these shared graphs. -3. **Saved-state route registration is fully shared.** - - `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list. - - Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`. -4. **Predictive back handling is KMP native.** - - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. - -## Alpha04 → Beta01 Changelog Impact Check - -Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`. - -> **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan. - -1. **NavDisplay API updated to Scene-based architecture.** - - The `sceneStrategy: SceneStrategy` parameter is deprecated in favor of `sceneStrategies: List>`. - - New `sceneDecoratorStrategies: List>` parameter available. - - New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions. - - Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`. -2. **Entry-scoped ViewModel lifecycle adopted.** - - Both `app` and `desktop` now use `MeshtasticNavDisplay` (`core:ui/commonMain`), which applies `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` per active backstack. - - ViewModels obtained via `koinViewModel()` inside `entry` blocks are now scoped to the entry's backstack lifetime. -3. **No direct Navigation 3 API breakage.** - - Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns. -4. **Primary risk is dependency wiring drift, not runtime behavior.** - - JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog. - - Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`). -5. **Saved-state and typed-route parity improved.** - - Both hosts share `MeshtasticNavSavedStateConfig` from `core:navigation/commonMain` via `MultiBackstack`, reducing platform drift risk in serializer registration. -6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).** - -### Actions Taken - -- Renamed all JetBrains-forked lifecycle/nav3 version catalog aliases from `androidx-*` to `jetbrains-*` prefix to make fork provenance unambiguous: - - `jetbrains-lifecycle-runtime`, `jetbrains-lifecycle-runtime-compose`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-viewmodel-navigation3` - - `jetbrains-navigation3-runtime`, `jetbrains-navigation3-ui` -- Documented in the version catalog that `jetbrains-navigation3-runtime` intentionally maps to `navigation3-ui` until a separate runtime artifact is published. -- Migrated `core:data` `commonMain` from `androidx.lifecycle:lifecycle-runtime` (Google) to `org.jetbrains.androidx.lifecycle:lifecycle-runtime` (JetBrains fork) for full consistency. -- Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`). -- Consolidated `app` adaptive dependencies to JetBrains Material 3 Adaptive coordinates (`org.jetbrains.compose.material3.adaptive:*`) so Android and Desktop consume the same adaptive artifact family. The Android-only navigation suite remains on `androidx.compose.material3:material3-adaptive-navigation-suite`. - -### Deferred Follow-ups - -- Add automated validation that desktop serializer registrations stay in sync with shared route keys. - -## Options Evaluated - -### Option A: Reuse `:app` navigation implementation directly in desktop - -**Pros** -- Maximum short-term parity in structure. - -**Cons** -- `:app` graph code is tightly coupled to Android wrappers (`Android*ViewModel`, Android-only screen wrappers, app-specific UI state like scroll-to-top flows). -- Pulling this code into desktop would either fail at compile-time or force additional platform branching in app files. -- Violates clean module boundaries (`desktop` should not depend on Android-specific app glue). - -**Decision:** Not recommended. - -### Option B: Keep fully separate desktop graph and replicate app behavior manually - -**Pros** -- Lowest refactor cost right now. -- Keeps platform customization simple. - -**Cons** -- Drift is guaranteed over time. -- No central policy for intentional vs accidental divergence. -- High maintenance burden for parity-sensitive flows. - -**Decision:** Not recommended as a long-term strategy. - -### Option C (Recommended): Hybrid shared contract + platform graph adapters - -**Pros** -- Preserves platform-specific wiring where needed. -- Reduces drift by moving parity-sensitive definitions to shared contracts. -- Enables explicit, testable exceptions for desktop-only or Android-only behavior. - -**Cons** -- Requires incremental extraction work. -- Needs light governance (parity matrix + tests + docs). - -**Decision:** Recommended. - -## Decision - -Adopt a **hybrid parity model**: - -1. Keep platform graph registration in `app` and `desktop`. -2. Extract parity-sensitive navigation metadata into shared contracts (top-level destination set/order, route ownership map, and allowed platform exceptions). -3. Keep platform-specific destination implementations as adapters around shared route keys. -4. Add route parity tests so drift is detected automatically. - -## Implementation Plan - -### Phase 1 (Immediate): Stop drift on shell structure ✅ - -- ✅ Aligned desktop top-level destination policy with Android (removed Firmware from top-level; kept as in-flow). -- ✅ Both shells now use shared `TopLevelDestination` enum from `core:navigation/commonMain`. -- ✅ Shared icon mapping in `core:ui` (`TopLevelDestinationExt.icon`). -- Parity matrix documented inline: top-level set is Conversations, Nodes, Map, Settings, Connections on both platforms. - -### Phase 2 (Near-term): Extract shared navigation contracts ✅ (partially) - -- ✅ Shared `TopLevelDestination` enum with `fromNavKey()` already serves as the canonical metadata object. -- Both `app` and `desktop` shells iterate `TopLevelDestination.entries` — no separate `DesktopDestination` enum remains. -- Remaining: optional visibility flags by platform, route grouping metadata (lower priority since shells are unified). - -### Phase 3 (Near-term): Add parity checks ✅ (partially) - -- ✅ `NavigationParityTest` in `core:navigation/commonTest` — asserts 5 top-level destinations and `fromNavKey` matching. -- ✅ `DesktopTopLevelDestinationParityTest` in `desktop/test` — asserts desktop routes match Android parity set and firmware is not top-level. -- Remaining: assert every desktop serializer registration corresponds to an actual route; assert every intentional exception is listed. - -### Phase 4 (Mid-term): Reduce app-specific graph coupling - -- Move reusable graph composition helpers out of `:app` where practical (while keeping Android-only wrappers in Android source sets). -- Keep desktop-specific placeholder implementations, but tie them to explicit parity exception entries. - -## Consequences - -- Navigation behavior remains platform-adaptive, but parity expectations become explicit and enforceable. -- Desktop can keep legitimate deviations (map/charts/platform integrations) without silently changing IA. -- New route additions will require touching one shared contract plus platform implementations, making review scope clearer. - -## Source Anchors - -- Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` -- Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/` -- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/` -- Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - - diff --git a/docs/kmp-status.md b/docs/kmp-status.md index c5362e479..1e6552437 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-03-31 +> Last updated: 2026-04-15 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -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`, `BleRadioInterface` | +| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioTransport` | | `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` and `BaseMapViewModel` 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:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | | `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | @@ -79,9 +79,7 @@ Working Compose Desktop application with: | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | - -> See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis. +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils | ## Completion Estimates @@ -105,18 +103,20 @@ Based on the latest codebase investigation, the following steps are proposed to | Decision | Status | Details | |---|---|---| -| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) | +| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared `TopLevelDestination` enum and `MeshtasticNavDisplay` from `core:ui/commonMain`; parity tests in `core:navigation/commonTest` | | Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) | | BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) | | Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target | -| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta01`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | +| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | -| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | +| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | -| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | +| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | +| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` | +| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) | ## Navigation Parity Note @@ -127,7 +127,7 @@ Based on the latest codebase investigation, the following steps are proposed to - Firmware remains available as an in-flow route instead of a top-level destination, matching Android information architecture. - Android navigation graphs are decoupled and extracted into their respective feature modules, aligning with the Desktop architecture. - Parity tests exist in `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -- Remaining parity work is documented in [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md): serializer registration validation and platform exception tracking. +- Remaining parity work: serializer registration validation and platform exception tracking. ## App Module Thinning Status @@ -144,35 +144,35 @@ Extracted to shared `commonMain` (no longer app-only): - `ChannelViewModel` → `feature:settings/commonMain` - `NodeMapViewModel` → `feature:map/commonMain` (Shared logic for node-specific maps) - `BaseMapViewModel` → `feature:map/commonMain` (Core contract for all maps) +- `TracerouteOverlay` → `core:model/commonMain` (Pure data class for traceroute route segments; extracted from `feature:map` for cross-module reuse) +- `GeoConstants` → `core:model/commonMain` (Centralized `DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS` constants; eliminates 7 duplicate private constants) 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 (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) +- TCP radio connections, BLE radio connections (`BleRadioTransport`), 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) +- `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) ## Prerelease Dependencies | Dependency | Version | Why | |---|---|---| -| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` | -| Koin | `4.2.0` | Nav3 + K2 compiler plugin support | -| JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | -| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | +| Compose Multiplatform | `1.11.0-beta02` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha06` | +| Compose Multiplatform Material 3 | `1.11.0-alpha06` | Material 3 components including `NavigationSuiteScaffold` | +| Koin | `4.2.1` | Nav3 + K2 compiler plugin support | +| JetBrains Lifecycle | `2.11.0-alpha03` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | +| JetBrains Navigation 3 | `1.1.0-rc01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | | JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back | | JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints | | Kable BLE | `0.42.0` | Provides fully multiplatform BLE support | **Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features. -> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan. - ## References - Roadmap: [`docs/roadmap.md`](./roadmap.md) - Agent guide: [`AGENTS.md`](../AGENTS.md) -- Playbooks: [`docs/agent-playbooks/`](./agent-playbooks/) +- Agent skills: [`.skills/`](../.skills/) - Decision records: [`docs/decisions/`](./decisions/) diff --git a/docs/roadmap.md b/docs/roadmap.md index d7412c2cc..8cff42c1f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,8 @@ # Roadmap -> Last updated: 2026-03-31 +> Last updated: 2026-04-15 -Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). +Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). ## Architecture Health (Immediate) @@ -18,6 +18,8 @@ These items address structural gaps identified in the March 2026 architecture re | Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ | | **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | | **iOS CI gate (compile-only validation)** | High | Medium | ✅ | +| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ | +| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ | ## Active Work @@ -57,7 +59,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` (`BleRadioInterface`) | +| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioTransport`) | ### Desktop Feature Gaps @@ -81,10 +83,10 @@ These items address structural gaps identified in the March 2026 architecture re 1. **Evaluate KMP-native testing tools** — ✅ **Done:** Fully evaluated and integrated `Mokkery`, `Turbine`, and `Kotest` across the KMP modules. `mockk` has been successfully replaced, enabling property-based and Flow testing in `commonTest` for iOS readiness. 2. **Desktop Map Integration** — Address the major Desktop feature gap by implementing a raster map view using [**MapComposeMP**](https://github.com/p-lr/MapComposeMP). - - Implement a `MapComposeProvider` for Desktop. + - Implement Desktop providers for the 3 decomposed map contracts: `MapViewProvider` (main map), `NodeTrackMapProvider` (per-node track overlay for `PositionLogScreen`), and `TracerouteMapProvider` (traceroute visualization). - Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane. - - Leverage the existing `BaseMapViewModel` contract. -3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. + - Leverage the existing `BaseMapViewModel` contract and `TracerouteNodeSelection` logic in `commonMain`. +3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. The `MapViewProvider` interface has been simplified (track/traceroute rendering extracted to dedicated providers), reducing the surface area of this unification. 4. **iOS CI gate** — ✅ **Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS. ## Medium-Term Priorities (60 days) diff --git a/docs/testing/baseline_coverage.md b/docs/testing/baseline_coverage.md deleted file mode 100644 index 6445ea9e5..000000000 --- a/docs/testing/baseline_coverage.md +++ /dev/null @@ -1,6 +0,0 @@ -# Baseline Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 8.796% -**App Module Coverage:** 1.6404% - -This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track. \ No newline at end of file diff --git a/docs/testing/final_coverage.md b/docs/testing/final_coverage.md deleted file mode 100644 index bc502d704..000000000 --- a/docs/testing/final_coverage.md +++ /dev/null @@ -1,18 +0,0 @@ -# Final Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 10.2591% (Baseline: 8.796%) -**Absolute Increase:** +1.46% - -## Module Highlights -| Module | Coverage | Notes | -| :--- | :--- | :--- | -| `core:domain` | 26.55% | UseCase gap fill complete. | -| `feature:intro` | 30.76% | ViewModel tests enabled. | -| `feature:map` | 33.33% | BaseMapViewModel tests refactored. | -| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. | -| `feature:connections` | 26.49% | ScannerViewModel verified. | -| `feature:messaging` | 18.54% | MessageViewModel verified. | - -This report concludes the 'Expand Testing Coverage' track. -Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration. -Foundational logic in `core:network` was strengthened with Kotest property-based tests. \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e4b607871..4fff2f870 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -38,7 +38,8 @@ platform :android do task: "assembleFdroidRelease", properties: { "android.injected.version.name" => ENV['VERSION_NAME'], - "android.injected.version.code" => ENV['VERSION_CODE'] + "android.injected.version.code" => ENV['VERSION_CODE'], + "aboutLibraries.release" => "true" } ) end @@ -50,7 +51,8 @@ platform :android do print_command: false, properties: { "android.injected.version.name" => ENV['VERSION_NAME'], - "android.injected.version.code" => ENV['VERSION_CODE'] + "android.injected.version.code" => ENV['VERSION_CODE'], + "aboutLibraries.release" => "true" } ) lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] diff --git a/fastlane/metadata/android/fr-FR/changelogs/default.txt b/fastlane/metadata/android/fr-FR/changelogs/default.txt index 0553de284..a322da020 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/default.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pour des notes de version détaillées, veuillez visiter : https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/changelogs/default.txt b/fastlane/metadata/android/ro-RO/changelogs/default.txt index 0553de284..b254b55b8 100644 --- a/fastlane/metadata/android/ro-RO/changelogs/default.txt +++ b/fastlane/metadata/android/ro-RO/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pentru note detaliate pentru versiuni, vizitați: 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 e3f0988db..f6c7d5664 100644 --- a/fastlane/metadata/android/ro-RO/short_description.txt +++ b/fastlane/metadata/android/ro-RO/short_description.txt @@ -1 +1 @@ -The official app for Meshtastic, an open-source, off-grid, mesh radio. \ No newline at end of file +Aplicația oficială pentru Meshtastic, un radio open, off-grid, mess. \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 82a914fc9..aa3c2488c 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -4,7 +4,7 @@ Meshtastic 是一款将安卓设备与开源、无互联网、基于多跳网状 社区和支持 -此项目目前处于测试阶段, 我们非常乐意听取您的建议和意见! 如果您有任何疑问,反馈或遇到任何问题,请加入我们友好和活跃的社区: +此项目目前处于测试阶段。 我们非常乐意听取您的建议和意见! 如果您有任何疑问,反馈或遇到任何问题,请加入我们友好和活跃的社区: • 论坛: https://github.com/orgs/meshtastic/discussionsDiscord: https://discord.gg/meshtastic diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 9ac1a69ba..f6fb40ae8 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -49,12 +49,5 @@ kotlin { } androidMain.dependencies { implementation(libs.usb.serial.android) } - - val androidHostTest by getting { - dependencies { - implementation(libs.androidx.test.core) - implementation(libs.robolectric) - } - } } } diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index b0a3d738c..b6999aadc 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase( override fun invoke(showMock: Boolean): Flow { val nodeDb = nodeRepository.nodeDBbyNum - val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } + // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.). + // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we + // must restrict the picker to entries whose advertised name matches the + // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN). + val bondedBleFlow = + bluetoothRepository.state.map { ble -> + ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) } + } val processedTcpFlow = combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index e4bb00c6b..7e57f2eff 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -20,7 +20,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -29,9 +28,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.RadioController @@ -39,6 +36,7 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -54,8 +52,8 @@ open class ScannerViewModel( private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ViewModel() { - private val _showMockInterface = MutableStateFlow(false) - val showMockInterface: StateFlow = _showMockInterface.asStateFlow() + private val _showMockTransport = MutableStateFlow(false) + val showMockTransport: StateFlow = _showMockTransport.asStateFlow() private val _errorText = MutableStateFlow(null) val errorText: StateFlow = _errorText.asStateFlow() @@ -68,7 +66,7 @@ open class ScannerViewModel( private var scanJob: kotlinx.coroutines.Job? = null init { - _showMockInterface.value = radioInterfaceService.isMockInterface() + _showMockTransport.value = radioInterfaceService.isMockTransport() } fun startBleScan() { @@ -77,25 +75,24 @@ open class ScannerViewModel( isBleScanningState.value = true scannedBleDevices.value = emptyMap() - 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) } + 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) } + } } - } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } - } finally { - isBleScanningState.value = false + } finally { + isBleScanningState.value = false + } } - } } fun stopBleScan() { @@ -105,9 +102,9 @@ open class ScannerViewModel( } private val discoveredDevicesFlow = - showMockInterface + showMockTransport .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + .stateInWhileSubscribed(initialValue = null) /** A combined list of bonded and scanned BLE devices for the UI. */ val bleDevicesForUi: StateFlow> = @@ -130,7 +127,7 @@ open class ScannerViewModel( } .flowOn(dispatchers.default) .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .stateInWhileSubscribed(initialValue = emptyList()) /** UI StateFlow for USB devices. */ val usbDevicesForUi: StateFlow> = @@ -186,11 +183,11 @@ open class ScannerViewModel( fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) } + safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { - viewModelScope.launch { recentAddressesDataSource.remove(address) } + safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) } } /** @@ -205,6 +202,7 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { + radioPrefs.setDevName(it.name) requestBonding(it) false } @@ -215,12 +213,13 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { + radioPrefs.setDevName(it.name) requestPermission(it) false } } is DeviceListEntry.Tcp -> { - viewModelScope.launch { + safeLaunch(tag = "onSelectedTcp") { radioPrefs.setDevName(it.name) addRecentAddress(it.fullAddress, it.name) changeDeviceAddress(it.fullAddress) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index ecdaeb3c3..4249cd625 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,14 +18,15 @@ package org.meshtastic.feature.connections.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.meshtastic import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices @@ -49,7 +50,7 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") processTcpServices(tcpServices, recentList, defaultName) } @@ -71,7 +72,7 @@ class CommonGetDiscoveredDevicesUseCase( usbList + if (showMock) { val demoModeLabel = - runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode") + safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") listOf(DeviceListEntry.Mock(demoModeLabel)) } else { emptyList() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index eabd920eb..c6962c8c0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -20,30 +20,30 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.ConnectionsRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.ConnectionsRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.ui.ConnectionsScreen import org.meshtastic.feature.settings.radio.RadioConfigViewModel -/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */ +/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoute.Connections]. */ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) { - entry { + entry { ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } - entry { + entry { ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 631a5da9d..7fdc287cd 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 @@ -27,8 +27,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Language import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -53,7 +51,7 @@ import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connected_device @@ -68,6 +66,7 @@ import org.meshtastic.core.resources.unknown_device import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NoDevice import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel @@ -126,8 +125,8 @@ fun ConnectionsScreen( getNavRouteFrom(radioConfigState.route)?.let { route -> isWaiting = false radioConfigViewModel.clearPacketResponse() - if (route == SettingsRoutes.LoRa) { - onConfigNavigate(SettingsRoutes.LoRa) + if (route == SettingsRoute.LoRa) { + onConfigNavigate(SettingsRoute.LoRa) } } }, @@ -152,7 +151,7 @@ fun ConnectionsScreen( MainAppBar( title = stringResource(Res.string.connections), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState.isConnected(), + showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, canNavigateUp = false, onNavigateUp = {}, actions = {}, @@ -168,17 +167,19 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(4.dp)) val uiState = when { - connectionState.isConnected() && ourNode != null -> 2 - connectionState.isConnected() || - connectionState == ConnectionState.Connecting || - selectedDevice != NO_DEVICE_SELECTED -> 1 + connectionState is ConnectionState.Connected && ourNode != null -> + ConnectionUiState.CONNECTED_WITH_NODE - else -> 0 + connectionState is ConnectionState.Connected || + connectionState == ConnectionState.Connecting || + selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING + + else -> ConnectionUiState.NO_DEVICE } Crossfade(targetState = uiState, label = "connection_state") { state -> when (state) { - 2 -> + ConnectionUiState.CONNECTED_WITH_NODE -> ConnectedDeviceContent( ourNode = ourNode, regionUnset = regionUnset, @@ -192,7 +193,7 @@ fun ConnectionsScreen( }, ) - 1 -> + ConnectionUiState.CONNECTING -> ConnectingDeviceContent( connectionState = connectionState, selectedDevice = selectedDevice, @@ -209,7 +210,9 @@ fun ConnectionsScreen( } var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } - LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } } + LaunchedEffect(selectedDevice) { + DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } + } val supportedDeviceTypes = scanModel.supportedDeviceTypes @@ -317,7 +320,7 @@ private fun ConnectedDeviceContent( if (regionUnset && selectedDevice != "m") { TitledCard(title = null) { ListItem( - leadingIcon = Icons.Rounded.Language, + leadingIcon = MeshtasticIcons.Language, text = stringResource(Res.string.set_your_region), onClick = onSetRegion, ) @@ -370,3 +373,15 @@ 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 9907e01c0..0d079ebdc 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,6 +32,7 @@ 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 @@ -41,6 +42,10 @@ 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, @@ -50,7 +55,7 @@ fun ConnectingDeviceInfo( modifier: Modifier = Modifier, ) { val statusText = - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { stringResource(Res.string.connected) } else { stringResource(Res.string.connecting) @@ -75,8 +80,8 @@ fun ConnectingDeviceInfo( } Button( - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = MaterialTheme.shapes.medium, + shape = RectangleShape, + modifier = Modifier.fillMaxWidth().height(40.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.StatusRed, @@ -84,7 +89,7 @@ fun ConnectingDeviceInfo( ), onClick = onClickDisconnect, ) { - Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium) + Text(stringResource(Res.string.disconnect)) } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt index acde5889e..af09136f2 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.feature.connections.ui.components -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Usb -import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Icon import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -27,13 +23,17 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow +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.model.DeviceType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bluetooth +import org.meshtastic.core.resources.ic_bluetooth +import org.meshtastic.core.resources.ic_usb +import org.meshtastic.core.resources.ic_wifi import org.meshtastic.core.resources.network import org.meshtastic.core.resources.serial @@ -55,15 +55,15 @@ fun ConnectionsSegmentedBar( shape = SegmentedButtonDefaults.itemShape(index, visibleItems.size), onClick = { onClickDeviceType(item.deviceType) }, selected = item.deviceType == selectedDeviceType, - icon = { Icon(imageVector = item.imageVector, contentDescription = text) }, + icon = { Icon(imageVector = vectorResource(item.icon), contentDescription = text) }, label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } } } -private enum class Item(val imageVector: ImageVector, val textRes: StringResource, val deviceType: DeviceType) { - BLUETOOTH(imageVector = Icons.Rounded.Bluetooth, textRes = Res.string.bluetooth, deviceType = DeviceType.BLE), - NETWORK(imageVector = Icons.Rounded.Wifi, textRes = Res.string.network, deviceType = DeviceType.TCP), - SERIAL(imageVector = Icons.Rounded.Usb, textRes = Res.string.serial, deviceType = DeviceType.USB), +private enum class Item(val icon: DrawableResource, val textRes: StringResource, val deviceType: DeviceType) { + BLUETOOTH(icon = Res.drawable.ic_bluetooth, textRes = Res.string.bluetooth, deviceType = DeviceType.BLE), + NETWORK(icon = Res.drawable.ic_wifi, textRes = Res.string.network, deviceType = DeviceType.TCP), + SERIAL(icon = Res.drawable.ic_usb, textRes = Res.string.serial, deviceType = DeviceType.USB), } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 57f06e225..8f5347e01 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource @@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo( while (bleDevice.device.isConnected) { try { rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } + } catch (_: TimeoutCancellationException) { + Logger.d { "RSSI read timed out" } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - // RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise Logger.d(e) { "Failed to read RSSI ${e.message}" } } delay(RSSI_DELAY.seconds) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 9331cc909..14f4dc42b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -17,20 +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.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.BluetoothConnected -import androidx.compose.material.icons.rounded.Usb -import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -43,21 +36,33 @@ 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 import org.meshtastic.core.resources.serial import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.Rssi +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.BluetoothConnected +import org.meshtastic.core.ui.icon.BluetoothSearching +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Usb +import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.connections.model.DeviceListEntry private const val RSSI_UPDATE_RATE_MS = 2000L @@ -75,32 +80,28 @@ fun DeviceListItem( ) { // Throttle the RSSI updates to match the connected device polling rate var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) } - LaunchedEffect(rssi) { - if (displayedRssi == 0) { - displayedRssi = rssi ?: 0 - } - } + val currentRssi by rememberUpdatedState(rssi) LaunchedEffect(Unit) { while (true) { delay(RSSI_UPDATE_RATE_MS) - displayedRssi = rssi ?: 0 + displayedRssi = currentRssi ?: 0 } } val icon = when (device) { is DeviceListEntry.Ble -> - if (connectionState.isConnected()) { - Icons.Rounded.BluetoothConnected - } else if (connectionState.isConnecting()) { - Icons.AutoMirrored.Rounded.BluetoothSearching + if (connectionState is ConnectionState.Connected) { + MeshtasticIcons.BluetoothConnected + } else if (connectionState is ConnectionState.Connecting) { + MeshtasticIcons.BluetoothSearching } else { - Icons.Rounded.Bluetooth + MeshtasticIcons.Bluetooth } - is DeviceListEntry.Usb -> Icons.Rounded.Usb - is DeviceListEntry.Tcp -> Icons.Rounded.Wifi - is DeviceListEntry.Mock -> Icons.Rounded.Add + is DeviceListEntry.Usb -> MeshtasticIcons.Usb + is DeviceListEntry.Tcp -> MeshtasticIcons.Wifi + is DeviceListEntry.Mock -> MeshtasticIcons.Add } val contentDescription = @@ -111,11 +112,19 @@ fun DeviceListItem( is DeviceListEntry.Mock -> stringResource(Res.string.add) } + val selectLabel = stringResource(Res.string.action_select_device) + val isSelected = connectionState is ConnectionState.Connected val clickableModifier = if (onDelete != null) { - Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) + Modifier.semantics { selected = isSelected } + .combinedClickable( + onClickLabel = selectLabel, + role = Role.RadioButton, + onClick = onSelect, + onLongClick = onDelete, + ) } else { - Modifier.clickable(onClick = onSelect) + Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) } ListItem( @@ -132,7 +141,7 @@ fun DeviceListItem( contentDescription = contentDescription, modifier = Modifier.size(32.dp), tint = - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -146,10 +155,10 @@ fun DeviceListItem( Rssi(rssi = displayedRssi) } - if (connectionState.isConnecting()) { + if (connectionState is ConnectionState.Connecting) { CircularProgressIndicator(modifier = Modifier.size(32.dp)) } else { - RadioButton(selected = connectionState.isConnected(), onClick = null) + RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null) } } }, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt index b775b715e..3ff51db1e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt @@ -23,9 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Router import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -59,6 +56,9 @@ import org.meshtastic.core.resources.discovered_network_devices import org.meshtastic.core.resources.ip_port import org.meshtastic.core.resources.no_network_devices_found import org.meshtastic.core.resources.recent_network_devices +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.HardwareModel +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.model.DeviceListEntry @@ -97,11 +97,11 @@ fun NetworkDevices( if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) { EmptyStateContent( text = stringResource(Res.string.no_network_devices_found), - imageVector = Icons.Rounded.Router, + imageVector = MeshtasticIcons.HardwareModel, modifier = Modifier.padding(vertical = 32.dp), ) { Button(onClick = { showAddDialog = true }) { - Icon(Icons.Rounded.Add, contentDescription = null) + Icon(MeshtasticIcons.Add, contentDescription = null) Text(stringResource(Res.string.add_network_device)) } } @@ -127,7 +127,7 @@ fun NetworkDevices( Row(modifier = Modifier.padding(top = 8.dp)) { FloatingActionButton(onClick = { showAddDialog = true }) { - Icon(Icons.Rounded.Add, contentDescription = stringResource(Res.string.add_network_device)) + Icon(MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add_network_device)) } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt index 4a10d18bf..ef1183c3f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt @@ -17,8 +17,6 @@ package org.meshtastic.feature.connections.ui.components import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.UsbOff import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -27,6 +25,8 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.no_usb_devices_found import org.meshtastic.core.resources.usb +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.UsbOff import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.model.DeviceListEntry @@ -40,7 +40,7 @@ fun UsbDevices( if (usbDevices.isEmpty()) { EmptyStateContent( text = stringResource(Res.string.no_usb_devices_found), - imageVector = Icons.Rounded.UsbOff, + imageVector = MeshtasticIcons.UsbOff, modifier = Modifier.padding(vertical = 32.dp), ) } else { 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 6f291d68a..04e9ac03e 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.isMockInterface() } returns false + every { radioInterfaceService.isMockTransport() } 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 9bf8fab92..a1b35c797 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -56,25 +56,8 @@ kotlin { implementation(libs.markdown.renderer.m3) } - androidMain.dependencies { - implementation(libs.androidx.appcompat) - implementation(libs.markdown.renderer.android) - } + androidMain.dependencies { implementation(libs.markdown.renderer.android) } - commonTest.dependencies { - implementation(projects.core.testing) - implementation(libs.turbine) - } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.robolectric) - implementation(libs.turbine) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.test.ext.junit) - } - } + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 1647a5af7..3fa26d1cd 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware import android.content.Context import co.touchlab.kermit.Logger +import com.eygraber.uri.toAndroidUri import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.head @@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.common.util.toPlatformUri import org.meshtastic.core.model.DeviceHardware import java.io.File import java.io.FileOutputStream @@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (!tempDir.exists()) tempDir.mkdirs() try { - val platformUri = uri.toPlatformUri() as android.net.Uri + val platformUri = uri.toAndroidUri() val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null ZipInputStream(inputStream).use { zipInput -> var entry = zipInput.nextEntry @@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() - ?: context.contentResolver - .openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r") - ?.use { descriptor -> descriptor.length.takeIf { it >= 0L } } + ?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor -> + descriptor.length.takeIf { it >= 0L } + } ?: 0L } @@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (localFile != null && localFile.exists()) { localFile.readBytes() } else { - context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use { - it.readBytes() - } ?: throw IOException("Cannot open artifact: ${artifact.uri}") + context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() } + ?: throw IOException("Cannot open artifact: ${artifact.uri}") } } override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) { - val inputStream = - context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri) - ?: return@withContext null + val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") tempFile.parentFile?.mkdirs() inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } @@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien withContext(ioDispatcher) { val inputStream = source.toLocalFileOrNull()?.inputStream() - ?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri) + ?: context.contentResolver.openInputStream(source.uri.toAndroidUri()) ?: throw IOException("Cannot open source URI") val outputStream = - context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri) + context.contentResolver.openOutputStream(destinationUri.toAndroidUri()) ?: throw IOException("Cannot open content URI for writing") inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 64d550a79..1dcb7ba69 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease @@ -29,7 +30,11 @@ private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtas /** OTA partition role in .mt.json manifests — the main application firmware. */ private const val OTA_PART_NAME = "app0" -private val manifestJson = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val manifestJson = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */ @Single diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index da7528d9b..1b5c0c803 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -34,18 +34,19 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -127,6 +128,7 @@ import org.meshtastic.core.resources.learn_more import org.meshtastic.core.resources.okay import org.meshtastic.core.resources.save import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.ArrowBack import org.meshtastic.core.ui.icon.Bluetooth import org.meshtastic.core.ui.icon.CheckCircle import org.meshtastic.core.ui.icon.CloudDownload @@ -161,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView uri?.let { viewModel.startUpdateFromFile(it) } } - val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri -> - viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString)) - } + val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) } val actions = remember(viewModel, onNavigateUp) { @@ -233,7 +233,7 @@ private fun FirmwareUpdateScaffold( title = { Text(stringResource(Res.string.firmware_update_title)) }, navigationIcon = { IconButton(onClick = { onNavigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + Icon(MeshtasticIcons.ArrowBack, contentDescription = stringResource(Res.string.back)) } }, ) @@ -382,24 +382,35 @@ 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 }, - modifier = Modifier.fillMaxWidth().height(56.dp), + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), ) { Icon(MeshtasticIcons.Folder, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.firmware_update_select_file)) + Text( + stringResource(Res.string.firmware_update_select_file), + style = ButtonDefaults.textStyleFor(largeHeight), + ) } } else if (state.release != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val largeHeight = ButtonDefaults.LargeContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, - modifier = Modifier.fillMaxWidth().height(56.dp), + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), ) { Icon( imageVector = @@ -417,6 +428,7 @@ private fun ReadyState( resource = Res.string.firmware_update_method_detail, stringResource(state.updateMethod.description), ), + style = ButtonDefaults.textStyleFor(largeHeight), ) } Spacer(Modifier.height(24.dp)) @@ -681,7 +693,8 @@ private fun ProgressContent( tint = MaterialTheme.colorScheme.primary, ) } else { - CircularProgressIndicator( + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + CircularWavyProgressIndicator( progress = { if (isUpdating) progressState.progress else 1f }, modifier = Modifier.size(64.dp), ) @@ -709,7 +722,8 @@ private fun ProgressContent( Spacer(Modifier.height(12.dp)) if (isDownloading || isUpdating) { - LinearProgressIndicator( + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + LinearWavyProgressIndicator( progress = { progressState.progress }, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) @@ -851,8 +865,15 @@ private fun SuccessState(onDone: () -> Unit) { textAlign = TextAlign.Center, ) Spacer(Modifier.height(32.dp)) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text(stringResource(Res.string.firmware_update_done)) + @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)) } } } 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 777968a45..f8ff9fcac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -34,7 +35,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.di.ApplicationCoroutineScope import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -69,6 +72,7 @@ private const val DEVICE_DETACH_TIMEOUT = 30_000L private const val VERIFY_TIMEOUT = 60_000L private const val VERIFY_DELAY = 2000L private const val MIN_BATTERY_LEVEL = 10 +private const val LOCAL_RELEASE_ID = "local" private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") @@ -88,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) @@ -121,7 +126,12 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - viewModelScope.launch { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } + // 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) + } } fun setReleaseType(type: FirmwareReleaseType) { @@ -141,7 +151,7 @@ class FirmwareUpdateViewModel( updateJob = viewModelScope.launch { _state.value = FirmwareUpdateState.Checking - runCatching { + safeCatching { val ourNode = nodeRepository.myNodeInfo.value val address = radioPrefs.devAddr.value?.drop(1) if (address == null || ourNode == null) { @@ -194,7 +204,6 @@ class FirmwareUpdateViewModel( } } .onFailure { e -> - if (e is CancellationException) throw e Logger.e(e) { "Error checking for updates" } val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error) _state.value = @@ -294,8 +303,7 @@ class FirmwareUpdateViewModel( val updateArtifact = firmwareUpdateManager.startUpdate( - release = - FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""), + release = FirmwareRelease(id = LOCAL_RELEASE_ID, zipUrl = "", releaseNotes = ""), hardware = currentState.deviceHardware, address = currentState.address, updateState = { _state.value = it }, @@ -385,7 +393,7 @@ private suspend fun cleanupTemporaryFiles( fileHandler: FirmwareFileHandler, tempFirmwareFile: FirmwareArtifact?, ): FirmwareArtifact? { - runCatching { + safeCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } fileHandler.cleanupAllTemporaryFiles() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt index 9ab1320b9..40c6ad904 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -17,18 +17,23 @@ 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 import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.FirmwareRoutes +import org.meshtastic.core.navigation.FirmwareRoute import org.meshtastic.feature.firmware.FirmwareUpdateScreen import org.meshtastic.feature.firmware.FirmwareUpdateViewModel /** Registers the firmware update screen entries into the Navigation 3 entry provider. */ fun EntryProviderScope.firmwareGraph(backStack: NavBackStack) { - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } } @Composable diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 9d2478f45..8035774c4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -38,13 +37,17 @@ 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 = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) : UnifiedOtaProtocol { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -68,16 +71,16 @@ class BleOtaTransport( tag = "BLE OTA", serviceUuid = OTA_SERVICE_UUID, retryCount = SCAN_RETRY_COUNT, - retryDelayMs = SCAN_RETRY_DELAY_MS, + retryDelay = SCAN_RETRY_DELAY, ) { it.address in targetAddresses } } @Suppress("MagicNumber") - 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) + override suspend fun connect(): Result = safeCatching { + Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } + delay(REBOOT_DELAY) Logger.i { "BLE OTA: Connecting to $address using Kable..." } @@ -96,7 +99,7 @@ class BleOtaTransport( .launchIn(transportScope) try { - val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) + val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) 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}") @@ -137,7 +140,7 @@ class BleOtaTransport( .launchIn(this) // Allow time for the BLE subscription to be established before proceeding. - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -149,14 +152,14 @@ class BleOtaTransport( sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) val packetsSent = sendCommand(command) var handshakeComplete = false var responsesReceived = 0 while (!handshakeComplete) { - val response = waitForResponse(ERASING_TIMEOUT_MS) + val response = waitForResponse(ERASING_TIMEOUT) responsesReceived++ when (val parsed = OtaResponse.parse(response)) { is OtaResponse.Ok -> { @@ -186,7 +189,7 @@ class BleOtaTransport( data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val totalBytes = data.size var sentBytes = 0 @@ -203,7 +206,7 @@ class BleOtaTransport( val nextSentBytes = sentBytes + currentChunkSize repeat(packetsSentForChunk) { i -> - val response = waitForResponse(ACK_TIMEOUT_MS) + val response = waitForResponse(ACK_TIMEOUT) val isLastPacketOfChunk = i == packetsSentForChunk - 1 when (val parsed = OtaResponse.parse(response)) { @@ -212,7 +215,7 @@ class BleOtaTransport( if (nextSentBytes >= totalBytes && isLastPacketOfChunk) { sentBytes = nextSentBytes onProgress(1.0f) - return@runCatching Unit + return@safeCatching Unit } } is OtaResponse.Error -> { @@ -229,7 +232,7 @@ class BleOtaTransport( onProgress(sentBytes.toFloat() / totalBytes) } - val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS) + val finalResponse = waitForResponse(VERIFICATION_TIMEOUT) when (val parsed = OtaResponse.parse(finalResponse)) { is OtaResponse.Ok -> Unit is OtaResponse.Error -> { @@ -274,21 +277,21 @@ class BleOtaTransport( return packetsSent } - private suspend fun waitForResponse(timeoutMs: Long): String = try { - withTimeout(timeoutMs) { responseChannel.receive() } + private suspend fun waitForResponse(timeout: Duration): String = try { + withTimeout(timeout) { responseChannel.receive() } } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { - throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms") + throw OtaProtocolException.Timeout("Timeout waiting for response after $timeout") } companion object { - 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 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 SCAN_RETRY_COUNT = 3 - private const val SCAN_RETRY_DELAY_MS = 2_000L + private val SCAN_RETRY_DELAY = 2.seconds 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 6df54ea43..fa9966b66 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds internal const val DEFAULT_SCAN_RETRY_COUNT = 3 -internal const val DEFAULT_SCAN_RETRY_DELAY_MS = 2_000L +internal val DEFAULT_SCAN_RETRY_DELAY: Duration = 2.seconds 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, - retryDelayMs: Long = DEFAULT_SCAN_RETRY_DELAY_MS, + retryDelay: Duration = DEFAULT_SCAN_RETRY_DELAY, 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(retryDelayMs) + if (attempt < retryCount - 1) delay(retryDelay) } 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 58c09f16a..82e91413d 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -67,7 +68,7 @@ private const val GATT_RELEASE_DELAY_MS = 1000L * * All platform I/O (file reading, content-resolver imports) is delegated to [FirmwareFileHandler]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Single class Esp32OtaUpdateHandler( private val firmwareRetriever: FirmwareRetriever, @@ -76,6 +77,7 @@ class Esp32OtaUpdateHandler( private val nodeRepository: NodeRepository, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (MAC with colons) or WiFi (IP without). */ @@ -102,7 +104,7 @@ class Esp32OtaUpdateHandler( hardware = hardware, updateState = updateState, firmwareUri = firmwareUri, - transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) }, + transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) }, rebootMode = 1, connectionAttempts = 5, ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 3694c4e6a..d21cc15ea 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.safeCatching /** * WiFi/TCP transport implementation for ESP32 Unified OTA protocol. @@ -54,7 +55,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In /** Connect to the device via TCP using Ktor raw sockets. */ override suspend fun connect(): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" } val selector = SelectorManager(ioDispatcher) @@ -82,7 +83,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) sendCommand(command) @@ -116,7 +117,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In chunkSize: Int, onProgress: suspend (Float) -> Unit, ): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { if (!isConnected) { throw OtaProtocolException.TransferFailed("Not connected") } @@ -166,7 +167,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In override suspend fun close() { withContext(ioDispatcher) { - runCatching { + safeCatching { socket?.close() selectorManager?.close() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt index 2763aa414..43f6804e1 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt @@ -17,9 +17,15 @@ package org.meshtastic.feature.firmware.ota.dfu import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecodingException -private val json = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val json = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** * Parse pre-extracted zip entries into a [DfuZipPackage]. @@ -36,7 +42,11 @@ internal fun parseDfuZipEntries(entries: Map): DfuZipPackage val manifest = runCatching { json.decodeFromString(manifestBytes.decodeToString()) } - .getOrElse { e -> throw DfuException.InvalidPackage("Failed to parse manifest.json: ${e.message}") } + .getOrElse { e -> + @OptIn(ExperimentalSerializationApi::class) + val detail = (e as? JsonDecodingException)?.shortMessage ?: e.message + throw DfuException.InvalidPackage("Failed to parse manifest.json: $detail") + } val entry = manifest.manifest.primaryEntry ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json") diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 3e673461b..a2eb5a7a4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.resources.Res @@ -70,6 +71,7 @@ class SecureDfuHandler( private val radioController: RadioController, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { @Suppress("LongMethod") @@ -108,7 +110,7 @@ class SecureDfuHandler( var transport: SecureDfuTransport? = null var completed = false try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target) + transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) transport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index f3d9d8648..42e92c8ac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel @@ -46,8 +45,12 @@ 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. @@ -63,7 +66,7 @@ class SecureDfuTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -88,7 +91,7 @@ class SecureDfuTransport( * * The caller must have already released the mesh-service BLE connection before calling this. */ - suspend fun triggerButtonlessDfu(): Result = runCatching { + suspend fun triggerButtonlessDfu(): Result = safeCatching { Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." } val device = @@ -96,7 +99,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_MS) + bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS) @@ -111,7 +114,7 @@ class SecureDfuTransport( .catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) Logger.i { "DFU: Writing buttonless DFU trigger..." } service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE) @@ -119,7 +122,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_MS) { + withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) { 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()}" } @@ -149,7 +152,7 @@ class SecureDfuTransport( * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling * notifications on the Control Point. */ - suspend fun connectToDfuMode(): Result = runCatching { + suspend fun connectToDfuMode(): Result = safeCatching { val dfuAddress = calculateMacPlusOne(address) val targetAddresses = setOf(address, dfuAddress) Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." } @@ -162,7 +165,7 @@ class SecureDfuTransport( bleConnection.connectionState.onEach { Logger.d { "DFU: Connection state → $it" } }.launchIn(transportScope) - val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS) + val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) if (connected is BleConnectionState.Disconnected) { throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}") } @@ -188,7 +191,7 @@ class SecureDfuTransport( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -207,7 +210,7 @@ class SecureDfuTransport( * PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet * is small (<512 bytes, fits in a single object) and does not benefit from flow control. */ - suspend fun transferInitPacket(initPacket: ByteArray): Result = runCatching { + suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." } setPrn(0) transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null) @@ -228,12 +231,13 @@ class SecureDfuTransport( * @param firmware Raw bytes of the `.bin` file. * @param onProgress Callback receiving progress in [0.0, 1.0]. */ - suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = runCatching { - Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } - setPrn(PRN_INTERVAL) - transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) - Logger.i { "DFU: Firmware transferred and executed." } - } + suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = + safeCatching { + Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } + setPrn(PRN_INTERVAL) + transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) + Logger.i { "DFU: Firmware transferred and executed." } + } // --------------------------------------------------------------------------- // Abort & teardown @@ -247,7 +251,7 @@ class SecureDfuTransport( * accept a fresh DFU session. */ suspend fun abort() { - runCatching { + safeCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, byteArrayOf(DfuOpcode.ABORT), BleWriteType.WITH_RESPONSE) @@ -259,7 +263,7 @@ class SecureDfuTransport( /** Disconnect from the DFU target and cancel the transport coroutine scope. */ suspend fun close() { - runCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } + safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } @@ -286,7 +290,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_MS) + if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY) } } throw lastError ?: DfuException.TransferFailed("Object transfer failed after $OBJECT_RETRY_COUNT attempts") @@ -347,7 +351,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_MS) + delay(FIRST_CHUNK_DELAY) isFirstChunk = false } @@ -399,7 +403,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_MS) + delay(RETRY_DELAY) sendExecute() } else { throw e @@ -440,7 +444,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_MS) + val response = awaitNotification(COMMAND_TIMEOUT) if (response is DfuResponse.ChecksumResult) { val expectedCrc = DfuCrc32.calculate(data, length = pos) if (response.offset != pos || response.crc32 != expectedCrc) { @@ -459,7 +463,7 @@ class SecureDfuTransport( val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, payload, BleWriteType.WITH_RESPONSE) } - return awaitNotification(COMMAND_TIMEOUT_MS) + return awaitNotification(COMMAND_TIMEOUT) } private suspend fun setPrn(value: Int) { @@ -506,13 +510,13 @@ class SecureDfuTransport( Logger.d { "DFU: Object executed." } } - private suspend fun awaitNotification(timeoutMs: Long): DfuResponse = try { - withTimeout(timeoutMs) { + private suspend fun awaitNotification(timeout: Duration): DfuResponse = try { + withTimeout(timeout) { val bytes = notificationChannel.receive() DfuResponse.parse(bytes).also { Logger.d { "DFU: Notification → $it" } } } } catch (_: TimeoutCancellationException) { - throw DfuException.Timeout("No response from Control Point after ${timeoutMs}ms") + throw DfuException.Timeout("No response from Control Point after $timeout") } private fun DfuResponse.requireSuccess(expectedOpcode: Byte) { @@ -541,7 +545,7 @@ class SecureDfuTransport( tag = "DFU", serviceUuid = SecureDfuUuids.SERVICE, retryCount = SCAN_RETRY_COUNT, - retryDelayMs = SCAN_RETRY_DELAY_MS, + retryDelay = SCAN_RETRY_DELAY, predicate = predicate, ) @@ -550,14 +554,14 @@ class SecureDfuTransport( // --------------------------------------------------------------------------- companion object { - 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 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 SCAN_RETRY_COUNT = 3 - 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 + private val SCAN_RETRY_DELAY = 2.seconds + private val RETRY_DELAY = 2.seconds + private val FIRST_CHUNK_DELAY = 400.milliseconds /** 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 723fed82f..0a26fd13e 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -20,9 +20,11 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -59,6 +61,12 @@ class DefaultFirmwareUpdateManagerTest { private val bleScanner: BleScanner = mock(MockMode.autofill) private val bleConnectionFactory: BleConnectionFactory = mock(MockMode.autofill) private val firmwareRetriever = FirmwareRetriever(fileHandler) + private val dispatchers = + CoroutineDispatchers( + io = Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + default = Dispatchers.Unconfined, + ) private val secureDfuHandler = SecureDfuHandler( @@ -67,6 +75,7 @@ class DefaultFirmwareUpdateManagerTest { radioController = radioController, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private val usbUpdateHandler = @@ -84,6 +93,7 @@ class DefaultFirmwareUpdateManagerTest { nodeRepository = nodeRepository, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private fun createManager(address: String?): DefaultFirmwareUpdateManager { diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 4c48a1ced..030d84eff 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 7032ed408..a8eddff83 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt similarity index 63% rename from core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt rename to feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt index 7669a66b0..3ef5c44ef 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt @@ -14,12 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.common.util +package org.meshtastic.feature.firmware -import android.net.Uri +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.common.di.ApplicationCoroutineScope -/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */ -fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString) - -/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */ -fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString()) +internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : + ApplicationCoroutineScope, + CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/firmware/src/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 b6a73bc52..da8f84057 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, timeoutMs: Long) = - delegate.connectAndAwait(device, timeoutMs) + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) = + delegate.connectAndAwait(device, timeout) 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 acb1545bd..23a0d03ab 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) // ----------------------------------------------------------------------- diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index e93ce2924..5429361f5 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -38,16 +38,5 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.robolectric) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) - implementation(libs.androidx.test.core) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - } - } } } diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt index 849c8ce11..4b5cdf8ff 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt @@ -19,11 +19,7 @@ package org.meshtastic.feature.intro import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Bluetooth -import androidx.compose.material.icons.outlined.SettingsInputAntenna import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bluetooth_feature_config @@ -35,6 +31,9 @@ import org.meshtastic.core.resources.configure_bluetooth_permissions import org.meshtastic.core.resources.next import org.meshtastic.core.resources.permission_missing_31 import org.meshtastic.core.resources.settings +import org.meshtastic.core.ui.icon.Antenna +import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.MeshtasticIcons /** * Screen for configuring Bluetooth permissions during the app introduction. It explains why Bluetooth permissions are @@ -55,20 +54,19 @@ internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConf tag = SETTINGS_TAG, ) - val features = remember { + val features = listOf( FeatureUIData( - icon = Icons.Outlined.Bluetooth, + icon = MeshtasticIcons.Bluetooth, titleRes = Res.string.bluetooth_feature_discovery, subtitleRes = Res.string.bluetooth_feature_discovery_description, ), FeatureUIData( - icon = Icons.Outlined.SettingsInputAntenna, + icon = MeshtasticIcons.Antenna, titleRes = Res.string.bluetooth_feature_config, subtitleRes = Res.string.bluetooth_feature_config_description, ), ) - } PermissionScreenLayout( headlineRes = Res.string.bluetooth_permission, diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt index 3d34178d4..0dc70d15d 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt @@ -19,11 +19,7 @@ package org.meshtastic.feature.intro import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.LocationOn -import androidx.compose.material.icons.outlined.Router import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.configure_location_permissions @@ -39,6 +35,9 @@ import org.meshtastic.core.resources.phone_location_description import org.meshtastic.core.resources.settings import org.meshtastic.core.resources.share_location import org.meshtastic.core.resources.share_location_description +import org.meshtastic.core.ui.icon.HardwareModel +import org.meshtastic.core.ui.icon.LocationOn +import org.meshtastic.core.ui.icon.MeshtasticIcons /** * Screen for configuring location permissions during the app introduction. It explains why location permissions are @@ -59,30 +58,29 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi tag = SETTINGS_TAG, ) - val features = remember { + val features = listOf( FeatureUIData( - icon = Icons.Outlined.LocationOn, + icon = MeshtasticIcons.LocationOn, titleRes = Res.string.share_location, subtitleRes = Res.string.share_location_description, ), FeatureUIData( - icon = Icons.Outlined.Router, + icon = MeshtasticIcons.HardwareModel, titleRes = Res.string.distance_measurements, subtitleRes = Res.string.distance_measurements_description, ), FeatureUIData( - icon = Icons.Outlined.Router, // Consider a different icon if appropriate + icon = MeshtasticIcons.HardwareModel, // Consider a different icon if appropriate titleRes = Res.string.distance_filters, subtitleRes = Res.string.distance_filters_description, ), FeatureUIData( - icon = Icons.Outlined.LocationOn, // Consider a different icon if appropriate + icon = MeshtasticIcons.LocationOn, // Consider a different icon if appropriate titleRes = Res.string.mesh_map_location, subtitleRes = Res.string.mesh_map_location_description, ), ) - } PermissionScreenLayout( headlineRes = Res.string.phone_location, diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt index 41a45f4e1..6cb632197 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt @@ -19,12 +19,7 @@ package org.meshtastic.feature.intro import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Message -import androidx.compose.material.icons.outlined.BatteryAlert -import androidx.compose.material.icons.outlined.SpeakerPhone import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_notifications @@ -38,6 +33,10 @@ import org.meshtastic.core.resources.notifications_for_channel_and_direct_messag import org.meshtastic.core.resources.notifications_for_low_battery_alerts import org.meshtastic.core.resources.notifications_for_newly_discovered_nodes import org.meshtastic.core.resources.settings +import org.meshtastic.core.ui.icon.BatteryAlert +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Message +import org.meshtastic.core.ui.icon.Speaker /** * Screen for configuring notification permissions during the app introduction. It explains why notification permissions @@ -58,25 +57,24 @@ internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, on tag = SETTINGS_TAG, ) - val features = remember { + val features = listOf( FeatureUIData( - icon = Icons.AutoMirrored.Outlined.Message, + icon = MeshtasticIcons.Message, titleRes = Res.string.incoming_messages, subtitleRes = Res.string.notifications_for_channel_and_direct_messages, ), FeatureUIData( - icon = Icons.Outlined.SpeakerPhone, + icon = MeshtasticIcons.Speaker, titleRes = Res.string.new_nodes, subtitleRes = Res.string.notifications_for_newly_discovered_nodes, ), FeatureUIData( - icon = Icons.Outlined.BatteryAlert, + icon = MeshtasticIcons.BatteryAlert, titleRes = Res.string.low_battery, subtitleRes = Res.string.notifications_for_low_battery_alerts, ), ) - } PermissionScreenLayout( headlineRes = Res.string.app_notifications, diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt index b9943974f..e5a7f6597 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt @@ -23,15 +23,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Hub -import androidx.compose.material.icons.outlined.NearMe -import androidx.compose.material.icons.outlined.SettingsInputAntenna import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -49,6 +44,10 @@ import org.meshtastic.core.resources.meshtastic import org.meshtastic.core.resources.share_your_location_in_real_time import org.meshtastic.core.resources.stay_connected_anywhere import org.meshtastic.core.resources.track_and_share_locations +import org.meshtastic.core.ui.icon.Antenna +import org.meshtastic.core.ui.icon.MeshHub +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.NearMe import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider /** @@ -59,25 +58,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider @Composable internal fun WelcomeScreen(onGetStarted: () -> Unit) { val analyticsIntro = LocalAnalyticsIntroProvider.current - val features = remember { + val features = listOf( FeatureUIData( - icon = Icons.Outlined.SettingsInputAntenna, + icon = MeshtasticIcons.Antenna, titleRes = Res.string.stay_connected_anywhere, subtitleRes = Res.string.communicate_off_the_grid, ), FeatureUIData( - icon = Icons.Outlined.Hub, + icon = MeshtasticIcons.MeshHub, titleRes = Res.string.create_your_own_networks, subtitleRes = Res.string.easily_set_up_private_mesh_networks, ), FeatureUIData( - icon = Icons.Outlined.NearMe, + icon = MeshtasticIcons.NearMe, titleRes = Res.string.track_and_share_locations, subtitleRes = Res.string.share_your_location_in_real_time, ), ) - } Scaffold( bottomBar = { diff --git a/feature/map/README.md b/feature/map/README.md index 3e38406a9..802f18913 100644 --- a/feature/map/README.md +++ b/feature/map/README.md @@ -1,24 +1,41 @@ # `:feature:map` ## Overview -The `:feature:map` module provides the mapping interface for the application. It supports multiple map providers and displays node positions, tracks, and waypoints. +The `:feature:map` module provides the mapping interface for the application. Map rendering is decomposed into three focused `CompositionLocal` provider contracts, each with per-flavor implementations in `:app`. -## Key Components +## Architecture -### 1. `MapScreen` -The main mapping interface. It integrates with flavor-specific map implementations (Google Maps for `google`, OpenStreetMap for `fdroid`). +### Provider Contracts (in `core:ui/commonMain`) -### 2. `BaseMapViewModel` -The base logic for managing map state, node markers, and camera positions. +| Contract | Purpose | Implementations | +|---|---|---| +| `MapViewProvider` | Main map (nodes, waypoints, controls) | `GoogleMapViewProvider`, `FdroidMapViewProvider` | +| `NodeTrackMapProvider` | Per-node GPS track overlay (embedded in `PositionLogScreen`) | Google: `NodeTrackMap` → `MapView(GoogleMapMode.NodeTrack)`, F-Droid: `NodeTrackMap` → `NodeTrackOsmMap` | +| `TracerouteMapProvider` | Traceroute route visualization | Google: `TracerouteMap` → `MapView(GoogleMapMode.Traceroute)`, F-Droid: `TracerouteMap` → `TracerouteOsmMap` | + +All providers are injected via `CompositionLocal` in `MainActivity.kt` and consumed by feature modules without direct dependency on Google Maps or osmdroid. + +### Shared ViewModels (in `commonMain`) + +- **`BaseMapViewModel`** — Core contract for all map state management, node markers, camera positions, and traceroute node selection logic (`TracerouteNodeSelection`, `tracerouteNodeSelection()`). +- **`NodeMapViewModel`** — Shared logic for per-node map views (track display, position history). + +### Key Data Types + +- **`TracerouteOverlay`** (`core:model/commonMain`) — Pure data class representing traceroute route segments. Extracted from `feature:map` for cross-module reuse. +- **`TracerouteNodeSelection`** (`feature:map/commonMain`) — Data class modeling node selection results during traceroute visualization. +- **`GeoConstants`** (`core:model/commonMain`) — Centralized geographic constants (`DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS`). ## Map Providers -- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. -- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source mapping experience. +- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. Implementations in `app/src/google/kotlin/org/meshtastic/app/map/`. +- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source experience. Implementations in `app/src/fdroid/kotlin/org/meshtastic/app/map/`. ## Features - **Live Node Tracking**: Real-time position updates for nodes on the mesh. - **Waypoints**: Create and share points of interest. +- **Per-Node Track Overlay**: Embedded map in `PositionLogScreen` showing a node's GPS track history. +- **Traceroute Visualization**: Dedicated map view showing route segments between mesh nodes. - **Offline Maps**: Support for pre-downloaded map tiles (via `osmdroid`). ## Module dependency graph diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 1880b136c..db52c350a 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -43,17 +43,5 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.di) } - - androidMain.dependencies { implementation(libs.material) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.robolectric) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.test.core) - } - } } } diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index a018ca8e6..588ca198b 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -57,7 +57,6 @@ fun MapScreen( ) { paddingValues -> LocalMapViewProvider.current?.MapView( modifier = Modifier.fillMaxSize().padding(paddingValues), - viewModel = viewModel, navigateToNodeDetails = navigateToNodeDetails, waypointId = waypointId, ) 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 bd8f8615b..294d84e4c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,19 +17,19 @@ 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 import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository @@ -39,11 +39,17 @@ 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.feature.map.model.TracerouteOverlay import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint +/** + * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute + * overlay state. + * + * Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic. + */ @Suppress("TooManyFunctions") open class BaseMapViewModel( protected val mapPrefs: MapPrefs, @@ -82,6 +88,7 @@ open class BaseMapViewModel( .getWaypoints() .mapLatest { list -> list + .filter { it.waypoint != null } .associateBy { packet -> packet.waypoint!!.id } .filterValues { val expire = it.waypoint?.expire ?: 0 @@ -91,7 +98,7 @@ open class BaseMapViewModel( .stateInWhileSubscribed(initialValue = emptyMap()) private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value) - val showOnlyFavoritesOnMap = showOnlyFavorites + val showOnlyFavoritesOnMap: StateFlow = showOnlyFavorites.asStateFlow() fun toggleOnlyFavorites() { val newValue = !showOnlyFavorites.value @@ -100,7 +107,7 @@ open class BaseMapViewModel( } private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value) - val showWaypointsOnMap = showWaypoints + val showWaypointsOnMap: StateFlow = showWaypoints.asStateFlow() fun toggleShowWaypointsOnMap() { val newValue = !showWaypoints.value @@ -109,7 +116,7 @@ open class BaseMapViewModel( } private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value) - val showPrecisionCircleOnMap = showPrecisionCircle + val showPrecisionCircleOnMap: StateFlow = showPrecisionCircle.asStateFlow() fun toggleShowPrecisionCircleOnMap() { val newValue = !showPrecisionCircle.value @@ -118,7 +125,7 @@ open class BaseMapViewModel( } private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value)) - val lastHeardFilter = lastHeardFilterValue + val lastHeardFilter: StateFlow = lastHeardFilterValue.asStateFlow() fun setLastHeardFilter(filter: LastHeardFilter) { lastHeardFilterValue.value = filter @@ -127,7 +134,7 @@ open class BaseMapViewModel( private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value)) - val lastHeardTrackFilter = lastHeardTrackFilterValue + val lastHeardTrackFilter: StateFlow = lastHeardTrackFilterValue.asStateFlow() fun setLastHeardTrackFilter(filter: LastHeardFilter) { lastHeardTrackFilterValue.value = filter @@ -139,7 +146,8 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = + safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -151,7 +159,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } + safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() @@ -186,16 +194,42 @@ open class BaseMapViewModel( ) } +/** + * Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances. + * + * @property overlayNodeNums All unique node nums referenced by the traceroute. + * @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available). + * @property nodeLookup Node-num-keyed map for polyline coordinate resolution. + */ data class TracerouteNodeSelection( val overlayNodeNums: Set, val nodesForMarkers: List, val nodeLookup: Map, ) +/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */ fun BaseMapViewModel.tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, +): TracerouteNodeSelection = tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = nodes, + getNodeOrFallback = ::getNodeOrFallback, +) + +/** + * Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute + * time) take priority over live positions from the node database. + * + * @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB. + */ +fun tracerouteNodeSelection( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + nodes: List, + getNodeOrFallback: (Int) -> Node, ): TracerouteNodeSelection { val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet() val tracerouteSnapshotNodes = diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt similarity index 87% rename from app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 0d5a79cdb..a8bce5529 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/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.app.map.component +package org.meshtastic.feature.map.component import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon @@ -24,13 +24,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +/** + * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance + * across both Google and F-Droid flavors. + */ @Composable fun MapButton( - modifier: Modifier = Modifier, icon: ImageVector, - iconTint: Color? = null, contentDescription: String, onClick: () -> Unit, + modifier: Modifier = Modifier, + iconTint: Color? = null, ) { FilledIconButton(onClick = onClick, modifier = modifier) { Icon( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt new file mode 100644 index 000000000..431354e6d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -0,0 +1,141 @@ +/* + * 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.map.component + +import androidx.compose.foundation.layout.Box +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 +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map_filter +import org.meshtastic.core.resources.orient_north +import org.meshtastic.core.resources.refresh +import org.meshtastic.core.resources.toggle_my_position +import org.meshtastic.core.ui.icon.LocationDisabled +import org.meshtastic.core.ui.icon.MapCompass +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MyLocation +import org.meshtastic.core.ui.icon.Refresh +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). + * + * @param onToggleFilterMenu Callback to open/close the filter dropdown. + * @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a + * `DropdownMenu` with filter options. + * @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type + * and custom tile options; F-Droid provides a tile source selector. + * @param layersContent Optional composable for a layers management button. + * @param showRefresh Whether to show a refresh button (e.g., for network map layers). + * @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( + onToggleFilterMenu: () -> Unit, + modifier: Modifier = Modifier, + bearing: Float = 0f, + onCompassClick: () -> Unit = {}, + followPhoneBearing: Boolean = false, + filterDropdownContent: @Composable () -> Unit = {}, + mapTypeContent: @Composable () -> Unit = {}, + layersContent: @Composable () -> Unit = {}, + isLocationTrackingEnabled: Boolean = false, + onToggleLocationTracking: () -> Unit = {}, + showRefresh: Boolean = false, + isRefreshing: Boolean = false, + onRefresh: () -> Unit = {}, +) { + HorizontalFloatingToolbar( + expanded = true, + modifier = modifier, + colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), + ) { + // Compass + CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) + + // Filter button + dropdown + Box { + MapButton( + icon = MeshtasticIcons.Tune, + contentDescription = stringResource(Res.string.map_filter), + onClick = onToggleFilterMenu, + ) + filterDropdownContent() + } + + // Map type selector (flavor-specific) + mapTypeContent() + + // Layers button (flavor-specific) + layersContent() + + // Refresh button (optional) + if (showRefresh) { + if (isRefreshing) { + Box(modifier = Modifier.padding(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } else { + MapButton( + icon = MeshtasticIcons.Refresh, + contentDescription = stringResource(Res.string.refresh), + onClick = onRefresh, + ) + } + } + + // Location tracking button + MapButton( + icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation, + contentDescription = stringResource(Res.string.toggle_my_position), + onClick = onToggleLocationTracking, + ) + } +} + +@Composable +private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) { + val iconTint = + when { + isFollowing -> MaterialTheme.colorScheme.primary + bearing == 0f -> MaterialTheme.colorScheme.StatusRed + else -> null + } + MapButton( + modifier = Modifier.rotate(-bearing), + icon = MeshtasticIcons.MapCompass, + iconTint = iconTint, + contentDescription = stringResource(Res.string.orient_north), + onClick = onClick, + ) +} 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 fb921bdde..8d2af9c4d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -19,15 +19,15 @@ package org.meshtastic.feature.map.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.meshtastic.core.navigation.MapRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.MapRoute +import org.meshtastic.core.navigation.NodesRoute fun EntryProviderScope.mapGraph(backStack: NavBackStack) { - entry { args -> + entry { args -> val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current mapScreen( - { backStack.add(NodesRoutes.NodeDetail(it)) }, // onClickNodeChip - { backStack.add(NodesRoutes.NodeDetail(it)) }, // navigateToNodeDetails + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails args.waypointId, ) } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt new file mode 100644 index 000000000..052e85da9 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt @@ -0,0 +1,49 @@ +/* + * 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.map + +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress("MagicNumber") +class LastHeardFilterTest { + + @Test + fun fromSeconds_knownValues() { + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L)) + assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L)) + assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L)) + assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L)) + assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L)) + } + + @Test + fun fromSeconds_unknownValue_defaultsToAny() { + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L)) + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L)) + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE)) + } + + @Test + fun seconds_matchExpectedValues() { + assertEquals(0L, LastHeardFilter.Any.seconds) + assertEquals(3600L, LastHeardFilter.OneHour.seconds) + assertEquals(28800L, LastHeardFilter.EightHours.seconds) + assertEquals(86400L, LastHeardFilter.OneDay.seconds) + assertEquals(172800L, LastHeardFilter.TwoDays.seconds) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt new file mode 100644 index 000000000..76ae25066 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt @@ -0,0 +1,214 @@ +/* + * 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.map + +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TracerouteNodeSelectionTest { + + private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node = + Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI)) + + private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position()) + + private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) } + + // ---- Null overlay (no traceroute active) ---- + + @Test + fun nullOverlay_returnsAllNodesUnfiltered() { + val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = null, + tracerouteNodePositions = emptyMap(), + nodes = nodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertEquals(emptySet(), result.overlayNodeNums) + assertEquals(3, result.nodesForMarkers.size) + assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet()) + } + + @Test + fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() { + val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = null, + tracerouteNodePositions = emptyMap(), + nodes = nodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + // nodeLookup filters to validPosition nodes when no snapshot + assertEquals(setOf(1, 3), result.nodeLookup.keys) + } + + // ---- Overlay with snapshot positions ---- + + @Test + fun overlayWithSnapshot_usesSnapshotPositions() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + ) + val liveNodes = + listOf( + nodeWithPosition(10, latI = 100000000, lonI = -100000000), + nodeWithPosition(20, latI = 200000000, lonI = -200000000), + nodeWithPosition(30), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = liveNodes, + getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) }, + ) + + // Should use snapshot positions, not live ones + assertEquals(setOf(10, 20), result.overlayNodeNums) + assertEquals(2, result.nodesForMarkers.size) + assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i) + assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i) + } + + @Test + fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> Node(num = num) }, + ) + + assertEquals(2, result.nodeLookup.size) + assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i) + } + + @Test + fun overlayWithSnapshot_filtersToOverlayNodes() { + // Snapshot has node 30 which is NOT in the overlay routes + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + 30 to Position(latitude_i = 420000000, longitude_i = -720000000), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> Node(num = num) }, + ) + + // nodesForMarkers should only contain nodes in the overlay (10, 20), not 30 + assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet()) + // but nodeLookup has all snapshot nodes (for polyline drawing) + assertEquals(3, result.nodeLookup.size) + } + + // ---- Overlay without snapshot positions (live fallback) ---- + + @Test + fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30)) + val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertEquals(setOf(10, 20, 30), result.overlayNodeNums) + assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet()) + } + + @Test + fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + // nodeLookup only includes nodes with validPosition + assertEquals(setOf(10), result.nodeLookup.keys) + } + + // ---- Edge cases ---- + + @Test + fun emptyOverlayRoutes_yieldsEmptySelection() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList()) + val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertTrue(result.overlayNodeNums.isEmpty()) + assertTrue(result.nodesForMarkers.isEmpty()) + } + + @Test + fun getNodeOrFallback_usedForSnapshotNodeLookup() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10)) + val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000)) + var lookupCalledWith: Int? = null + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> + lookupCalledWith = num + Node(num = num) + }, + ) + + assertEquals(10, lookupCalledWith) + assertEquals(1, result.nodesForMarkers.size) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt index c19881280..2e0cbaed7 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.model +import org.meshtastic.core.model.TracerouteOverlay import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index e6634e0a1..f2887d98a 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { android { namespace = "org.meshtastic.feature.messaging" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -43,7 +42,6 @@ kotlin { implementation(projects.core.ui) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.jetbrains.navigationevent.compose) implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.compose) @@ -56,12 +54,8 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } - val androidHostTest by getting { - dependencies { - implementation(libs.androidx.work.testing) - implementation(libs.androidx.test.core) - implementation(libs.robolectric) - } - } + commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } 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 7be0b4027..8cc621e1c 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 @@ -35,8 +35,6 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -56,6 +54,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -66,6 +65,7 @@ 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 @@ -76,6 +76,8 @@ import org.meshtastic.core.resources.type_a_message import org.meshtastic.core.resources.unknown_channel import org.meshtastic.core.ui.component.SharedContactDialog import org.meshtastic.core.ui.component.smartScrollToIndex +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Send import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.createClipEntry import org.meshtastic.feature.messaging.component.ActionModeTopBar @@ -326,7 +328,7 @@ fun MessageScreen( Column { AnimatedVisibility(visible = showQuickChat) { QuickChatRow( - enabled = connectionState.isConnected(), + enabled = connectionState is ConnectionState.Connected, actions = quickChatActions, onClick = { action -> handleQuickChatAction( @@ -343,7 +345,7 @@ fun MessageScreen( ourNode = ourNode, ) MessageInput( - isEnabled = connectionState.isConnected(), + isEnabled = connectionState is ConnectionState.Connected, isHomoglyphEncodingEnabled = homoglyphEncodingEnabled, textFieldState = messageInputState, onSendMessage = { @@ -460,7 +462,9 @@ private fun MessageInput( shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()), isError = isOverLimit, placeholder = { Text(stringResource(Res.string.type_a_message)) }, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + keyboardOptions = + KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSendMessage() }, supportingText = { if (isEnabled) { // Only show supporting text if input is enabled Text( @@ -483,10 +487,7 @@ private fun MessageInput( // cursor position and multi-byte characters, likely outside simple inputTransformation. trailingIcon = { IconButton(onClick = { if (canSend) onSendMessage() }, enabled = canSend) { - Icon( - imageVector = Icons.AutoMirrored.Default.Send, - contentDescription = stringResource(Res.string.send), - ) + Icon(imageVector = MeshtasticIcons.Send, contentDescription = stringResource(Res.string.send)) } }, ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 9cd435f82..9a742a4ea 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -44,8 +43,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType @@ -452,23 +450,12 @@ private fun UpdateUnreadCountPaged( onUnreadChange: (Long, Long) -> Unit, ) { val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) - val lifecycleOwner = LocalLifecycleOwner.current - var isResumed by remember { - mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) - } + var isResumed by remember { mutableStateOf(false) } // Track lifecycle state changes - DisposableEffect(lifecycleOwner) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> isResumed = true - Lifecycle.Event.ON_PAUSE -> isResumed = false - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + LifecycleResumeEffect(Unit) { + isResumed = true + onPauseOrDispose { isResumed = false } } // Track remote message count to restart effect when remote messages change diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 7c57b46af..4d3e5679d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings @@ -49,6 +48,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -157,7 +157,7 @@ class MessageViewModel( } fun setTitle(title: String) { - viewModelScope.launch { _title.value = title } + _title.value = title } fun getMessagesFromPaged(contactKey: String): Flow> { @@ -190,7 +190,9 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -211,21 +213,21 @@ class MessageViewModel( * @param replyId The ID of the message this is a reply to, if any. */ fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { - viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) } + safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch { + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } + safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { - return@launch + return@safeLaunch } packetRepository.clearUnreadCount(contact, lastReadTimestamp) packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt index 02278d15b..4652664a3 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt @@ -31,11 +31,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.DragHandle -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.FastForward import androidx.compose.material3.Card import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -80,6 +75,11 @@ import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.component.dragContainer import org.meshtastic.core.ui.component.dragDropItemsIndexed import org.meshtastic.core.ui.component.rememberDragDropState +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.DragHandle +import org.meshtastic.core.ui.icon.Edit +import org.meshtastic.core.ui.icon.FastForward +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun QuickChatScreen(modifier: Modifier = Modifier, viewModel: QuickChatViewModel, onNavigateUp: () -> Unit) { @@ -135,7 +135,7 @@ fun QuickChatScreen(modifier: Modifier = Modifier, viewModel: QuickChatViewModel onClick = { showActionDialog = QuickChatAction(position = actions.size) }, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), ) { - Icon(imageVector = Icons.Rounded.Add, contentDescription = stringResource(Res.string.add)) + Icon(imageVector = MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add)) } } } @@ -215,9 +215,9 @@ internal fun EditQuickChatDialog( val (text, icon) = if (isInstant) { - Res.string.quick_chat_instant to Icons.Rounded.FastForward + Res.string.quick_chat_instant to MeshtasticIcons.FastForward } else { - Res.string.quick_chat_append to Icons.Rounded.Add + Res.string.quick_chat_append to MeshtasticIcons.Add } Row(verticalAlignment = Alignment.CenterVertically) { @@ -302,7 +302,7 @@ internal fun QuickChatItem( leadingContent = { if (action.mode == QuickChatAction.Mode.Instant) { Icon( - imageVector = Icons.Rounded.FastForward, + imageVector = MeshtasticIcons.FastForward, contentDescription = stringResource(Res.string.quick_chat_instant), ) } @@ -313,12 +313,12 @@ internal fun QuickChatItem( Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = { onEdit(action) }, modifier = Modifier.size(48.dp)) { Icon( - imageVector = Icons.Rounded.Edit, + imageVector = MeshtasticIcons.Edit, contentDescription = stringResource(Res.string.quick_chat_edit), ) } Icon( - imageVector = Icons.Rounded.DragHandle, + imageVector = MeshtasticIcons.DragHandle, contentDescription = stringResource(Res.string.quick_chat), ) } diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index 53d023d08..6451b8885 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @KoinViewModel @@ -31,7 +30,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "updateActionPositions") { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -39,8 +38,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } + safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } + safeLaunch(context = ioDispatcher, tag = "deleteQuickChatAction") { quickChatActionRepository.delete(action) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt index b3ea63ca1..badac0f37 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt @@ -20,17 +20,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.rounded.AddReaction -import androidx.compose.material.icons.twotone.AddLink -import androidx.compose.material.icons.twotone.Cloud -import androidx.compose.material.icons.twotone.CloudDone -import androidx.compose.material.icons.twotone.CloudOff -import androidx.compose.material.icons.twotone.CloudUpload -import androidx.compose.material.icons.twotone.HowToReg -import androidx.compose.material.icons.twotone.Link -import androidx.compose.material.icons.twotone.Warning import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -46,6 +35,17 @@ import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.resources.react import org.meshtastic.core.resources.reply import org.meshtastic.core.ui.emoji.EmojiPickerDialog +import org.meshtastic.core.ui.icon.Acknowledged +import org.meshtastic.core.ui.icon.AddLink +import org.meshtastic.core.ui.icon.AddReaction +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.Reply +import org.meshtastic.core.ui.icon.Warning @Composable internal fun ReactionButton(onSendReaction: (String) -> Unit = {}) { @@ -60,16 +60,14 @@ internal fun ReactionButton(onSendReaction: (String) -> Unit = {}) { ) } IconButton(onClick = { showEmojiPickerDialog = true }) { - Icon(imageVector = Icons.Rounded.AddReaction, contentDescription = stringResource(Res.string.react)) + Icon(imageVector = MeshtasticIcons.AddReaction, contentDescription = stringResource(Res.string.react)) } } @Composable private fun ReplyButton(onClick: () -> Unit = {}) = IconButton( onClick = onClick, - content = { - Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = stringResource(Res.string.reply)) - }, + content = { Icon(imageVector = MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, ) @Composable @@ -80,14 +78,14 @@ internal fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: Message Icon( imageVector = when (currentStatus) { - MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg - MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload - MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone - MessageStatus.SFPP_ROUTING -> Icons.TwoTone.AddLink - MessageStatus.SFPP_CONFIRMED -> Icons.TwoTone.Link - MessageStatus.ENROUTE -> Icons.TwoTone.Cloud - MessageStatus.ERROR -> Icons.TwoTone.CloudOff - else -> Icons.TwoTone.Warning + MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged + MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload + MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered + MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink + MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon + MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute + MessageStatus.ERROR -> MeshtasticIcons.MessageError + else -> MeshtasticIcons.Warning }, contentDescription = stringResource(Res.string.message_delivery_status), ) 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 b89a88984..5ffb5ea1d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -26,12 +26,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Reply -import androidx.compose.material.icons.rounded.AddReaction -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -42,12 +36,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_copy_message +import org.meshtastic.core.resources.action_delete_message +import org.meshtastic.core.resources.action_react_with_emoji +import org.meshtastic.core.resources.action_select_message +import org.meshtastic.core.resources.action_send_reply +import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.device_metrics_label_value @@ -55,7 +55,14 @@ import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.resources.more_reactions import org.meshtastic.core.resources.reply import org.meshtastic.core.resources.select +import org.meshtastic.core.ui.icon.AddReaction +import org.meshtastic.core.ui.icon.Copy +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Reply +import org.meshtastic.core.ui.icon.SelectAll +@Suppress("LongMethod") @Composable fun MessageActionsContent( quickEmojis: List, @@ -84,34 +91,59 @@ fun MessageActionsContent( Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty())) }, leadingContent = { MessageStatusIcon(status = status) }, - modifier = Modifier.clickable(onClick = onStatus), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_show_message_status), + role = Role.Button, + onClick = onStatus, + ), ) } ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, - leadingContent = { - Icon(Icons.AutoMirrored.Rounded.Reply, contentDescription = stringResource(Res.string.reply)) - }, - modifier = Modifier.clickable(onClick = onReply), + leadingContent = { Icon(MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_send_reply), + role = Role.Button, + onClick = onReply, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.copy)) }, - leadingContent = { Icon(Icons.Rounded.ContentCopy, contentDescription = stringResource(Res.string.copy)) }, - modifier = Modifier.clickable(onClick = onCopy), + leadingContent = { Icon(MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) }, + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_copy_message), + role = Role.Button, + onClick = onCopy, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.select)) }, - leadingContent = { Icon(Icons.Rounded.SelectAll, contentDescription = stringResource(Res.string.select)) }, - modifier = Modifier.clickable(onClick = onSelect), + leadingContent = { + Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select)) + }, + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_message), + role = Role.Button, + onClick = onSelect, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.delete)) }, - leadingContent = { Icon(Icons.Rounded.Delete, contentDescription = stringResource(Res.string.delete)) }, - modifier = Modifier.clickable(onClick = onDelete), + leadingContent = { Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) }, + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_delete_message), + role = Role.Button, + onClick = onDelete, + ), ) } } @@ -131,10 +163,15 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, Modifier.size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onReact(emoji) }, + .clickable( + onClickLabel = stringResource(Res.string.action_react_with_emoji), + role = Role.Button, + ) { + onReact(emoji) + }, contentAlignment = Alignment.Center, ) { - Text(text = emoji, fontSize = 20.sp) + Text(text = emoji, style = MaterialTheme.typography.titleMedium) } } @@ -143,7 +180,7 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, modifier = Modifier.size(40.dp).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), ) { Icon( - Icons.Rounded.AddReaction, + MeshtasticIcons.AddReaction, contentDescription = stringResource(Res.string.more_reactions), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, 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 261fb0948..7d8747eb8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -29,16 +29,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FormatQuote -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 @@ -49,8 +45,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -71,8 +70,11 @@ import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr import org.meshtastic.core.ui.component.TransportIcon import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.Hops +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 @@ -176,7 +178,9 @@ fun MessageItem( } val containsBel = message.text.contains('\u0007') + val contrastLevel = LocalContrastLevel.current + val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second) val alpha = if (message.filtered) { FILTERED_ALPHA @@ -185,15 +189,31 @@ fun MessageItem( } else { NORMAL_ALPHA } + val containerColor = - if (message.fromLocal) { - Color(ourNode.colors.second).copy(alpha = alpha) - } else { - Color(node.colors.second).copy(alpha = alpha) + when (contrastLevel) { + ContrastLevel.HIGH -> + when { + message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow + inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest + inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } + ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f)) + ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha) + } + val contentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first) + } + val metadataStyle = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall + else -> MaterialTheme.typography.labelSmall } - val cardColors = - CardDefaults.cardColors() - .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) val messageShape = getMessageBubbleShape( cornerRadius = 8.dp, @@ -207,7 +227,12 @@ fun MessageItem( if (containsBel) { Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape) } else { - Modifier + when (contrastLevel) { + ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape) + ContrastLevel.MEDIUM -> + Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape) + ContrastLevel.STANDARD -> Modifier + } }, ) val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name @@ -245,9 +270,12 @@ fun MessageItem( onDoubleClick = onDoubleClick, ) .then(messageModifier) - .semantics(mergeDescendants = true) { contentDescription = messageA11yText }, + .semantics(mergeDescendants = true) { + contentDescription = messageA11yText + role = Role.Button + }, color = containerColor, - contentColor = contentColorFor(containerColor), + contentColor = contentColor, shape = messageShape, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { @@ -255,16 +283,11 @@ fun MessageItem( modifier = Modifier.fillMaxWidth(), message = message, ourNode = ourNode, - hasSamePrev = hasSamePrev, onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { - AutoLinkText( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = cardColors.contentColor, - ) + AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor) Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { if (!message.fromLocal) { @@ -279,10 +302,13 @@ fun MessageItem( horizontalArrangement = Arrangement.spacedBy(2.dp), ) { Icon( - imageVector = MeshtasticIcons.Hops, + imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), - tint = cardColors.contentColor.copy(alpha = 0.7f), + tint = + contentColor.copy( + alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f, + ), ) Text( text = @@ -291,7 +317,7 @@ fun MessageItem( } else { "?" }, - style = MaterialTheme.typography.labelSmall, + style = metadataStyle, ) } } @@ -307,8 +333,13 @@ fun MessageItem( if (message.filtered) { Text( text = stringResource(Res.string.filter_message_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = metadataStyle, + color = + if (contrastLevel == ContrastLevel.HIGH) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } @@ -319,11 +350,7 @@ fun MessageItem( ) } Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.padding(start = 16.dp), - text = message.time, - style = MaterialTheme.typography.labelSmall, - ) + Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) } } } @@ -357,30 +384,33 @@ private enum class ActiveSheet { private fun OriginalMessageSnippet( message: Message, ourNode: Node, - hasSamePrev: Boolean, onNavigateToOriginalMessage: (Int) -> Unit, modifier: Modifier = Modifier, ) { val originalMessage = message.originalMessage if (originalMessage != null && originalMessage.packetId != 0) { val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node - val cardColors = - CardDefaults.cardColors() - .copy( - containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f), - contentColor = Color(originalMessageNode.colors.first), - ) + val contrastLevel = LocalContrastLevel.current + val replyContainerColor = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer + else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f) + } + val replyContentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first) + } + // Rectangle shape — the outer message bubble's Surface clips to its + // rounded corners, so the reply header inherits the correct top radii + // automatically and stays square on the bottom where body text follows. Surface( modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, - contentColor = cardColors.contentColor, - color = cardColors.containerColor, - shape = - getMessageBubbleShape( - cornerRadius = 16.dp, - isSender = originalMessage.fromLocal, - hasSamePrev = hasSamePrev, - hasSameNext = true, // always square off original message bottom - ), + contentColor = replyContentColor, + color = replyContainerColor, + shape = RectangleShape, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), @@ -388,7 +418,7 @@ private fun OriginalMessageSnippet( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - Icons.Rounded.FormatQuote, + MeshtasticIcons.FormatQuote, contentDescription = stringResource(Res.string.reply), modifier = Modifier.size(16.dp), ) diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index 456df7eb2..6416337df 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -32,23 +32,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.rounded.SpeakerNotes -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ChatBubbleOutline -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.FilterList -import androidx.compose.material.icons.rounded.FilterListOff -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.SelectAll -import androidx.compose.material.icons.rounded.SpeakerNotesOff -import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button @@ -85,7 +68,6 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.alert_bell_text import org.meshtastic.core.resources.cancel_reply import org.meshtastic.core.resources.clear_selection -import org.meshtastic.core.resources.conversations import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.delete_messages @@ -94,7 +76,6 @@ import org.meshtastic.core.resources.filter_disable_for_contact import org.meshtastic.core.resources.filter_enable_for_contact import org.meshtastic.core.resources.filter_hide_count import org.meshtastic.core.resources.filter_show_count -import org.meshtastic.core.resources.message_input_label import org.meshtastic.core.resources.navigate_back import org.meshtastic.core.resources.new_messages_below import org.meshtastic.core.resources.overflow_menu @@ -105,15 +86,26 @@ import org.meshtastic.core.resources.reply import org.meshtastic.core.resources.replying_to import org.meshtastic.core.resources.scroll_to_bottom import org.meshtastic.core.resources.select_all -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.type_a_message import org.meshtastic.core.resources.unknown -import org.meshtastic.core.ui.component.EmptyDetailPlaceholder import org.meshtastic.core.ui.component.MeshtasticTextDialog import org.meshtastic.core.ui.component.NodeKeyStatusIcon import org.meshtastic.core.ui.component.SecurityIcon -import org.meshtastic.core.ui.icon.Conversations +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.ArrowDownward +import org.meshtastic.core.ui.icon.ChatBubbleOutline +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.Copy +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.FilterList +import org.meshtastic.core.ui.icon.FilterListOff import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.More +import org.meshtastic.core.ui.icon.Muted +import org.meshtastic.core.ui.icon.Reply +import org.meshtastic.core.ui.icon.SelectAll +import org.meshtastic.core.ui.icon.Unmuted +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff import org.meshtastic.feature.messaging.DeliveryInfo import org.meshtastic.proto.ChannelSet @@ -136,13 +128,13 @@ fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyLi if (unreadCount > 0) { BadgedBox(badge = { Badge { Text(unreadCount.toString()) } }) { Icon( - imageVector = Icons.Rounded.ArrowDownward, + imageVector = MeshtasticIcons.ArrowDownward, contentDescription = stringResource(Res.string.scroll_to_bottom), ) } } else { Icon( - imageVector = Icons.Rounded.ArrowDownward, + imageVector = MeshtasticIcons.ArrowDownward, contentDescription = stringResource(Res.string.scroll_to_bottom), ) } @@ -178,7 +170,7 @@ fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ourNode: N horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - imageVector = Icons.AutoMirrored.Default.Reply, + imageVector = MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -194,7 +186,7 @@ fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ourNode: N overflow = TextOverflow.Ellipsis, ) IconButton(onClick = onClearReply) { - Icon(Icons.Filled.Close, contentDescription = stringResource(Res.string.cancel_reply)) + Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.cancel_reply)) } } } @@ -253,20 +245,23 @@ fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) navigationIcon = { IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = MeshtasticIcons.ArrowBack, contentDescription = stringResource(Res.string.clear_selection), ) } }, actions = { IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) { - Icon(imageVector = Icons.Rounded.ContentCopy, contentDescription = stringResource(Res.string.copy)) + Icon(imageVector = MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) } IconButton(onClick = { onAction(MessageMenuAction.Delete) }) { - Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.delete)) + Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) } IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) { - Icon(imageVector = Icons.Rounded.SelectAll, contentDescription = stringResource(Res.string.select_all)) + Icon( + imageVector = MeshtasticIcons.SelectAll, + contentDescription = stringResource(Res.string.select_all), + ) } }, ) @@ -316,7 +311,7 @@ fun MessageTopBar( navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = MeshtasticIcons.ArrowBack, contentDescription = stringResource(Res.string.navigate_back), ) } @@ -356,7 +351,7 @@ private fun MessageTopBarActions( var expanded by remember { mutableStateOf(false) } Box { IconButton(onClick = { expanded = true }, enabled = true) { - Icon(imageVector = Icons.Rounded.MoreVert, contentDescription = stringResource(Res.string.overflow_menu)) + Icon(imageVector = MeshtasticIcons.More, contentDescription = stringResource(Res.string.overflow_menu)) } OverFlowMenu( expanded = expanded, @@ -409,8 +404,7 @@ private fun QuickChatToggleMenuItem(showQuickChat: Boolean, onDismiss: () -> Uni }, leadingIcon = { Icon( - imageVector = - if (showQuickChat) Icons.Rounded.SpeakerNotesOff else Icons.AutoMirrored.Rounded.SpeakerNotes, + imageVector = if (showQuickChat) MeshtasticIcons.Muted else MeshtasticIcons.Unmuted, contentDescription = title, ) }, @@ -426,7 +420,7 @@ private fun QuickChatOptionsMenuItem(onDismiss: () -> Unit, onNavigate: () -> Un onDismiss() onNavigate() }, - leadingIcon = { Icon(imageVector = Icons.Rounded.ChatBubbleOutline, contentDescription = title) }, + leadingIcon = { Icon(imageVector = MeshtasticIcons.ChatBubbleOutline, contentDescription = title) }, ) } @@ -441,7 +435,7 @@ private fun FilteredMessagesMenuItem(showFiltered: Boolean, count: Int, onDismis }, leadingIcon = { Icon( - imageVector = if (showFiltered) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + imageVector = if (showFiltered) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, contentDescription = title, ) }, @@ -462,7 +456,7 @@ private fun FilterToggleMenuItem(filteringDisabled: Boolean, onDismiss: () -> Un }, leadingIcon = { Icon( - imageVector = if (filteringDisabled) Icons.Rounded.FilterList else Icons.Rounded.FilterListOff, + imageVector = if (filteringDisabled) MeshtasticIcons.FilterList else MeshtasticIcons.FilterListOff, contentDescription = title, ) }, @@ -599,99 +593,8 @@ fun MessageStatusDialog( // endregion -// region ── EmptyConversationsPlaceholder ── - -@Composable -fun EmptyConversationsPlaceholder(modifier: Modifier = Modifier) { - EmptyDetailPlaceholder( - icon = MeshtasticIcons.Conversations, - title = stringResource(Res.string.conversations), - modifier = modifier, - ) -} - -// endregion - -// region ── MessageInput ── - -/** - * Shared message input field with send button, byte counter, and homoglyph encoding support. - * - * @param messageText The current message text. - * @param onMessageChange Callback when the text changes. - * @param onSendMessage Callback when the send button is pressed. - * @param isEnabled Whether the input field should be enabled. - * @param isHomoglyphEncodingEnabled Whether to optimize text using homoglyph encoding. - * @param maxByteSize The maximum allowed size of the message in bytes. - */ -@Composable -fun MessageInput( - messageText: String, - onMessageChange: (String) -> Unit, - onSendMessage: () -> Unit, - isEnabled: Boolean, - modifier: Modifier = Modifier, - isHomoglyphEncodingEnabled: Boolean = false, - maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES, -) { - val currentText = - if (isHomoglyphEncodingEnabled) { - org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs( - messageText, - ) - } else { - messageText - } - - val currentByteLength = remember(currentText) { currentText.encodeToByteArray().size } - - val isOverLimit = currentByteLength > maxByteSize - val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled - - androidx.compose.material3.OutlinedTextField( - modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - value = messageText, - onValueChange = onMessageChange, - maxLines = MAX_INPUT_LINES, - label = { Text(stringResource(Res.string.message_input_label)) }, - enabled = isEnabled, - shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()), - isError = isOverLimit, - placeholder = { Text(stringResource(Res.string.type_a_message)) }, - supportingText = { - if (isEnabled) { - Text( - text = "$currentByteLength/$maxByteSize", - style = MaterialTheme.typography.bodySmall, - color = - if (isOverLimit) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.End, - ) - } - }, - trailingIcon = { - IconButton(onClick = { if (canSend) onSendMessage() }, enabled = canSend) { - Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(Res.string.send)) - } - }, - ) -} - -// endregion - // region ── Utility Functions ── -/** Maximum number of lines for the message input field. */ -private const val MAX_INPUT_LINES = 3 - -/** Corner radius percentage for the message input field. */ -private const val ROUNDED_CORNER_PERCENT = 100 - /** The maximum number of characters to display in the reply snippet. */ internal const val SNIPPET_CHARACTER_LIMIT = 50 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 329164f42..7b361d497 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt @@ -24,11 +24,13 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.ui.icon.Acknowledged -import org.meshtastic.core.ui.icon.CloudDone -import org.meshtastic.core.ui.icon.CloudOffTwoTone -import org.meshtastic.core.ui.icon.CloudSync -import org.meshtastic.core.ui.icon.CloudTwoTone +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.Warning @Composable @@ -36,12 +38,12 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { val icon = when (status) { MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged - MessageStatus.QUEUED -> MeshtasticIcons.CloudSync - MessageStatus.DELIVERED -> MeshtasticIcons.CloudDone - MessageStatus.SFPP_ROUTING -> MeshtasticIcons.CloudSync - MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.CloudDone - MessageStatus.ENROUTE -> MeshtasticIcons.CloudTwoTone - MessageStatus.ERROR -> MeshtasticIcons.CloudOffTwoTone + MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload + MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered + MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink + MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon + MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute + MessageStatus.ERROR -> MeshtasticIcons.MessageError else -> MeshtasticIcons.Warning } Icon( 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 6f7cba05d..9b8267793 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AddReaction import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -75,7 +73,8 @@ import org.meshtastic.core.ui.component.BottomSheetDialog import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.Hops +import org.meshtastic.core.ui.icon.AddReaction +import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.messaging.DeliveryInfo @@ -124,7 +123,6 @@ internal fun ReactionItem( text = emojiCount.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -145,7 +143,7 @@ internal fun ReactionRow( AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) { LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(emojiGroups.entries.toList()) { entry -> + items(emojiGroups.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -182,7 +180,7 @@ internal fun AddReactionButton(modifier: Modifier = Modifier, onSendReaction: (S border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), ) { Icon( - imageVector = Icons.Rounded.AddReaction, + imageVector = MeshtasticIcons.AddReaction, contentDescription = stringResource(Res.string.react), modifier = Modifier.padding(6.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -239,7 +237,7 @@ internal fun ReactionDialog( } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - items(groupedEmojis.entries.toList()) { entry -> + items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -249,7 +247,13 @@ internal fun ReactionDialog( text = "$emoji${reactions.size}", modifier = Modifier.clip(CircleShape) - .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) + .background( + if (selectedEmoji == emoji) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + }, + ) .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier) .padding(8.dp) .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, @@ -261,7 +265,7 @@ internal fun ReactionDialog( HorizontalDivider(Modifier.padding(vertical = 8.dp)) LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(filteredReactions) { reaction -> + items(filteredReactions, key = { reaction -> "${reaction.user.id}:${reaction.emoji}" }) { reaction -> Column(modifier = Modifier.padding(horizontal = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -305,7 +309,7 @@ internal fun ReactionDialog( horizontalArrangement = Arrangement.spacedBy(2.dp), ) { Icon( - imageVector = MeshtasticIcons.Hops, + imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 1e83f8039..62b57d3a8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -21,14 +21,15 @@ 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 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.replaceLast import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.feature.messaging.QuickChatScreen @@ -43,15 +44,15 @@ fun EntryProviderScope.contactsGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), ) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) } - entry(metadata = { ListDetailSceneStrategy.listPane() }) { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) } - entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> val contactKey = args.contactKey val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel = koinViewModel(key = "messages-$contactKey") @@ -61,25 +62,26 @@ fun EntryProviderScope.contactsGraph( contactKey = contactKey, message = args.message, viewModel = messageViewModel, - navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetail(it)) }, - navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) }, - onNavigateBack = { backStack.removeLastOrNull() }, + navigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + navigateToQuickChatOptions = + dropUnlessResumed { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, + onNavigateBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val message = args.message val viewModel = koinViewModel() ShareScreen( viewModel = viewModel, - onConfirm = { contactKey -> backStack.replaceLast(ContactsRoutes.Messages(contactKey, message)) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onConfirm = { contactKey -> backStack.replaceLast(ContactsRoute.Messages(contactKey, message)) }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { val viewModel = koinViewModel() - QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + QuickChatScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index df3d5a7ad..1607ffa5d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -20,10 +20,10 @@ import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.common.util.MeshtasticUri -import org.meshtastic.core.navigation.ChannelsRoutes -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.navigation.ChannelsRoute +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact @@ -35,21 +35,21 @@ fun AdaptiveContactsScreen( scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, ) { ContactsScreen( - onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + onNavigateToShare = { backStack.add(ChannelsRoute.ChannelsGraph) }, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = onHandleDeepLink, onClearSharedContactRequested = onClearSharedContactRequested, onClearRequestChannelUrl = onClearRequestChannelUrl, viewModel = contactsViewModel, - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetail(it)) }, - onNavigateToMessages = { contactKey -> backStack.add(ContactsRoutes.Messages(contactKey)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetail(it)) }, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, + onNavigateToMessages = { contactKey -> backStack.add(ContactsRoute.Messages(contactKey)) }, + onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, scrollToTopEvents = scrollToTopEvents, activeContactKey = null, ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt index 00f518f0d..f2f897551 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt @@ -31,8 +31,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.twotone.VolumeOff import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card @@ -53,6 +51,8 @@ import androidx.compose.ui.unit.dp import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.Contact import org.meshtastic.core.ui.component.SecurityIcon +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.VolumeOff import org.meshtastic.proto.ChannelSet @Suppress("LongMethod") @@ -175,7 +175,7 @@ private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) { AnimatedVisibility(visible = contact.isMuted) { Icon( modifier = Modifier.padding(start = 4.dp).size(20.dp), - imageVector = Icons.AutoMirrored.TwoTone.VolumeOff, + imageVector = MeshtasticIcons.VolumeOff, contentDescription = null, ) } 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 6292f9ad9..7abaf6db6 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,9 +62,10 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants @@ -102,8 +104,8 @@ import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.MarkChatRead import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.SelectAll -import org.meshtastic.core.ui.icon.VolumeMuteTwoTone -import org.meshtastic.core.ui.icon.VolumeUpTwoTone +import org.meshtastic.core.ui.icon.VolumeMute +import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.core.ui.qr.ScannedQrCodeDialog import org.meshtastic.core.ui.util.rememberShowToastResource import org.meshtastic.proto.ChannelSet @@ -116,7 +118,7 @@ fun ContactsScreen( onNavigateToShare: () -> Unit, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, viewModel: ContactsViewModel, @@ -130,8 +132,8 @@ fun ContactsScreen( val scope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - var showMuteDialog by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } + var showMuteDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } // State for managing selected contacts val selectedContactKeys = remember { mutableStateListOf() } @@ -232,7 +234,7 @@ fun ContactsScreen( MainAppBar( title = stringResource(Res.string.conversations), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState.isConnected(), + showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, canNavigateUp = false, onNavigateUp = {}, actions = { @@ -250,11 +252,11 @@ fun ContactsScreen( ) }, floatingActionButton = { - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> - onHandleDeepLink(MeshtasticUri(uriString)) { + onHandleDeepLink(CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, @@ -455,9 +457,9 @@ private fun SelectionToolbar( Icon( imageVector = if (isAllMuted) { - MeshtasticIcons.VolumeUpTwoTone + MeshtasticIcons.VolumeUp } else { - MeshtasticIcons.VolumeMuteTwoTone + MeshtasticIcons.VolumeMute }, contentDescription = if (isAllMuted) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 865242cfb..f8aa46032 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact @@ -37,6 +36,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import kotlin.collections.map as collectionsMap @@ -188,17 +188,20 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } + safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = + safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } + safeLaunch(context = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } /** diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt index 7e896a86e..e02513fd5 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold @@ -42,6 +40,8 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.share import org.meshtastic.core.resources.share_to import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Send import org.meshtastic.feature.messaging.ui.contact.ContactItem import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel @@ -90,10 +90,7 @@ fun ShareScreen(contacts: List, onConfirm: (String) -> Unit, onNavigate modifier = Modifier.fillMaxWidth().padding(24.dp), enabled = selectedContact.isNotEmpty(), ) { - Icon( - imageVector = Icons.AutoMirrored.Default.Send, - contentDescription = stringResource(Res.string.share), - ) + Icon(imageVector = MeshtasticIcons.Send, contentDescription = stringResource(Res.string.share)) } } } diff --git a/feature/messaging/src/androidHostTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt similarity index 89% rename from feature/messaging/src/androidHostTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt rename to feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt index f75031fa8..30ec27f16 100644 --- a/feature/messaging/src/androidHostTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt @@ -27,8 +27,8 @@ class HomoglyphCharacterTransformTest { fun `optimizeUtf8StringWithHomoglyphs shrinks binary size of cyrillic text containing some homoglyphs`() { val testString = "Мештастик - это проект с открытым исходным кодом" val transformedTestString = HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(testString) - val testStringBytes = testString.toByteArray(charset = Charsets.UTF_8) - val transformedTestStringBytes = transformedTestString.toByteArray(charset = Charsets.UTF_8) + val testStringBytes = testString.encodeToByteArray() + val transformedTestStringBytes = transformedTestString.encodeToByteArray() val transformedStringBinarySizeShrinked = transformedTestStringBytes.size < testStringBytes.size assertTrue(transformedStringBinarySizeShrinked) } @@ -37,8 +37,8 @@ class HomoglyphCharacterTransformTest { fun `optimizeUtf8StringWithHomoglyphs shrinks binary size in half of cyrillic text containing only homoglyphs`() { val testString = "Косуха" val transformedTestString = HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(testString) - val testStringBytes = testString.toByteArray(charset = Charsets.UTF_8) - val transformedTestStringBytes = transformedTestString.toByteArray(charset = Charsets.UTF_8) + val testStringBytes = testString.encodeToByteArray() + val transformedTestStringBytes = transformedTestString.encodeToByteArray() assertEquals(transformedTestStringBytes.size, testStringBytes.size / 2) } diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt similarity index 81% rename from feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt rename to feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 30f65afff..cf45cb1ec 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -16,25 +16,21 @@ */ 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.tooling.preview.NodePreviewParameterProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class MessageItemTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun mqttIconIsDisplayedWhenViaMqttIsTrue() { + fun mqttIconIsDisplayedWhenViaMqttIsTrue() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithMqtt = Message( @@ -56,7 +52,7 @@ class MessageItemTest { viaMqtt = true, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithMqtt, node = testNode, @@ -69,11 +65,11 @@ class MessageItemTest { } // Check that the MQTT icon is displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertIsDisplayed() + onNodeWithContentDescription("via MQTT").assertIsDisplayed() } @Test - fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() { + fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithoutMqtt = Message( @@ -95,7 +91,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithoutMqtt, node = testNode, @@ -108,11 +104,11 @@ class MessageItemTest { } // Check that the MQTT icon is not displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist() + onNodeWithContentDescription("via MQTT").assertDoesNotExist() } @Test - fun messageItem_hasCorrectSemanticContentDescription() { + fun messageItem_hasCorrectSemanticContentDescription() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val message = Message( @@ -134,7 +130,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = message, node = testNode, @@ -147,8 +143,6 @@ class MessageItemTest { } // Verify that the node containing the message text exists and matches the text - composeTestRule - .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World") - .assertIsDisplayed() + onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed() } } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 8c6c3b746..0d89b55f6 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -49,7 +49,6 @@ kotlin { implementation(projects.feature.map) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.kotlinx.collections.immutable) implementation(libs.markdown.renderer) implementation(libs.markdown.renderer.m3) implementation(libs.vico.compose) @@ -62,21 +61,6 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.navigation3) } - androidMain.dependencies { - implementation(libs.androidx.appcompat) - - implementation(libs.markdown.renderer.android) - } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.robolectric) - implementation(libs.turbine) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.test.ext.junit) - } - } + androidMain.dependencies { implementation(libs.markdown.renderer.android) } } } diff --git a/feature/node/component/DeviceActions.kt b/feature/node/component/DeviceActions.kt deleted file mode 100644 index 103558c7e..000000000 --- a/feature/node/component/DeviceActions.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.feature.node.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Message -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.automirrored.outlined.VolumeMute -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.QrCode2 -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.actions -import org.meshtastic.core.resources.direct_message -import org.meshtastic.core.resources.favorite -import org.meshtastic.core.resources.ignore -import org.meshtastic.core.resources.mute_always -import org.meshtastic.core.resources.remove -import org.meshtastic.core.resources.share_contact -import org.meshtastic.core.resources.unmute -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.component.SwitchListItem -import org.meshtastic.feature.node.model.NodeDetailAction -import org.meshtastic.feature.node.model.isEffectivelyUnmessageable - -@Composable -fun DeviceActions( - node: Node, - lastTracerouteTime: Long?, - lastRequestNeighborsTime: Long?, - onAction: (NodeDetailAction) -> Unit, - modifier: Modifier = Modifier, - isLocal: Boolean = false, -) { - var displayFavoriteDialog by remember { mutableStateOf(false) } - var displayIgnoreDialog by remember { mutableStateOf(false) } - var displayMuteDialog by remember { mutableStateOf(false) } - var displayRemoveDialog by remember { mutableStateOf(false) } - - NodeActionDialogs( - node = node, - displayFavoriteDialog = displayFavoriteDialog, - displayIgnoreDialog = displayIgnoreDialog, - displayMuteDialog = displayMuteDialog, - displayRemoveDialog = displayRemoveDialog, - onDismissMenuRequest = { - displayFavoriteDialog = false - displayIgnoreDialog = false - displayMuteDialog = false - displayRemoveDialog = false - }, - onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) }, - onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) }, - onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) }, - onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) }, - ) - - ElevatedCard( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - shape = MaterialTheme.shapes.extraLarge, - ) { - DeviceActionsContent( - node = node, - isLocal = isLocal, - lastTracerouteTime = lastTracerouteTime, - lastRequestNeighborsTime = lastRequestNeighborsTime, - onAction = onAction, - onFavoriteClick = { displayFavoriteDialog = true }, - onIgnoreClick = { displayIgnoreDialog = true }, - onMuteClick = { displayMuteDialog = true }, - onRemoveClick = { displayRemoveDialog = true }, - ) - } -} - -@Composable -private fun DeviceActionsContent( - node: Node, - isLocal: Boolean, - lastTracerouteTime: Long?, - lastRequestNeighborsTime: Long?, - onAction: (NodeDetailAction) -> Unit, - onFavoriteClick: () -> Unit, - onIgnoreClick: () -> Unit, - onMuteClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Column(modifier = Modifier.padding(vertical = 12.dp)) { - Text( - text = stringResource(Res.string.actions), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) - - PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick) - - if (!isLocal) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - RemoteDeviceActions( - node = node, - lastTracerouteTime = lastTracerouteTime, - lastRequestNeighborsTime = lastRequestNeighborsTime, - onAction = onAction, - ) - } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - ManagementActions(node, onIgnoreClick, onMuteClick, onRemoveClick) - } -} - -@Composable -private fun PrimaryActionsRow( - node: Node, - isLocal: Boolean, - onAction: (NodeDetailAction) -> Unit, - onFavoriteClick: () -> Unit, -) { - Row( - modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!node.isEffectivelyUnmessageable && !isLocal) { - Button( - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) }, - modifier = Modifier.weight(1f), - shape = MaterialTheme.shapes.large, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.direct_message)) - } - } - - OutlinedButton( - onClick = { onAction(NodeDetailAction.ShareContact) }, - modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier, - shape = MaterialTheme.shapes.large, - ) { - Icon(Icons.Rounded.QrCode2, contentDescription = null) - if (node.isEffectivelyUnmessageable || isLocal) { - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.share_contact)) - } - } - - IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) { - Icon( - imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, - contentDescription = stringResource(Res.string.favorite), - tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current, - ) - } - } -} - -@Composable -private fun ManagementActions( - node: Node, - onIgnoreClick: () -> Unit, - onMuteClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Column { - SwitchListItem( - text = stringResource(Res.string.ignore), - leadingIcon = - if (node.isIgnored) { - Icons.AutoMirrored.Outlined.VolumeMute - } else { - Icons.AutoMirrored.Default.VolumeUp - }, - checked = node.isIgnored, - onClick = onIgnoreClick, - ) - - SwitchListItem( - text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always), - leadingIcon = if (node.isMuted) { - Icons.AutoMirrored.Filled.VolumeOff - } else { - Icons.AutoMirrored.Default.VolumeUp - }, - checked = node.isMuted, - onClick = onMuteClick, - ) - - ListItem( - text = stringResource(Res.string.remove), - leadingIcon = Icons.Rounded.Delete, - trailingIcon = null, - textColor = MaterialTheme.colorScheme.error, - leadingIconTint = MaterialTheme.colorScheme.error, - onClick = onRemoveClick, - ) - } -} diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index ec3cf5ea5..8a4c0d7d5 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.flowOf import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.traceroute @@ -51,9 +52,8 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.core.ui.util.LocalTracerouteMapProvider import org.meshtastic.proto.Position @Composable @@ -117,16 +117,14 @@ private fun TracerouteMapScaffold( }, ) { paddingValues -> Box(modifier = modifier.fillMaxSize().padding(paddingValues)) { - LocalMapViewProvider.current?.MapView( - modifier = Modifier, - viewModel = Unit, - navigateToNodeDetails = {}, - tracerouteOverlay = overlay, - tracerouteNodePositions = snapshotPositions, - onTracerouteMappableCountChanged = { shown: Int, total: Int -> + LocalTracerouteMapProvider.current( + overlay, + snapshotPositions, + { shown: Int, total: Int -> tracerouteNodesShown = shown tracerouteNodesTotal = total }, + Modifier.fillMaxSize(), ) Column( modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index b7c5f35bd..699021fbc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +25,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.formatString @@ -37,6 +35,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Config import org.meshtastic.proto.Position import kotlin.math.abs @@ -92,13 +91,17 @@ class CompassViewModel( updatesJob?.cancel() - updatesJob = viewModelScope.launch { - combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { heading, location -> - buildState(heading, location) + updatesJob = + safeLaunch(tag = "compassUpdates") { + combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { + heading, + location, + -> + buildState(heading, location) + } + .flowOn(dispatchers.default) + .collect { _uiState.value = it } } - .flowOn(dispatchers.default) - .collect { _uiState.value = it } - } } fun stop() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index f127076d3..23ef010e8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -17,11 +17,6 @@ package org.meshtastic.feature.node.component import androidx.compose.foundation.layout.Column -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ForkLeft -import androidx.compose.material.icons.rounded.Icecream -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -32,7 +27,7 @@ import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration import org.meshtastic.core.resources.firmware @@ -43,6 +38,11 @@ import org.meshtastic.core.resources.latest_stable_firmware import org.meshtastic.core.resources.remote_admin import org.meshtastic.core.resources.request_metadata import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.ForkLeft +import org.meshtastic.core.ui.icon.Icecream +import org.meshtastic.core.ui.icon.Memory +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Settings import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -63,7 +63,7 @@ fun AdministrationSection( Column { ListItem( text = stringResource(Res.string.request_metadata), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, trailingIcon = null, onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) @@ -74,10 +74,10 @@ fun AdministrationSection( ListItem( text = stringResource(Res.string.remote_admin), - leadingIcon = Icons.Rounded.Settings, + leadingIcon = MeshtasticIcons.Settings, enabled = metricsState.isLocal || node.metadata != null, ) { - onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num))) + onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num))) } } } @@ -101,8 +101,8 @@ private fun FirmwareSection( firmwareEdition?.let { edition -> val icon = when (edition) { - FirmwareEdition.VANILLA -> Icons.Rounded.Icecream - else -> Icons.Rounded.ForkLeft + FirmwareEdition.VANILLA -> MeshtasticIcons.Icecream + else -> MeshtasticIcons.ForkLeft } ListItem( @@ -138,7 +138,7 @@ private fun FirmwareVersionItems( ListItem( text = stringResource(Res.string.installed_firmware_version), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, supportingText = version.substringBeforeLast("."), copyable = true, leadingIconTint = statusColor, @@ -149,7 +149,7 @@ private fun FirmwareVersionItems( ListItem( text = stringResource(Res.string.latest_stable_firmware), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""), copyable = true, leadingIconTint = MaterialTheme.colorScheme.StatusGreen, @@ -161,7 +161,7 @@ private fun FirmwareVersionItems( ListItem( text = stringResource(Res.string.latest_alpha_firmware), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""), copyable = true, leadingIconTint = MaterialTheme.colorScheme.StatusYellow, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt index cfaa5943a..101e43ff3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Tsunami import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,6 +23,8 @@ import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_label +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Tsunami @Composable fun ChannelInfo( @@ -34,7 +34,7 @@ fun ChannelInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Tsunami, + icon = MeshtasticIcons.Tsunami, contentDescription = stringResource(Res.string.channel_label), text = channel.toString(), contentColor = contentColor, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt index 7b42dd374..1f4d96b9f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt @@ -25,9 +25,6 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ErrorOutline -import androidx.compose.material.icons.rounded.GpsFixed import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -70,6 +67,9 @@ import org.meshtastic.core.resources.compass_uncertainty_unknown import org.meshtastic.core.resources.elevation_suffix import org.meshtastic.core.resources.exchange_position import org.meshtastic.core.resources.last_position_update +import org.meshtastic.core.ui.icon.ErrorOutline +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MyLocation import org.meshtastic.feature.node.compass.CompassUiState import org.meshtastic.feature.node.compass.CompassWarning import kotlin.math.PI @@ -152,7 +152,7 @@ fun CompassSheetContent( ) // Quick way to re-request a fresh fix without leaving the compass sheet Button(onClick = onRequestPosition, modifier = Modifier.fillMaxWidth()) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) + Icon(imageVector = MeshtasticIcons.MyLocation, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.exchange_position)) } @@ -189,7 +189,7 @@ private fun WarningList( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( - imageVector = Icons.Rounded.ErrorOutline, + imageVector = MeshtasticIcons.ErrorOutline, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer, ) @@ -204,13 +204,13 @@ private fun WarningList( if (warnings.contains(CompassWarning.NO_LOCATION_PERMISSION)) { Button(onClick = onRequestPermission, modifier = Modifier.fillMaxWidth()) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) + Icon(imageVector = MeshtasticIcons.MyLocation, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.compass_no_location_permission)) } } else if (warnings.contains(CompassWarning.LOCATION_DISABLED)) { Button(onClick = onOpenLocationSettings, modifier = Modifier.fillMaxWidth()) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) + Icon(imageVector = MeshtasticIcons.MyLocation, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.compass_location_disabled)) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index db10ed175..a0a9290fe 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -23,15 +23,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Message -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.automirrored.outlined.VolumeMute -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.QrCode2 -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -57,19 +48,30 @@ import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.share_contact import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.SwitchListItem +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Message +import org.meshtastic.core.ui.icon.NotFavorite +import org.meshtastic.core.ui.icon.QrCode2 +import org.meshtastic.core.ui.icon.VolumeMute +import org.meshtastic.core.ui.icon.VolumeOff +import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.feature.node.model.isEffectivelyUnmessageable +import org.meshtastic.proto.Config @Composable fun DeviceActions( node: Node, + ourNode: Node?, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, availableLogs: Set, onAction: (NodeDetailAction) -> Unit, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, modifier: Modifier = Modifier, isLocal: Boolean = false, ) { @@ -85,10 +87,12 @@ fun DeviceActions( TelemetricActionsSection( node = node, + ourNode = ourNode, availableLogs = availableLogs, lastTracerouteTime = lastTracerouteTime, lastRequestNeighborsTime = lastRequestNeighborsTime, - metricsState = metricsState, + displayUnits = displayUnits, + isFahrenheit = isFahrenheit, onAction = onAction, isLocal = isLocal, ) @@ -113,7 +117,7 @@ private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetai contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { - Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null) + Icon(MeshtasticIcons.Message, contentDescription = null) Spacer(Modifier.width(8.dp)) Text(stringResource(Res.string.direct_message)) } @@ -124,7 +128,7 @@ private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetai modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier, shape = MaterialTheme.shapes.large, ) { - Icon(Icons.Rounded.QrCode2, contentDescription = null) + Icon(MeshtasticIcons.QrCode2, contentDescription = null) if (node.isEffectivelyUnmessageable || isLocal) { Spacer(Modifier.width(8.dp)) Text(stringResource(Res.string.share_contact)) @@ -137,7 +141,7 @@ private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetai onCheckedChange = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(node))) }, ) { Icon( - imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + imageVector = if (node.isFavorite) MeshtasticIcons.Favorite else MeshtasticIcons.NotFavorite, contentDescription = stringResource(Res.string.favorite), tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current, ) @@ -153,9 +157,9 @@ private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) text = stringResource(Res.string.ignore), leadingIcon = if (node.isIgnored) { - Icons.AutoMirrored.Outlined.VolumeMute + MeshtasticIcons.VolumeMute } else { - Icons.AutoMirrored.Default.VolumeUp + MeshtasticIcons.VolumeUp }, checked = node.isIgnored, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(node))) }, @@ -166,9 +170,9 @@ private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) text = stringResource(Res.string.mute_notifications), leadingIcon = if (node.isMuted) { - Icons.AutoMirrored.Filled.VolumeOff + MeshtasticIcons.VolumeOff } else { - Icons.AutoMirrored.Default.VolumeUp + MeshtasticIcons.VolumeUp }, checked = node.isMuted, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(node))) }, @@ -177,7 +181,7 @@ private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) ListItem( text = stringResource(Res.string.remove), - leadingIcon = Icons.Rounded.Delete, + leadingIcon = MeshtasticIcons.Delete, trailingIcon = null, textColor = MaterialTheme.colorScheme.error, leadingIconTint = MaterialTheme.colorScheme.error, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt index b73f9f476..cd834d1a5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt @@ -27,9 +27,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.twotone.Verified import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -51,6 +48,9 @@ import org.meshtastic.core.resources.img_hw_unknown import org.meshtastic.core.resources.supported import org.meshtastic.core.resources.supported_by_community import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.HardwareModel +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Verified import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.node.model.MetricsState @@ -78,7 +78,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) { ?: deviceHardware.displayName ListItem( text = stringResource(Res.string.hardware), - leadingIcon = Icons.Rounded.Router, + leadingIcon = MeshtasticIcons.HardwareModel, supportingText = deviceText, copyable = true, trailingIcon = null, @@ -116,7 +116,7 @@ private fun SupportStatusItem(isSupported: Boolean) { }, leadingIcon = if (isSupported) { - Icons.TwoTone.Verified + MeshtasticIcons.Verified } else { org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_unverified) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt index cf42eefe9..f8bf4e1e7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.SocialDistance import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,6 +23,8 @@ import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.distance +import org.meshtastic.core.ui.icon.Distance +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun DistanceInfo( @@ -34,7 +34,7 @@ fun DistanceInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.SocialDistance, + icon = MeshtasticIcons.Distance, contentDescription = stringResource(Res.string.distance), label = stringResource(Res.string.distance), text = distance, 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 1229900c8..067d9cf40 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -19,20 +19,7 @@ package org.meshtastic.feature.node.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Navigation -import androidx.compose.material.icons.rounded.Air -import androidx.compose.material.icons.rounded.BlurOn -import androidx.compose.material.icons.rounded.Bolt -import androidx.compose.material.icons.rounded.Height -import androidx.compose.material.icons.rounded.LightMode -import androidx.compose.material.icons.rounded.Power -import androidx.compose.material.icons.rounded.Scale -import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material.icons.rounded.Thermostat -import androidx.compose.material.icons.rounded.WaterDrop import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.NumberFormatter @@ -53,6 +40,7 @@ import org.meshtastic.core.resources.ic_radioactive import org.meshtastic.core.resources.ic_soil_moisture import org.meshtastic.core.resources.ic_soil_temperature import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.pressure import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture @@ -62,6 +50,18 @@ import org.meshtastic.core.resources.uv_lux import org.meshtastic.core.resources.voltage import org.meshtastic.core.resources.weight import org.meshtastic.core.resources.wind +import org.meshtastic.core.ui.icon.AirQuality +import org.meshtastic.core.ui.icon.Altitude +import org.meshtastic.core.ui.icon.Humidity +import org.meshtastic.core.ui.icon.LightMode +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Particulate +import org.meshtastic.core.ui.icon.PowerSupply +import org.meshtastic.core.ui.icon.Pressure +import org.meshtastic.core.ui.icon.Temperature +import org.meshtastic.core.ui.icon.Voltage +import org.meshtastic.core.ui.icon.Weight +import org.meshtastic.core.ui.icon.WindDirection import org.meshtastic.feature.node.model.DrawableMetricInfo import org.meshtastic.feature.node.model.VectorMetricInfo import org.meshtastic.proto.Config @@ -73,153 +73,170 @@ internal fun EnvironmentMetrics( displayUnits: Config.DisplayConfig.DisplayUnits, isFahrenheit: Boolean = false, ) { - val vectorMetrics = - remember(node.environmentMetrics, isFahrenheit, displayUnits) { - buildList { - with(node.environmentMetrics) { - temperature?.let { temp -> - if (!temp.isNaN()) { - add( - VectorMetricInfo( - label = Res.string.temperature, - value = temp.toTempString(isFahrenheit), - icon = Icons.Rounded.Thermostat, - ), - ) - } - } - relative_humidity?.let { rh -> - add( - VectorMetricInfo( - Res.string.humidity, - "${NumberFormatter.format(rh, 0)}%", - Icons.Rounded.WaterDrop, - ), - ) - } - barometric_pressure?.let { bp -> - add( - VectorMetricInfo( - Res.string.pressure, - "${NumberFormatter.format(bp, 0)} hPa", - Icons.Rounded.Speed, - ), - ) - } - gas_resistance?.let { gr -> - add( - VectorMetricInfo( - label = Res.string.gas_resistance, - value = "${NumberFormatter.format(gr, 0)} MΩ", - icon = Icons.Rounded.BlurOn, - ), - ) - } - voltage?.let { v -> - add( - VectorMetricInfo( - label = Res.string.voltage, - value = "${NumberFormatter.format(v, 2)}V", - icon = Icons.Rounded.Bolt, - ), - ) - } - current?.let { c -> - add( - VectorMetricInfo( - label = Res.string.current, - value = "${NumberFormatter.format(c, 1)}mA", - icon = Icons.Rounded.Power, - ), - ) - } - iaq?.let { i -> add(VectorMetricInfo(Res.string.iaq, i.toString(), Icons.Rounded.Air)) } - distance?.let { d -> - add( - VectorMetricInfo( - label = Res.string.distance, - value = d.toSmallDistanceString(displayUnits), - icon = Icons.Rounded.Height, - ), - ) - } - lux?.let { l -> - add( - VectorMetricInfo( - label = Res.string.lux, - value = "${NumberFormatter.format(l, 0)} lx", - icon = Icons.Rounded.LightMode, - ), - ) - } - uv_lux?.let { uvl -> - add( - VectorMetricInfo( - label = Res.string.uv_lux, - value = "${NumberFormatter.format(uvl, 0)} lx", - icon = Icons.Rounded.LightMode, - ), - ) - } - wind_speed?.let { ws -> - @Suppress("MagicNumber") - val normalizedBearing = ((wind_direction ?: 0) + 180) % 360 - add( - VectorMetricInfo( - label = Res.string.wind, - value = ws.toSpeedString(displayUnits), - icon = Icons.Outlined.Navigation, - rotateIcon = normalizedBearing.toFloat(), - ), - ) - } - weight?.let { w -> - add( - VectorMetricInfo( - label = Res.string.weight, - value = "${NumberFormatter.format(w, 2)} kg", - icon = Icons.Rounded.Scale, - ), - ) - } - if (temperature != null && relative_humidity != null) { - val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!) - if (!dewPoint.isNaN()) { - add( - DrawableMetricInfo( - label = Res.string.dew_point, - value = dewPoint.toTempString(isFahrenheit), - icon = Res.drawable.ic_dew_point, - ), - ) - } - } - soil_temperature?.let { st -> - if (!st.isNaN()) { - add( - DrawableMetricInfo( - label = Res.string.soil_temperature, - value = st.toTempString(isFahrenheit), - icon = Res.drawable.ic_soil_temperature, - ), - ) - } - } - soil_moisture?.let { sm -> - add(DrawableMetricInfo(Res.string.soil_moisture, "$sm%", Res.drawable.ic_soil_moisture)) - } - radiation?.let { r -> - add( - DrawableMetricInfo( - label = Res.string.radiation, - value = "${NumberFormatter.format(r, 1)} µR/h", - icon = Res.drawable.ic_radioactive, - ), - ) - } + val vectorMetrics = buildList { + with(node.environmentMetrics) { + temperature?.let { temp -> + if (!temp.isNaN()) { + add( + VectorMetricInfo( + label = Res.string.temperature, + value = temp.toTempString(isFahrenheit), + icon = MeshtasticIcons.Temperature, + ), + ) } } + relative_humidity?.let { rh -> + add( + VectorMetricInfo( + label = Res.string.humidity, + value = "${NumberFormatter.format(rh, 0)}%", + icon = MeshtasticIcons.Humidity, + ), + ) + } + barometric_pressure?.let { bp -> + add( + VectorMetricInfo( + label = Res.string.pressure, + value = "${NumberFormatter.format(bp, 0)} hPa", + icon = MeshtasticIcons.Pressure, + ), + ) + } + gas_resistance?.let { gr -> + add( + VectorMetricInfo( + label = Res.string.gas_resistance, + value = "${NumberFormatter.format(gr, 0)} MΩ", + icon = MeshtasticIcons.Particulate, + ), + ) + } + voltage?.let { v -> + add( + VectorMetricInfo( + label = Res.string.voltage, + value = "${NumberFormatter.format(v, 2)}V", + icon = MeshtasticIcons.Voltage, + ), + ) + } + current?.let { c -> + add( + VectorMetricInfo( + label = Res.string.current, + value = "${NumberFormatter.format(c, 1)}mA", + icon = MeshtasticIcons.PowerSupply, + ), + ) + } + iaq?.let { i -> + add(VectorMetricInfo(label = Res.string.iaq, value = i.toString(), icon = MeshtasticIcons.AirQuality)) + } + distance?.let { d -> + add( + VectorMetricInfo( + label = Res.string.distance, + value = d.toSmallDistanceString(displayUnits), + icon = MeshtasticIcons.Altitude, + ), + ) + } + lux?.let { l -> + add( + VectorMetricInfo( + label = Res.string.lux, + value = "${NumberFormatter.format(l, 0)} lx", + icon = MeshtasticIcons.LightMode, + ), + ) + } + uv_lux?.let { uvl -> + add( + VectorMetricInfo( + label = Res.string.uv_lux, + value = "${NumberFormatter.format(uvl, 0)} lx", + icon = MeshtasticIcons.LightMode, + ), + ) + } + wind_speed?.let { ws -> + @Suppress("MagicNumber") + val normalizedBearing = ((wind_direction ?: 0) + 180) % 360 + add( + VectorMetricInfo( + label = Res.string.wind, + value = ws.toSpeedString(displayUnits), + icon = MeshtasticIcons.WindDirection, + rotateIcon = normalizedBearing.toFloat(), + ), + ) + } + weight?.let { w -> + add( + VectorMetricInfo( + label = Res.string.weight, + value = "${NumberFormatter.format(w, 2)} kg", + icon = MeshtasticIcons.Weight, + ), + ) + } + if (temperature != null && relative_humidity != null) { + val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!) + if (!dewPoint.isNaN()) { + add( + DrawableMetricInfo( + label = Res.string.dew_point, + value = dewPoint.toTempString(isFahrenheit), + icon = Res.drawable.ic_dew_point, + ), + ) + } + } + soil_temperature?.let { st -> + if (!st.isNaN()) { + add( + DrawableMetricInfo( + label = Res.string.soil_temperature, + value = st.toTempString(isFahrenheit), + icon = Res.drawable.ic_soil_temperature, + ), + ) + } + } + soil_moisture?.let { sm -> + add( + DrawableMetricInfo( + label = Res.string.soil_moisture, + value = "$sm%", + icon = Res.drawable.ic_soil_moisture, + ), + ) + } + radiation?.let { r -> + add( + DrawableMetricInfo( + label = Res.string.radiation, + value = "${NumberFormatter.format(r, 1)} µR/h", + icon = Res.drawable.ic_radioactive, + ), + ) + } + // 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( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt index 788e041cd..faf5d8721 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt @@ -25,9 +25,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Link import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -41,6 +38,9 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.download import org.meshtastic.core.resources.view_release +import org.meshtastic.core.ui.icon.Download +import org.meshtastic.core.ui.icon.LinkIcon +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.rememberOpenUrl @Composable @@ -56,12 +56,15 @@ fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modi Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { openUrl(firmwareRelease.pageUrl) }, modifier = Modifier.weight(1f)) { - Icon(imageVector = Icons.Rounded.Link, contentDescription = stringResource(Res.string.view_release)) + Icon( + imageVector = MeshtasticIcons.LinkIcon, + contentDescription = stringResource(Res.string.view_release), + ) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.view_release)) } Button(onClick = { openUrl(firmwareRelease.zipUrl) }, modifier = Modifier.weight(1f)) { - Icon(imageVector = Icons.Rounded.Download, contentDescription = stringResource(Res.string.download)) + Icon(imageVector = MeshtasticIcons.Download, contentDescription = stringResource(Res.string.download)) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.download)) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt index a145eedff..fbfe04450 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CrueltyFree import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,12 +23,14 @@ import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.hops_away +import org.meshtastic.core.ui.icon.HopCount +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { IconInfo( modifier = modifier, - icon = Icons.Rounded.CrueltyFree, + icon = MeshtasticIcons.HopCount, contentDescription = stringResource(Res.string.hops_away), label = stringResource(Res.string.hops_away), text = hops.toString(), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt index 38a5e30b0..07bbd5abf 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -17,9 +17,6 @@ package org.meshtastic.feature.node.component import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -42,6 +39,9 @@ import org.meshtastic.core.resources.elevation_suffix import org.meshtastic.core.resources.last_position_update import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.icon +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.LocationOn +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.createClipEntry import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.rememberOpenMap @@ -80,9 +80,9 @@ fun LinkedCoordinatesItem( ) }, text = stringResource(Res.string.last_position_update), - leadingIcon = Icons.Rounded.LocationOn, + leadingIcon = MeshtasticIcons.LocationOn, supportingText = "$ago • $coordinates$elevationText", - trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(), + trailingContent = MeshtasticIcons.ChevronRight.icon(), onClick = { openMap(node.latitude, node.longitude, node.user.long_name) }, onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } }, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt index 7531991d6..1e6ed33b4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt @@ -16,14 +16,6 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.DoDisturbOn -import androidx.compose.material.icons.outlined.DoDisturbOn -import androidx.compose.material.icons.rounded.DeleteOutline -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -42,6 +34,13 @@ import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_favorite import org.meshtastic.core.resources.remove_ignored import org.meshtastic.core.resources.unmute +import org.meshtastic.core.ui.icon.DeleteNode +import org.meshtastic.core.ui.icon.DoDisturb +import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.NotFavorite +import org.meshtastic.core.ui.icon.VolumeOff +import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.core.ui.theme.StatusColors.StatusRed /** @@ -80,7 +79,7 @@ private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () - enabled = !node.isIgnored, leadingIcon = { Icon( - imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + imageVector = if (isFavorite) MeshtasticIcons.Favorite else MeshtasticIcons.NotFavorite, contentDescription = null, ) }, @@ -98,7 +97,7 @@ private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Un }, leadingIcon = { Icon( - imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn, + imageVector = if (isIgnored) MeshtasticIcons.DoDisturb else MeshtasticIcons.DoDisturb, contentDescription = null, tint = MaterialTheme.colorScheme.StatusRed, ) @@ -122,7 +121,7 @@ private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) }, leadingIcon = { Icon( - imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, + imageVector = if (isMuted) MeshtasticIcons.VolumeOff else MeshtasticIcons.VolumeUp, contentDescription = null, ) }, @@ -140,7 +139,7 @@ private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Un enabled = !node.isIgnored, leadingIcon = { Icon( - imageVector = Icons.Rounded.DeleteOutline, + imageVector = MeshtasticIcons.DeleteNode, contentDescription = null, tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt new file mode 100644 index 000000000..4d9287bec --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt @@ -0,0 +1,168 @@ +/* + * 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("TooManyFunctions", "MagicNumber") + +package org.meshtastic.feature.node.component + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.proto.Config + +// --------------------------------------------------------------------------- +// Sample data for previews +// --------------------------------------------------------------------------- + +private val previewData = NodePreviewParameterProvider() + +// --------------------------------------------------------------------------- +// DeviceActions previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun DeviceActionsRemotePreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + DeviceActions( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + ), + onAction = {}, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DeviceActionsLocalPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + DeviceActions( + node = node, + ourNode = node, + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS), + onAction = {}, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + isLocal = true, + ) + } + } +} + +// --------------------------------------------------------------------------- +// TelemetricActionsSection previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun TelemetricActionsSectionPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + TelemetricActionsSection( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + LogsType.NEIGHBOR_INFO, + ), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + onAction = {}, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun TelemetricActionsSectionEmptyPreview() { + val node = previewData.minnieMouse + AppTheme { + Surface { + TelemetricActionsSection( + node = node, + ourNode = previewData.mickeyMouse, + availableLogs = emptySet(), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + displayUnits = Config.DisplayConfig.DisplayUnits.IMPERIAL, + isFahrenheit = true, + onAction = {}, + ) + } + } +} + +// --------------------------------------------------------------------------- +// PositionInlineContent preview +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun PositionInlineContentPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + PositionInlineContent( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + onAction = {}, + ) + } + } +} + +// --------------------------------------------------------------------------- +// NodeDetailsSection preview +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun NodeDetailsSectionPreview() { + val node = previewData.mickeyMouse + AppTheme { Surface { NodeDetailsSection(node = node) } } +} 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 925e4ab5d..036fd3404 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -29,9 +29,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Notes -import androidx.compose.material.icons.rounded.Numbers import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -52,7 +49,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime @@ -78,14 +75,17 @@ import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.user_id import org.meshtastic.core.resources.via_mqtt import org.meshtastic.core.ui.icon.ArrowCircleUp -import org.meshtastic.core.ui.icon.ChannelUtilization -import org.meshtastic.core.ui.icon.Cloud +import org.meshtastic.core.ui.icon.DeviceNumbers import org.meshtastic.core.ui.icon.History -import org.meshtastic.core.ui.icon.Hops +import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.KeyOff import org.meshtastic.core.ui.icon.Lock import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MqttConnected +import org.meshtastic.core.ui.icon.Notes import org.meshtastic.core.ui.icon.Person +import org.meshtastic.core.ui.icon.Rssi +import org.meshtastic.core.ui.icon.Snr import org.meshtastic.core.ui.icon.Verified import org.meshtastic.core.ui.icon.role import org.meshtastic.core.ui.util.createClipEntry @@ -189,7 +189,7 @@ private fun StatusMessageRow(status: String) { InfoItem( label = stringResource(Res.string.status_message), value = status, - icon = Icons.AutoMirrored.Rounded.Notes, + icon = MeshtasticIcons.Notes, modifier = Modifier.fillMaxWidth(), ) } @@ -200,13 +200,13 @@ private fun NodeIdentificationRow(node: Node) { InfoItem( label = stringResource(Res.string.node_id), value = DataPacket.nodeNumToDefaultId(node.num), - icon = Icons.Rounded.Numbers, + icon = MeshtasticIcons.DeviceNumbers, modifier = Modifier.weight(1f), ) InfoItem( label = stringResource(Res.string.node_number), value = node.num.toUInt().toString(), - icon = Icons.Rounded.Numbers, + icon = MeshtasticIcons.DeviceNumbers, modifier = Modifier.weight(1f), ) } @@ -225,7 +225,7 @@ private fun HearsAndHopsRow(node: Node) { InfoItem( label = stringResource(Res.string.hops_away), value = node.hopsAway.toString(), - icon = MeshtasticIcons.Hops, + icon = MeshtasticIcons.HopCount, modifier = Modifier.weight(1f), ) } else { @@ -263,8 +263,8 @@ private fun SignalRow(node: Node) { if (node.snr != Float.MAX_VALUE) { InfoItem( label = stringResource(Res.string.snr), - value = formatString("%.1f dB", node.snr), - icon = MeshtasticIcons.ChannelUtilization, + value = MetricFormatter.snr(node.snr), + icon = MeshtasticIcons.Snr, modifier = Modifier.weight(1f), ) } else { @@ -273,8 +273,8 @@ private fun SignalRow(node: Node) { if (node.rssi != Int.MAX_VALUE) { InfoItem( label = stringResource(Res.string.rssi), - value = formatString("%d dBm", node.rssi), - icon = MeshtasticIcons.ChannelUtilization, + value = MetricFormatter.rssi(node.rssi), + icon = MeshtasticIcons.Rssi, modifier = Modifier.weight(1f), ) } else { @@ -290,7 +290,7 @@ private fun MqttAndVerificationRow(node: Node) { InfoItem( label = stringResource(Res.string.via_mqtt), value = "Yes", - icon = MeshtasticIcons.Cloud, + icon = MeshtasticIcons.MqttConnected, modifier = Modifier.weight(1f), ) } else { 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 f40acd33b..0bc022c34 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -29,10 +29,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -53,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -60,6 +57,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure import org.meshtastic.core.resources.node_filter_exclude_mqtt @@ -72,6 +70,10 @@ import org.meshtastic.core.resources.node_filter_show_ignored import org.meshtastic.core.resources.node_filter_title import org.meshtastic.core.resources.node_sort_button import org.meshtastic.core.resources.node_sort_title +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Search +import org.meshtastic.core.ui.icon.Sort @Suppress("LongParameterList") @Composable @@ -173,19 +175,24 @@ private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Un ) }, leadingIcon = { - Icon(Icons.Rounded.Search, contentDescription = stringResource(Res.string.node_filter_placeholder)) + Icon(MeshtasticIcons.Search, contentDescription = stringResource(Res.string.node_filter_placeholder)) }, onValueChange = onTextChange, trailingIcon = { if (filterText.isNotEmpty() || isFocused) { + val clearLabel = stringResource(Res.string.clear) Icon( - Icons.Rounded.Clear, + MeshtasticIcons.Close, contentDescription = stringResource(Res.string.desc_node_filter_clear), modifier = - Modifier.clickable { - onTextChange("") - focusManager.clearFocus() - }, + Modifier.clickable( + onClickLabel = clearLabel, + role = Role.Button, + onClick = { + onTextChange("") + focusManager.clearFocus() + }, + ), ) } }, @@ -208,7 +215,7 @@ private fun NodeSortButton( IconButton(onClick = { expanded = true }) { Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, + imageVector = MeshtasticIcons.Sort, contentDescription = stringResource(Res.string.node_sort_button), modifier = Modifier.heightIn(max = 48.dp), tint = MaterialTheme.colorScheme.onSurface, 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 a96501f6d..22f4422ad 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -29,11 +29,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Notes 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 @@ -48,11 +45,12 @@ import androidx.compose.ui.text.style.TextDecoration 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.formatString +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_utilization @@ -90,13 +88,13 @@ import org.meshtastic.core.ui.component.determineSignalQuality import org.meshtastic.core.ui.icon.AirUtilization import org.meshtastic.core.ui.icon.ChannelUtilization import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Notes import org.meshtastic.proto.Config 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( @@ -108,6 +106,7 @@ fun NodeItem( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, connectionState: ConnectionState, + deviceType: DeviceType? = null, isActive: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } @@ -168,6 +167,7 @@ fun NodeItem( isMuted = isMuted, isUnmessageable = unmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) @@ -178,7 +178,7 @@ fun NodeItem( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.AutoMirrored.Rounded.Notes, + imageVector = MeshtasticIcons.Notes, contentDescription = null, modifier = Modifier.size(16.dp), tint = contentColor.copy(alpha = 0.7f), @@ -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 = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization), + text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), label = stringResource(Res.string.air_utilization), - text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx), + text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f), contentColor = contentColor, ) } @@ -284,7 +284,7 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col if (thatNode.snr < 100f && thatNode.rssi < 0) { val quality = determineSignalQuality(thatNode.snr, thatNode.rssi) IconInfo( - icon = quality.imageVector, + icon = vectorResource(quality.icon), contentDescription = stringResource(Res.string.signal_quality), contentColor = quality.color.invoke(), text = stringResource(quality.nameRes), @@ -319,31 +319,24 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C } if ((env.temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f)) - } else { - formatString("%.1f°C", env.temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit) items.add { TemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.relative_humidity ?: 0f) != 0f) { items.add { - HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor) + HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor) } } if ((env.barometric_pressure ?: 0f) != 0f) { items.add { - PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor) + PressureInfo( + pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f), + contentColor = contentColor, + ) } } if ((env.soil_temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f)) - } else { - formatString("%.1f°C", env.soil_temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit) items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) { @@ -352,7 +345,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.voltage ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.2fV", env.voltage ?: 0f), + value = MetricFormatter.voltage(env.voltage ?: 0f), label = stringResource(Res.string.voltage), contentColor = contentColor, ) @@ -361,7 +354,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.current ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.1fmA", env.current ?: 0f), + value = MetricFormatter.current(env.current ?: 0f), label = stringResource(Res.string.current), contentColor = contentColor, ) @@ -391,7 +384,6 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) { } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun NodeItemHeader( thatNode: Node, @@ -403,6 +395,7 @@ private fun NodeItemHeader( isMuted: Boolean, isUnmessageable: Boolean, connectionState: ConnectionState, + deviceType: DeviceType?, contentColor: Color, ) { Row( @@ -448,6 +441,7 @@ private fun NodeItemHeader( isMuted = isMuted, isUnmessageable = isUnmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index 7c4e23d4f..1bbafad6a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -46,17 +47,11 @@ import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure -import org.meshtastic.core.ui.icon.CloudDone -import org.meshtastic.core.ui.icon.CloudOffTwoTone -import org.meshtastic.core.ui.icon.CloudSync -import org.meshtastic.core.ui.icon.CloudTwoTone +import org.meshtastic.core.ui.component.ConnectionsNavIcon import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff -import org.meshtastic.core.ui.theme.StatusColors.StatusGreen -import org.meshtastic.core.ui.theme.StatusColors.StatusOrange -import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @OptIn(ExperimentalMaterial3Api::class) @@ -68,11 +63,12 @@ fun NodeStatusIcons( isMuted: Boolean, connectionState: ConnectionState, modifier: Modifier = Modifier, + deviceType: DeviceType? = null, contentColor: Color = LocalContentColor.current, ) { Row(modifier = modifier.padding(4.dp)) { if (isThisNode) { - ThisNodeStatusBadge(connectionState) + ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType) } if (isUnmessageable) { @@ -104,7 +100,7 @@ fun NodeStatusIcons( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ThisNodeStatusBadge(connectionState: ConnectionState) { +private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: DeviceType?) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { @@ -123,55 +119,10 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState) { }, state = rememberTooltipState(), ) { - when (connectionState) { - ConnectionState.Connected -> ConnectedStatusIcon() - ConnectionState.Connecting -> ConnectingStatusIcon() - ConnectionState.Disconnected -> DisconnectedStatusIcon() - ConnectionState.DeviceSleep -> DeviceSleepStatusIcon() - } + ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp)) } } -@Composable -private fun ConnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.CloudDone, - contentDescription = stringResource(Res.string.connected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusGreen, - ) -} - -@Composable -private fun ConnectingStatusIcon() { - Icon( - imageVector = MeshtasticIcons.CloudSync, - contentDescription = stringResource(Res.string.connecting), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusOrange, - ) -} - -@Composable -private fun DisconnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.CloudOffTwoTone, - contentDescription = stringResource(Res.string.disconnected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusRed, - ) -} - -@Composable -private fun DeviceSleepStatusIcon() { - Icon( - imageVector = MeshtasticIcons.CloudTwoTone, - 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/NotesSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NotesSection.kt index d8b99c9c7..f9d7f640a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NotesSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NotesSection.kt @@ -23,8 +23,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Save import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -48,6 +46,8 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.add_a_note import org.meshtastic.core.resources.notes import org.meshtastic.core.resources.save +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Save @Composable fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) { @@ -86,7 +86,10 @@ fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modif }, enabled = edited, ) { - Icon(imageVector = Icons.Rounded.Save, contentDescription = stringResource(Res.string.save)) + Icon( + imageVector = MeshtasticIcons.Save, + contentDescription = stringResource(Res.string.save), + ) } }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 57c7980df..0ab017f7b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -16,10 +16,7 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -28,13 +25,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.rounded.Explore -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.SocialDistance -import androidx.compose.material3.AssistChip -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -49,79 +39,45 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.exchange_position import org.meshtastic.core.resources.open_compass -import org.meshtastic.core.resources.position +import org.meshtastic.core.ui.icon.Compass +import org.meshtastic.core.ui.icon.Distance +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.LocalInlineMapProvider -import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config -private const val EXCHANGE_BUTTON_WEIGHT = 1.1f -private const val COMPASS_BUTTON_WEIGHT = 0.9f private const val MAP_HEIGHT_DP = 200 /** - * Displays node position details, last update time, distance, and related actions like requesting position and - * accessing map/position logs. + * Inline position content shown beneath the Position row in the Telemetry section. Displays the inline map with + * distance badge, linked coordinates, and compass button. */ @Composable -fun PositionSection( +internal fun PositionInlineContent( node: Node, ourNode: Node?, - metricsState: MetricsState, - availableLogs: Set, + displayUnits: Config.DisplayConfig.DisplayUnits, onAction: (NodeDetailAction) -> Unit, - modifier: Modifier = Modifier, ) { - val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits) - val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 - val isLocal = metricsState.isLocal + val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits) - SectionCard(title = Res.string.position, modifier = modifier) { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - if (hasValidPosition) { - PositionMap(node, distance) - LinkedCoordinatesItem(node, metricsState.displayUnits) - Spacer(Modifier.height(8.dp)) - } - - PositionActionButtons( - node = node, - isLocal = isLocal, - hasValidPosition = hasValidPosition, - displayUnits = metricsState.displayUnits, - onAction = onAction, - ) - - if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) { - Spacer(Modifier.height(12.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (availableLogs.contains(LogsType.NODE_MAP)) { - AssistChip( - onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) }, - label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) }, - leadingIcon = { Icon(LogsType.NODE_MAP.icon, null, Modifier.size(18.dp)) }, - ) - } - - if (availableLogs.contains(LogsType.POSITIONS)) { - AssistChip( - onClick = { - onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num))) - }, - label = { Text(stringResource(LogsType.POSITIONS.titleRes)) }, - leadingIcon = { Icon(LogsType.POSITIONS.icon, null, Modifier.size(18.dp)) }, - ) - } - } - } - } + PositionMap(node, distance) + LinkedCoordinatesItem(node, displayUnits) + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) { + Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.open_compass), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -141,7 +97,7 @@ private fun PositionMap(node: Node, distance: String?) { modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Rounded.SocialDistance, null, Modifier.size(16.dp)) + Icon(MeshtasticIcons.Distance, null, Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(distance, style = MaterialTheme.typography.labelLarge) } @@ -149,59 +105,3 @@ private fun PositionMap(node: Node, distance: String?) { } } } - -@Composable -private fun PositionActionButtons( - node: Node, - isLocal: Boolean, - hasValidPosition: Boolean, - displayUnits: Config.DisplayConfig.DisplayUnits, - onAction: (NodeDetailAction) -> Unit, -) { - if (isLocal && !hasValidPosition) return - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!isLocal) { - Button( - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, - modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT), - shape = MaterialTheme.shapes.large, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.exchange_position), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Visible, - ) - } - } - - if (hasValidPosition) { - FilledTonalButton( - onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, - modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT), - shape = MaterialTheme.shapes.large, - ) { - Icon(Icons.Rounded.Explore, null, Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.open_compass), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt index 154803e81..aba8fa75c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt @@ -19,11 +19,7 @@ package org.meshtastic.feature.node.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bolt -import androidx.compose.material.icons.rounded.Power import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.NumberFormatter @@ -32,6 +28,9 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 import org.meshtastic.core.resources.channel_2 import org.meshtastic.core.resources.channel_3 +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PowerSupply +import org.meshtastic.core.ui.icon.Voltage import org.meshtastic.feature.node.model.VectorMetricInfo /** @@ -44,61 +43,58 @@ import org.meshtastic.feature.node.model.VectorMetricInfo @Composable @Suppress("LongMethod", "CyclomaticComplexMethod") internal fun PowerMetrics(node: Node) { - val metrics = - remember(node.powerMetrics) { - buildList { - with(node.powerMetrics) { - if ((ch1_voltage ?: 0f) != 0f) { - add( - VectorMetricInfo( - Res.string.channel_1, - "${NumberFormatter.format(ch1_voltage ?: 0f, 2)}V", - Icons.Rounded.Bolt, - ), - ) - add( - VectorMetricInfo( - Res.string.channel_1, - "${NumberFormatter.format(ch1_current ?: 0f, 1)}mA", - Icons.Rounded.Power, - ), - ) - } - if ((ch2_voltage ?: 0f) != 0f) { - add( - VectorMetricInfo( - Res.string.channel_2, - "${NumberFormatter.format(ch2_voltage ?: 0f, 2)}V", - Icons.Rounded.Bolt, - ), - ) - add( - VectorMetricInfo( - Res.string.channel_2, - "${NumberFormatter.format(ch2_current ?: 0f, 1)}mA", - Icons.Rounded.Power, - ), - ) - } - if ((ch3_voltage ?: 0f) != 0f) { - add( - VectorMetricInfo( - Res.string.channel_3, - "${NumberFormatter.format(ch3_voltage ?: 0f, 2)}V", - Icons.Rounded.Bolt, - ), - ) - add( - VectorMetricInfo( - Res.string.channel_3, - "${NumberFormatter.format(ch3_current ?: 0f, 1)}mA", - Icons.Rounded.Power, - ), - ) - } - } + val metrics = buildList { + with(node.powerMetrics) { + if ((ch1_voltage ?: 0f) != 0f) { + add( + VectorMetricInfo( + Res.string.channel_1, + "${NumberFormatter.format(ch1_voltage ?: 0f, 2)}V", + MeshtasticIcons.Voltage, + ), + ) + add( + VectorMetricInfo( + Res.string.channel_1, + "${NumberFormatter.format(ch1_current ?: 0f, 1)}mA", + MeshtasticIcons.PowerSupply, + ), + ) + } + if ((ch2_voltage ?: 0f) != 0f) { + add( + VectorMetricInfo( + Res.string.channel_2, + "${NumberFormatter.format(ch2_voltage ?: 0f, 2)}V", + MeshtasticIcons.Voltage, + ), + ) + add( + VectorMetricInfo( + Res.string.channel_2, + "${NumberFormatter.format(ch2_current ?: 0f, 1)}mA", + MeshtasticIcons.PowerSupply, + ), + ) + } + if ((ch3_voltage ?: 0f) != 0f) { + add( + VectorMetricInfo( + Res.string.channel_3, + "${NumberFormatter.format(ch3_voltage ?: 0f, 2)}V", + MeshtasticIcons.Voltage, + ), + ) + add( + VectorMetricInfo( + Res.string.channel_3, + "${NumberFormatter.format(ch3_current ?: 0f, 1)}mA", + MeshtasticIcons.PowerSupply, + ), + ) } } + } FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt index 20ee89fc7..eac0a7207 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.SatelliteAlt import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,6 +23,8 @@ import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.sats +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Satellites @Composable fun SatelliteCountInfo( @@ -34,7 +34,7 @@ fun SatelliteCountInfo( ) { IconInfo( modifier = modifier, - icon = Icons.TwoTone.SatelliteAlt, + icon = MeshtasticIcons.Satellites, contentDescription = stringResource(Res.string.sats), label = stringResource(Res.string.sats), text = "$satCount", 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 7178e4340..f3a71b374 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 @@ -41,51 +41,63 @@ import androidx.compose.runtime.remember 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.text.font.FontWeight import androidx.compose.ui.unit.dp +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.model.Node import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_air +import org.meshtastic.core.resources.ic_person +import org.meshtastic.core.resources.ic_thermostat import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.request_air_quality_metrics import org.meshtastic.core.resources.request_telemetry import org.meshtastic.core.resources.telemetry import org.meshtastic.core.resources.userinfo -import org.meshtastic.core.ui.icon.AirQuality import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Person import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Temperature import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction +import org.meshtastic.proto.Config private data class TelemetricFeature( val titleRes: StringResource, - val icon: ImageVector, + val icon: DrawableResource, val requestAction: ((Node) -> NodeMenuAction)?, val logsType: LogsType? = null, val isVisible: (Node) -> Boolean = { true }, val cooldownTimestamp: Long? = null, val cooldownDuration: Long = COOL_DOWN_TIME_MS, - val content: @Composable ((Node) -> Unit)? = null, + val content: @Composable ((Node, (NodeDetailAction) -> Unit) -> Unit)? = null, val hasContent: (Node) -> Boolean = { false }, ) @Composable internal fun TelemetricActionsSection( node: Node, + ourNode: Node?, availableLogs: Set, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, onAction: (NodeDetailAction) -> Unit, isLocal: Boolean = false, ) { - val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) + val features = + rememberTelemetricFeatures( + node, + ourNode, + lastTracerouteTime, + lastRequestNeighborsTime, + displayUnits, + isFahrenheit, + isLocal, + ) SectionCard(title = Res.string.telemetry) { features @@ -110,83 +122,94 @@ internal fun TelemetricActionsSection( @Composable private fun rememberTelemetricFeatures( node: Node, + ourNode: Node?, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, isLocal: Boolean, -): List = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) { - listOf( - TelemetricFeature( - titleRes = Res.string.userinfo, - icon = MeshtasticIcons.Person, - requestAction = { NodeMenuAction.RequestUserInfo(it) }, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.TRACEROUTE.titleRes, - icon = LogsType.TRACEROUTE.icon, - requestAction = { NodeMenuAction.TraceRoute(it) }, - logsType = LogsType.TRACEROUTE, - cooldownTimestamp = lastTracerouteTime, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.NEIGHBOR_INFO.titleRes, - icon = LogsType.NEIGHBOR_INFO.icon, - requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, - logsType = LogsType.NEIGHBOR_INFO, - isVisible = { it.capabilities.canRequestNeighborInfo }, - cooldownTimestamp = lastRequestNeighborsTime, - cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, - ), - TelemetricFeature( - titleRes = LogsType.SIGNAL.titleRes, - icon = LogsType.SIGNAL.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, - logsType = LogsType.SIGNAL, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.DEVICE.titleRes, - icon = LogsType.DEVICE.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, - logsType = LogsType.DEVICE, - ), - TelemetricFeature( - titleRes = LogsType.ENVIRONMENT.titleRes, - icon = MeshtasticIcons.Temperature, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, - logsType = LogsType.ENVIRONMENT, - content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) }, - hasContent = { it.hasEnvironmentMetrics }, - ), - TelemetricFeature( - titleRes = Res.string.request_air_quality_metrics, - icon = MeshtasticIcons.AirQuality, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, - ), - TelemetricFeature( - titleRes = LogsType.POWER.titleRes, - icon = LogsType.POWER.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, - logsType = LogsType.POWER, - content = { PowerMetrics(it) }, - hasContent = { it.hasPowerMetrics }, - ), - TelemetricFeature( - titleRes = LogsType.HOST.titleRes, - icon = LogsType.HOST.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, - logsType = LogsType.HOST, - ), - TelemetricFeature( - titleRes = LogsType.PAX.titleRes, - icon = LogsType.PAX.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, - logsType = LogsType.PAX, - ), - ) -} +): List = + remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) { + listOf( + TelemetricFeature( + titleRes = Res.string.userinfo, + icon = Res.drawable.ic_person, + requestAction = { NodeMenuAction.RequestUserInfo(it) }, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.TRACEROUTE.titleRes, + icon = LogsType.TRACEROUTE.icon, + requestAction = { NodeMenuAction.TraceRoute(it) }, + logsType = LogsType.TRACEROUTE, + cooldownTimestamp = lastTracerouteTime, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.NEIGHBOR_INFO.titleRes, + icon = LogsType.NEIGHBOR_INFO.icon, + requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, + logsType = LogsType.NEIGHBOR_INFO, + isVisible = { it.capabilities.canRequestNeighborInfo }, + cooldownTimestamp = lastRequestNeighborsTime, + cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, + ), + TelemetricFeature( + titleRes = LogsType.SIGNAL.titleRes, + icon = LogsType.SIGNAL.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, + logsType = LogsType.SIGNAL, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.DEVICE.titleRes, + icon = LogsType.DEVICE.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, + logsType = LogsType.DEVICE, + ), + TelemetricFeature( + titleRes = LogsType.ENVIRONMENT.titleRes, + icon = Res.drawable.ic_thermostat, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, + logsType = LogsType.ENVIRONMENT, + content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) }, + hasContent = { it.hasEnvironmentMetrics }, + ), + TelemetricFeature( + titleRes = Res.string.request_air_quality_metrics, + icon = Res.drawable.ic_air, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, + ), + TelemetricFeature( + titleRes = LogsType.POWER.titleRes, + icon = LogsType.POWER.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, + logsType = LogsType.POWER, + content = { node, _ -> PowerMetrics(node) }, + hasContent = { it.hasPowerMetrics }, + ), + TelemetricFeature( + titleRes = LogsType.HOST.titleRes, + icon = LogsType.HOST.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, + logsType = LogsType.HOST, + ), + TelemetricFeature( + titleRes = LogsType.PAX.titleRes, + icon = LogsType.PAX.icon, + 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 }, + ), + ) + } @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @@ -201,7 +224,11 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, ListItem( colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { - Icon(imageVector = feature.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Icon( + imageVector = vectorResource(feature.icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) }, headlineContent = { Text( @@ -229,7 +256,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, }, ) { Icon( - imageVector = feature.logsType?.icon ?: feature.icon, + imageVector = vectorResource(feature.logsType?.icon ?: feature.icon), modifier = Modifier.size(24.dp), contentDescription = logsDescription, tint = MaterialTheme.colorScheme.primary, @@ -268,7 +295,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, if (showContent) { Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) { - feature.content.invoke(node) + feature.content.invoke(node, onAction) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt index 46178dcce..1e49530b4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt @@ -18,16 +18,6 @@ package org.meshtastic.feature.node.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Air -import androidx.compose.material.icons.rounded.ElectricBolt -import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material.icons.rounded.Grass -import androidx.compose.material.icons.rounded.People -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.rounded.Thermostat -import androidx.compose.material.icons.rounded.WaterDrop -import androidx.compose.material.icons.rounded.Work import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -45,6 +35,16 @@ import org.meshtastic.core.resources.role import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature import org.meshtastic.core.resources.temperature +import org.meshtastic.core.ui.icon.AirQuality +import org.meshtastic.core.ui.icon.ElectricPower +import org.meshtastic.core.ui.icon.HardwareModel +import org.meshtastic.core.ui.icon.Humidity +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.NodeId +import org.meshtastic.core.ui.icon.PeopleCount +import org.meshtastic.core.ui.icon.Role +import org.meshtastic.core.ui.icon.SoilMoisture +import org.meshtastic.core.ui.icon.Temperature @Composable fun TemperatureInfo( @@ -54,7 +54,7 @@ fun TemperatureInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Thermostat, + icon = MeshtasticIcons.Temperature, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.temperature), text = temp, @@ -70,7 +70,7 @@ fun HumidityInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.WaterDrop, + icon = MeshtasticIcons.Humidity, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.humidity), text = humidity, @@ -86,7 +86,7 @@ fun SoilTemperatureInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Grass, + icon = MeshtasticIcons.SoilMoisture, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.soil_temperature), text = temp, @@ -102,7 +102,7 @@ fun SoilMoistureInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Grass, + icon = MeshtasticIcons.SoilMoisture, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.soil_moisture), text = moisture, @@ -118,7 +118,7 @@ fun PaxcountInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.People, + icon = MeshtasticIcons.PeopleCount, contentDescription = stringResource(Res.string.pax_metrics_log), label = stringResource(Res.string.pax), text = pax, @@ -134,7 +134,7 @@ fun AirQualityInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Air, + icon = MeshtasticIcons.AirQuality, contentDescription = stringResource(Res.string.env_metrics_log), label = stringResource(Res.string.iaq), text = iaq, @@ -151,7 +151,7 @@ fun PowerInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.ElectricBolt, + icon = MeshtasticIcons.ElectricPower, contentDescription = stringResource(Res.string.env_metrics_log), label = label, text = value, @@ -167,7 +167,7 @@ fun HardwareInfo( ) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Router, + icon = MeshtasticIcons.HardwareModel, contentDescription = stringResource(Res.string.hardware_model), text = hwModel, style = MaterialTheme.typography.labelSmall, @@ -179,7 +179,7 @@ fun HardwareInfo( fun RoleInfo(role: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Work, + icon = MeshtasticIcons.Role, contentDescription = stringResource(Res.string.role), text = role, style = MaterialTheme.typography.labelSmall, @@ -191,7 +191,7 @@ fun RoleInfo(role: String, modifier: Modifier = Modifier, contentColor: Color = fun NodeIdInfo(id: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { IconInfo( modifier = modifier, - icon = Icons.Rounded.Fingerprint, + icon = MeshtasticIcons.NodeId, contentDescription = stringResource(Res.string.node_id), text = id, style = MaterialTheme.typography.labelSmall, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 9ce025604..559582417 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -43,10 +43,7 @@ internal fun handleNodeAction( val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) navigateToMessages(route) } - is NodeMenuAction.Remove -> { - viewModel.handleNodeMenuAction(menuAction) - onNavigateUp() - } + is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp) else -> viewModel.handleNodeMenuAction(menuAction) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index e0d8fe1d1..03367debf 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -41,7 +41,6 @@ import org.meshtastic.feature.node.component.DeviceActions import org.meshtastic.feature.node.component.DeviceDetailsSection import org.meshtastic.feature.node.component.NodeDetailsSection import org.meshtastic.feature.node.component.NotesSection -import org.meshtastic.feature.node.component.PositionSection import org.meshtastic.feature.node.model.NodeDetailAction /** @@ -81,8 +80,8 @@ fun NodeDetailContent( } /** - * Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and - * administration. + * Scrollable list of node detail sections: identity, device actions (including telemetry and position), hardware + * details, notes, and administration. */ @Composable fun NodeDetailList( @@ -105,15 +104,16 @@ fun NodeDetailList( item { DeviceActions( node = node, + ourNode = ourNode, lastTracerouteTime = uiState.lastTracerouteTime, lastRequestNeighborsTime = uiState.lastRequestNeighborsTime, availableLogs = uiState.availableLogs, onAction = onAction, - metricsState = uiState.metricsState, + displayUnits = uiState.metricsState.displayUnits, + isFahrenheit = uiState.metricsState.isFahrenheit, isLocal = uiState.metricsState.isLocal, ) } - item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) } if (uiState.metricsState.deviceHardware != null) { item { DeviceDetailsSection(uiState.metricsState) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt new file mode 100644 index 000000000..caa68b106 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt @@ -0,0 +1,125 @@ +/* + * 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("TooManyFunctions", "MagicNumber") + +package org.meshtastic.feature.node.detail + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.feature.node.model.MetricsState + +// --------------------------------------------------------------------------- +// Sample data for previews +// --------------------------------------------------------------------------- + +private val previewData = NodePreviewParameterProvider() + +// --------------------------------------------------------------------------- +// NodeDetailContent previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun NodeDetailContentRemotePreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + metricsState = MetricsState(isLocal = false, isManaged = false), + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + ), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentLocalPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = node, + metricsState = MetricsState(isLocal = true, isManaged = false), + availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentLoadingPreview() { + AppTheme { + Surface { + NodeDetailContent( + uiState = NodeDetailUiState(), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentMinimalPreview() { + val node = previewData.minnieMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = previewData.mickeyMouse, + metricsState = MetricsState(isLocal = false, isManaged = true), + availableLogs = emptySet(), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} 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 35b33a9c3..e891d8ae0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -21,13 +21,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.DataPacket @@ -35,6 +33,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.UiText +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.metrics.EnvironmentMetricsState @@ -81,7 +80,7 @@ class NodeDetailViewModel( if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) getNodeDetailsUseCase(nodeId) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState()) + .stateInWhileSubscribed(initialValue = NodeDetailUiState()) fun start(nodeId: Int) { if (manualNodeId.value != nodeId) { @@ -90,9 +89,10 @@ class NodeDetailViewModel( } /** Dispatches high-level node management actions like removal, muting, or favoriting. */ - fun handleNodeMenuAction(action: NodeMenuAction) { + fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) { when (action) { - is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node) + is NodeMenuAction.Remove -> + nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove) is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node) is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node) @@ -123,7 +123,7 @@ class NodeDetailViewModel( /** Returns the type-safe navigation route for a direct message to this node. */ fun getDirectMessageRoute(node: Node, ourNode: Node?): String { - val hasPKC = ourNode?.hasPKC == true + val hasPKC = ourNode?.hasPKC == true && node.hasPKC val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel return "${channel}${node.user.id}" } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 436954201..9c021e666 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -50,11 +50,14 @@ constructor( private val radioController: RadioController, private val alertManager: AlertManager, ) { - open fun requestRemoveNode(scope: CoroutineScope, node: Node) { + open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { alertManager.showAlert( titleRes = Res.string.remove, messageRes = Res.string.remove_node_text, - onConfirm = { removeNode(scope, node.num) }, + onConfirm = { + removeNode(scope, node.num) + onAfterRemove() + }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 661010deb..a7b33f6a7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -186,7 +186,6 @@ constructor( val availableLogs = buildSet { if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE) if (metricsState.hasPositionLogs()) { - add(LogsType.NODE_MAP) add(LogsType.POSITIONS) } if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 9c2c208f4..2e8093ad8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -72,7 +72,7 @@ fun NodeListScreen( onNavigateToChannels: () -> Unit = {}, scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -97,6 +97,7 @@ fun NodeListScreen( } val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val deviceType by viewModel.deviceType.collectAsStateWithLifecycle() val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) } @@ -124,7 +125,7 @@ fun NodeListScreen( alignment = androidx.compose.ui.Alignment.BottomEnd, ), onImport = { uriString -> - onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) { + onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, @@ -187,6 +188,7 @@ fun NodeListScreen( onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, connectionState = connectionState, + deviceType = deviceType, isActive = isActive, ) val isThisNode = remember(node) { ourNode?.num == node.num } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index df65a3477..172a296eb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -23,13 +23,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions @@ -45,6 +48,7 @@ class NodeListViewModel( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val radioController: RadioController, + private val radioInterfaceService: RadioInterfaceService, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -58,6 +62,11 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState + val deviceType: StateFlow = + radioInterfaceService.currentDeviceAddressFlow + .map { address -> address?.let { DeviceType.fromAddress(it) } } + .stateInWhileSubscribed(initialValue = null) + private val nodeSortOption = nodeFilterPreferences.nodeSortOption private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index b31061ded..88f4d1d6d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -16,8 +16,12 @@ */ package org.meshtastic.feature.node.metrics +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -25,21 +29,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Info 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 import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.AutoScrollCondition import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.FadingEdges import com.patrykandpatrick.vico.compose.cartesian.Scroll import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.Zoom @@ -47,10 +54,12 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarkerVisibilityListener import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberFadingEdges import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import kotlinx.coroutines.launch @@ -58,15 +67,28 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res +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 * selection synchronization. + * + * Uses [FadingEdges] to indicate scrollable content beyond the visible area, and accepts optional [Decoration]s for + * reference threshold lines/bands. */ @Composable fun GenericMetricChart( @@ -77,69 +99,146 @@ fun GenericMetricChart( endAxis: VerticalAxis? = null, bottomAxis: HorizontalAxis? = null, marker: CartesianMarker? = null, + decorations: List = emptyList(), selectedX: Double? = null, onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), ) { - val markerVisibilityListener = - remember(onPointSelected) { - object : CartesianMarkerVisibilityListener { - override fun onShown(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } - } + // 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) - override fun onUpdated(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) } + } } } - } - 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 }, - ), - modelProducer = modelProducer, - modifier = modifier, - scrollState = vicoScrollState, - zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content), - ) + 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, + ) + } +} + +/** + * Common scaffold for all metric chart composables. Provides: + * - A [Column] container with the supplied [modifier] + * - An empty-data guard (returns early when [isEmpty] is true) + * - A remembered [CartesianChartModelProducer] passed to [content] + * - A trailing [Legend] strip + * + * @param isEmpty Whether the chart data is empty — when true, nothing is rendered. + * @param legendData Legend items shown below the chart. + * @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, + 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 chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp) + content(modelProducer, chartModifier) + Legend( + legendData = legendData, + modifier = Modifier.padding(top = 0.dp), + hiddenSet = hiddenSet, + onToggle = onToggle, + ) + } } /** * An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for - * narrow screens (phones). + * narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available + * space. */ @Composable fun AdaptiveMetricLayout( chartPart: @Composable (Modifier) -> Unit, listPart: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, + isChartExpanded: Boolean = false, ) { BoxWithConstraints(modifier = modifier) { val isExpanded = maxWidth >= 600.dp if (isExpanded) { Row(modifier = Modifier.fillMaxSize()) { chartPart(Modifier.weight(1f).fillMaxHeight()) - listPart(Modifier.weight(1f).fillMaxHeight()) + AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) { + listPart(Modifier.weight(1f).fillMaxHeight()) + } } } else { Column(modifier = Modifier.fillMaxSize()) { - chartPart(Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) - listPart(Modifier.fillMaxWidth().weight(1f)) + chartPart( + if (isChartExpanded) { + Modifier.fillMaxWidth().weight(1f) + } else { + Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.45f) + }, + ) + AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) { + listPart(Modifier.fillMaxWidth().weight(1f)) + } } } } } -/** A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and synchronization. */ +/** + * 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 + * 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") fun BaseMetricScreen( @@ -151,14 +250,21 @@ 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 remember { mutableStateOf(false) } + var displayInfoDialog by rememberSaveable { mutableStateOf(false) } + var isChartExpanded by rememberSaveable { mutableStateOf(false) } val lazyListState = rememberLazyListState() - val vicoScrollState = rememberVicoScrollState() + val vicoScrollState = + rememberVicoScrollState( + autoScroll = Scroll.Absolute.End, + autoScrollCondition = AutoScrollCondition.OnModelGrowth, + ) val coroutineScope = rememberCoroutineScope() var selectedX by remember { mutableStateOf(null) } @@ -172,9 +278,35 @@ fun BaseMetricScreen( canNavigateUp = true, 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 }) { + Icon( + imageVector = + if (isChartExpanded) { + MeshtasticIcons.List + } else { + MeshtasticIcons.BarChart + }, + contentDescription = + stringResource( + if (isChartExpanded) Res.string.collapse_chart else Res.string.expand_chart, + ), + ) + } if (infoData.isNotEmpty()) { IconButton(onClick = { displayInfoDialog = true }) { - Icon(imageVector = Icons.Rounded.Info, contentDescription = stringResource(Res.string.info)) + Icon( + imageVector = MeshtasticIcons.Info, + contentDescription = stringResource(Res.string.info), + ) } } if (telemetryType != null) { @@ -198,6 +330,7 @@ fun BaseMetricScreen( controlPart() AdaptiveMetricLayout( + isChartExpanded = isChartExpanded, chartPart = { modifier -> chartPart(modifier, selectedX, vicoScrollState) { x -> selectedX = x 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 1624f1673..da8b16e47 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.node.metrics import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -28,8 +29,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider +import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration +import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget @@ -37,6 +43,7 @@ import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesi import com.patrykandpatrick.vico.compose.common.Fill import com.patrykandpatrick.vico.compose.common.Insets import com.patrykandpatrick.vico.compose.common.MarkerCornerBasedShape +import com.patrykandpatrick.vico.compose.common.Position import com.patrykandpatrick.vico.compose.common.component.ShapeComponent import com.patrykandpatrick.vico.compose.common.component.TextComponent import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent @@ -46,121 +53,110 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent /** * Utility object for chart styling and component creation. Provides reusable styled lines, points, and axes for Vico * charts. + * + * **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. + * - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data. */ +@Suppress("TooManyFunctions") object ChartStyling { - // Point sizes - const val SMALL_POINT_SIZE_DP = 6f - const val MEDIUM_POINT_SIZE_DP = 8f - const val LARGE_POINT_SIZE_DP = 10f - // Line stroke widths const val THIN_LINE_WIDTH_DP = 1.5f const val MEDIUM_LINE_WIDTH_DP = 2f const val THICK_LINE_WIDTH_DP = 2.5f /** - * Creates a solid line with optional point markers. + * Creates a clean timeseries line — thin, smooth, with **no** point markers. This is the default style recommended + * by Oscar's UX guidance: "thin lines, and maybe a dot where the cursor is." * * @param lineColor The color of the line - * @param pointSize Size of point markers (in dp). If null, no point markers are shown. * @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, - pointSize: Float? = MEDIUM_POINT_SIZE_DP, lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), - pointProvider = - pointSize?.let { - LineCartesianLayer.PointProvider.single( - LineCartesianLayer.Point( - rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape), - size = it.dp, - ), - ) - }, stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = interpolator, ) /** - * Creates a transparent line (no line, only points). Useful for distinguishing multiple metrics on the same chart. - * - * @param pointColor The color of the point markers - * @param pointSize Size of point markers in dp - * @return Configured [LineCartesianLayer.Line] - */ - @Composable - fun createPointOnlyLine(pointColor: Color, pointSize: Float = MEDIUM_POINT_SIZE_DP): LineCartesianLayer.Line = - LineCartesianLayer.rememberLine( - // we still need to give the line a color, the Marker derives the label color from the line - fill = LineCartesianLayer.LineFill.single(Fill(pointColor)), - // magic sauce to make the line disappear - stroke = LineCartesianLayer.LineStroke.Dashed(thickness = 0.dp, dashLength = 0.dp), - pointProvider = - LineCartesianLayer.PointProvider.single( - LineCartesianLayer.Point( - rememberShapeComponent(fill = Fill(pointColor), shape = CircleShape), - size = pointSize.dp, - ), - ), - ) - - /** - * Creates a line with a gradient fill effect. The gradient goes from the line color to transparent. + * Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The + * gradient goes from the line color at ~30% opacity to near-transparent. * * @param lineColor The primary color of the line - * @param pointSize Size of point markers (in dp). If null, no point markers are shown. * @param lineWidth Width of the line in dp * @return Configured [LineCartesianLayer.Line] */ @Composable fun createGradientLine( lineColor: Color, - pointSize: Float? = MEDIUM_POINT_SIZE_DP, lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), ): LineCartesianLayer.Line { val gradientBrush = - Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.1f))) + 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)), - pointProvider = - pointSize?.let { - LineCartesianLayer.PointProvider.single( - LineCartesianLayer.Point( - rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape), - size = it.dp, - ), - ) - }, stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = interpolator, ) } /** - * Creates a bold line suitable for highlighting primary metrics. + * Creates a bold line suitable for highlighting the primary metric in a multi-series chart. * * @param lineColor The color of the line - * @param pointSize Size of point markers (in dp). If null, no point markers are shown. * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createBoldLine(lineColor: Color, pointSize: Float? = LARGE_POINT_SIZE_DP): LineCartesianLayer.Line = - createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THICK_LINE_WIDTH_DP) + fun createBoldLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator) /** - * Creates a subtle line suitable for secondary metrics. + * Creates a subtle line suitable for secondary metrics that should not dominate the chart. * * @param lineColor The color of the line - * @param pointSize Size of point markers (in dp). If null, no point markers are shown. * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createSubtleLine(lineColor: Color, pointSize: Float? = SMALL_POINT_SIZE_DP): LineCartesianLayer.Line = - createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THIN_LINE_WIDTH_DP) + fun createSubtleLine(lineColor: Color): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THIN_LINE_WIDTH_DP) + + /** + * Creates a dashed secondary line. Useful for distinguishing two metrics that share the same axis without relying + * on colour alone. + * + * @param lineColor The color of the dashed line + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createDashedLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = + LineCartesianLayer.LineStroke.Dashed( + thickness = THIN_LINE_WIDTH_DP.dp, + dashLength = 6.dp, + gapLength = 3.dp, + ), + interpolator = interpolator, + ) /** * Gets Material 3 theme-aware colors with opacity. Useful for creating color variants while respecting the current @@ -172,6 +168,38 @@ object ChartStyling { */ fun createThemedColor(baseColor: Color, alpha: Float = 1f): Color = baseColor.copy(alpha = alpha) + /** + * Creates a [HorizontalLine] decoration for a reference threshold (e.g. battery low, pressure normal). + * + * @param y The y-value to draw the line at + * @param color The color of the threshold line + * @param label Optional label text for the line + */ + @Composable + fun rememberThresholdLine(y: Double, color: Color, label: String? = null): Decoration { + val line = rememberLineComponent(fill = Fill(color.copy(alpha = 0.4f)), thickness = 1.dp) + val labelComponent = + if (label != null) { + rememberTextComponent( + style = + TextStyle(color = color.copy(alpha = 0.7f), fontSize = 9.sp, fontWeight = FontWeight.Medium), + padding = Insets(horizontal = 4.dp, vertical = 1.dp), + ) + } else { + null + } + return remember(y, color, label) { + HorizontalLine( + y = { y }, + line = line, + labelComponent = labelComponent, + label = { label ?: "" }, + horizontalLabelPosition = Position.Horizontal.End, + verticalLabelPosition = Position.Vertical.Top, + ) + } + } + /** * Creates and remembers a default [CartesianMarker] styled for the Meshtastic theme. * @@ -240,28 +268,19 @@ object ChartStyling { if (target is LineCartesianLayerMarkerTarget) { target.points.forEachIndexed { pointIndex, point -> if (pointIndex > 0) append(", ") - // Force alpha to 1f so text is readable even if the line is transparent/subtle - val color = point.color.copy(alpha = .8f) - val text = format(point.entry.y, color) - withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) } + // Pass the opaque color to the format lambda so callers can match without alpha gymnastics. + // Apply 0.8 alpha only on the rendered text for readability. + val opaqueColor = point.color.copy(alpha = 1f) + val text = format(point.entry.y, opaqueColor) + withStyle(SpanStyle(color = opaqueColor.copy(alpha = .8f), fontWeight = FontWeight.Bold)) { + append(text) + } } } } } } - /** - * Creates a standard [com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer] with optimized - * spacing. - */ - fun rememberItemPlacer( - spacing: Int = 50, - ): com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer = - com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer.aligned( - spacing = { spacing }, - addExtremeLabelPadding = true, - ) - /** * Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis * labels. @@ -270,3 +289,25 @@ object ChartStyling { fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent = rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium)) } + +/** + * Creates a [LineCartesianLayer] only when [hasData] is true, returning null otherwise. + * + * Extracts the repeated `if (data.isNotEmpty()) rememberLineCartesianLayer(...) else null` pattern used in every metric + * chart composable. + */ +@Composable +fun rememberConditionalLayer( + hasData: Boolean, + lineProvider: LineCartesianLayer.LineProvider, + verticalAxisPosition: Axis.Position.Vertical, + rangeProvider: CartesianLayerRangeProvider? = null, +): LineCartesianLayer? = if (hasData) { + rememberLineCartesianLayer( + lineProvider = lineProvider, + verticalAxisPosition = verticalAxisPosition, + rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(), + ) +} else { + null +} 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 5d8a172bc..f8d48dd59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -33,9 +33,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -49,53 +48,54 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.close import org.meshtastic.core.resources.info import org.meshtastic.core.resources.rssi import org.meshtastic.core.resources.snr +import org.meshtastic.core.ui.icon.Info +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.theme.AppTheme import kotlin.time.Duration.Companion.days object CommonCharts { - const val MS_PER_SEC = 1000L const val MAX_PERCENT_VALUE = 100f const val SCROLL_BIAS = 0.5f - /** Gets the Material 3 primary color with optional opacity adjustment. */ - @Composable - fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - - /** Gets the Material 3 secondary color with optional opacity adjustment. */ - @Composable - fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) - - /** Gets the Material 3 tertiary color with optional opacity adjustment. */ - @Composable - fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha) - - /** Gets the Material 3 error color with optional opacity adjustment. */ - @Composable - fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha) - - /** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */ + /** + * A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span + * ([CartesianRanges.xLength]). + * + * Since chart data is already filtered by [TimeFrame], `xLength` approximates the visible window. Vico's formatter + * receives [CartesianMeasuringContext] during measurement passes — **not** [CartesianDrawingContext] — so + * `context.zoom` is unavailable and we intentionally avoid it. + * + * | Data span | Format | Example | + * |-----------|------------------------|------------------| + * | ≤ 1 hour | Time with seconds | 3:45:12 PM | + * | ≤ 2 days | Time only | 3:45 PM | + * | ≤ 14 days | Date + time (two-line) | 4/9/26 ↵ 3:45 PM | + * | > 14 days | Date only | 4/9/26 | + */ val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ -> val timestampMillis = (value * MS_PER_SEC.toDouble()).toLong() - val xLength = context.ranges.xLength - val zoom = if (context is CartesianDrawingContext) context.zoom else 1f - val visibleSpan = xLength / zoom + val dataSpanSeconds = context.ranges.xLength when { - visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> DateFormatter.formatTimeWithSeconds(timestampMillis) - visibleSpan <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis) - visibleSpan <= 14.days.inWholeSeconds -> { - // < 2 weeks visible: separate date and time with a newline + dataSpanSeconds <= TimeConstants.ONE_HOUR.inWholeSeconds -> + DateFormatter.formatTimeWithSeconds(timestampMillis) + dataSpanSeconds <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis) + dataSpanSeconds <= 14.days.inWholeSeconds -> { val dateStr = DateFormatter.formatDate(timestampMillis) val timeStr = DateFormatter.formatTime(timestampMillis) "$dateStr\n$timeStr" @@ -104,30 +104,76 @@ object CommonCharts { } } - fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis) + /** + * Shared bottom time axis used by all metric chart screens. + * + * Uses `spacing = 1` with `addExtremeLabelPadding = true` so Vico's built-in auto-thinning controls label density — + * it measures label widths and automatically skips labels when they would overlap, adapting to both zoom level and + * screen width. + */ + @Composable + fun rememberBottomTimeAxis(): HorizontalAxis = HorizontalAxis.rememberBottom( + label = ChartStyling.rememberAxisLabel(), + valueFormatter = dynamicTimeFormatter, + itemPlacer = HorizontalAxis.ItemPlacer.aligned(spacing = { 1 }, addExtremeLabelPadding = true), + labelRotationDegrees = LABEL_ROTATION_DEGREES, + ) + + private const val LABEL_ROTATION_DEGREES = 45f } data class LegendData( val nameRes: StringResource, val color: Color, val isLine: Boolean = false, - val environmentMetric: Environment? = null, + 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) -/** Creates the legend that identifies the colors used for the graph. */ +/** + * Creates the legend that identifies the colors used for the graph. + * + * When [onToggle] is provided, each item renders as a Material 3 [FilterChip] so users can tap to show/hide chart + * series. This provides proper M3 affordance (selected state styling, ripple, accessibility semantics). When [onToggle] + * is null, a compact read-only legend is shown instead. + */ @OptIn(ExperimentalLayoutApi::class) @Composable -fun Legend(legendData: List, modifier: Modifier = Modifier) { +fun Legend( + legendData: List, + modifier: Modifier = Modifier, + hiddenSet: Set = emptySet(), + onToggle: ((Int) -> Unit)? = null, +) { FlowRow( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.Center, - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), ) { - legendData.forEach { data -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { - LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine) + 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) }, + leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, + modifier = Modifier.padding(horizontal = 2.dp), + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + LegendIndicator(color = data.color, isLine = data.isLine) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelSmall.fontSize, + ) + } } } } @@ -137,7 +183,7 @@ fun Legend(legendData: List, modifier: Modifier = Modifier) { @Composable fun LegendInfoDialog(infoData: List, onDismiss: () -> Unit) { AlertDialog( - icon = { Icon(imageVector = Icons.Rounded.Info, contentDescription = null) }, + icon = { Icon(imageVector = MeshtasticIcons.Info, contentDescription = null) }, title = { Text( text = stringResource(Res.string.info), @@ -180,8 +226,9 @@ fun LegendInfoDialog(infoData: List, onDismiss: () -> Unit) { ) } +/** Draws a small colored line segment or circle to identify a chart series. */ @Composable -private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { +fun LegendIndicator(color: Color, isLine: Boolean = false) { Canvas(modifier = Modifier.size(height = 4.dp, width = if (isLine) 16.dp else 4.dp)) { if (isLine) { drawLine( @@ -195,12 +242,6 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { drawCircle(color = color) } } - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = text, - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelSmall.fontSize, - ) } @Composable @@ -208,13 +249,21 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) { Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color)) } -@Suppress("UnusedPrivateMember") // Compose preview +@PreviewLightDark +@Suppress("unused") // Compose preview @Composable private fun LegendPreview() { val data = listOf( - LegendData(nameRes = Res.string.rssi, color = Color.Red), - LegendData(nameRes = Res.string.snr, color = Color.Green), + LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true), + LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true), ) - Legend(legendData = data) + AppTheme { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Read-only legend + Legend(legendData = data) + // Toggleable legend + Legend(legendData = data, hiddenSet = setOf(1), onToggle = {}) + } + } } 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 78f04396f..609048a92 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -18,8 +18,6 @@ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,10 +30,6 @@ 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.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -49,21 +43,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis -import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer 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 +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_util_definition @@ -84,7 +81,7 @@ 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.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class Device(val color: Color) { @@ -106,20 +103,10 @@ private enum class Device(val color: Color) { private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null), - LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null), - LegendData( - nameRes = Res.string.channel_utilization, - color = Device.CH_UTIL.color, - isLine = false, - environmentMetric = null, - ), - LegendData( - nameRes = Res.string.air_utilization, - color = Device.AIR_UTIL.color, - isLine = false, - environmentMetric = null, - ), + LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true), + LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true), + LegendData(nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, isLine = true), + LegendData(nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, isLine = true), ) @Suppress("LongMethod") @@ -130,6 +117,8 @@ 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 } } @@ -181,6 +170,7 @@ 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, @@ -215,7 +205,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsChart( modifier: Modifier = Modifier, telemetries: List, @@ -224,10 +213,10 @@ private fun DeviceMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (telemetries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = legendData, modifier = modifier) { + modelProducer, + chartModifier, + -> val batteryColor = Device.BATTERY.color val voltageColor = Device.VOLTAGE.color val chUtilColor = Device.CH_UTIL.color @@ -243,12 +232,13 @@ private fun DeviceMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { - batteryColor -> formatString(percentValueTemplate, batteryLabel, value) - voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) - chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) - airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value) - else -> formatString(numericValueTemplate, value) + 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) } }, ) @@ -260,19 +250,19 @@ private fun DeviceMetricsChart( val batteryStyle = if (batteryData.isNotEmpty()) { - ChartStyling.createBoldLine(batteryColor, ChartStyling.MEDIUM_POINT_SIZE_DP) + ChartStyling.createBoldLine(batteryColor) } else { null } val chUtilStyle = if (chUtilData.isNotEmpty()) { - ChartStyling.createPointOnlyLine(chUtilColor, ChartStyling.LARGE_POINT_SIZE_DP) + ChartStyling.createSubtleLine(chUtilColor) } else { null } val airUtilStyle = if (airUtilData.isNotEmpty()) { - ChartStyling.createPointOnlyLine(airUtilColor, ChartStyling.LARGE_POINT_SIZE_DP) + ChartStyling.createDashedLine(airUtilColor) } else { null } @@ -317,44 +307,41 @@ private fun DeviceMetricsChart( } } + val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) } val leftLayer = - if (leftLayerSeriesStyles.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } else { - null - } + rememberConditionalLayer( + hasData = leftLayerSeriesStyles.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = percentRangeProvider, + ) val rightLayer = - if (voltageData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine( - lineColor = voltageColor, - pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, - ), - ), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = voltageData.isNotEmpty(), + lineProvider = + LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(lineColor = voltageColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) } if (layers.isNotEmpty()) { + val decorations = buildList { + if (leftLayer != null) { + add(ChartStyling.rememberThresholdLine(y = 20.0, color = batteryColor, label = "20%")) + } + } + GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (leftLayer != null) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = batteryColor), - valueFormatter = { _, value, _ -> formatString("%.0f%%", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) }, ) } else { null @@ -363,32 +350,25 @@ private fun DeviceMetricsChart( if (rightLayer != null) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, + decorations = decorations, selectedX = selectedX, onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, ) } - - Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp)) } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsChartPreview() { val now = nowSeconds.toInt() val telemetries = @@ -419,7 +399,6 @@ private fun DeviceMetricsChartPreview() { @Composable @Suppress("LongMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val deviceMetrics = telemetry.device_metrics val time = telemetry.time.toLong() * MS_PER_SEC @@ -428,101 +407,75 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick val uptimeLabel = stringResource(Res.string.uptime) val percentValueTemplate = stringResource(Res.string.device_metrics_percent_value) val labelValueTemplate = stringResource(Res.string.device_metrics_label_value) - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - /* Time, Battery, and Voltage */ - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + /* Time, Battery, and Voltage */ + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) - Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics?.battery_level != null) { - MetricIndicator(Device.BATTERY.color) - Spacer(Modifier.width(4.dp)) - } - if (deviceMetrics?.voltage != null) { - MetricIndicator(Device.VOLTAGE.color) - Spacer(Modifier.width(8.dp)) - } - MaterialBatteryInfo( - level = deviceMetrics?.battery_level ?: 0, - voltage = deviceMetrics?.voltage ?: 0f, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + if (deviceMetrics?.battery_level != null) { + MetricIndicator(Device.BATTERY.color) + Spacer(Modifier.width(4.dp)) } + if (deviceMetrics?.voltage != null) { + MetricIndicator(Device.VOLTAGE.color) + Spacer(Modifier.width(8.dp)) + } + MaterialBatteryInfo( + level = deviceMetrics?.battery_level ?: 0, + voltage = deviceMetrics?.voltage ?: 0f, + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - /* Channel Utilization and Air Utilization Tx */ - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics?.channel_utilization != null) { - MetricIndicator(Device.CH_UTIL.color) - Spacer(Modifier.width(4.dp)) - Text( - text = - formatString( - percentValueTemplate, - channelUtilizationLabel, - deviceMetrics.channel_utilization ?: 0f, - ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - Spacer(Modifier.width(12.dp)) - } - if (deviceMetrics?.air_util_tx != null) { - MetricIndicator(Device.AIR_UTIL.color) - Spacer(Modifier.width(4.dp)) - Text( - text = - formatString( - percentValueTemplate, - airUtilizationLabel, - deviceMetrics.air_util_tx ?: 0f, - ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - } - Text( + /* Channel Utilization and Air Utilization Tx */ + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (deviceMetrics?.channel_utilization != null) { + MetricValueRow( + color = Device.CH_UTIL.color, text = formatString( - labelValueTemplate, - uptimeLabel, - formatUptime(deviceMetrics?.uptime_seconds ?: 0), + percentValueTemplate, + channelUtilizationLabel, + NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1), + ), + ) + Spacer(Modifier.width(12.dp)) + } + if (deviceMetrics?.air_util_tx != null) { + MetricValueRow( + color = Device.AIR_UTIL.color, + text = + formatString( + percentValueTemplate, + airUtilizationLabel, + NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1), ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } } + Text( + text = + formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) } } } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsCardPreview() { val now = nowSeconds.toInt() val telemetry = @@ -540,9 +493,9 @@ private fun DeviceMetricsCardPreview() { AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsScreenPreview() { val now = nowSeconds.toInt() val telemetries = 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 6470e24dc..5029729ca 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -21,14 +21,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis -import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -39,10 +42,13 @@ 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 import org.meshtastic.core.resources.temperature import org.meshtastic.core.resources.uv_lux +import org.meshtastic.core.resources.wind_speed import org.meshtastic.proto.Telemetry @Suppress("MagicNumber") @@ -52,40 +58,42 @@ private val LEGEND_DATA_1 = nameRes = Res.string.temperature, color = Environment.TEMPERATURE.color, isLine = true, - environmentMetric = Environment.TEMPERATURE, + metricKey = Environment.TEMPERATURE, ), LegendData( nameRes = Res.string.humidity, color = Environment.HUMIDITY.color, isLine = true, - environmentMetric = Environment.HUMIDITY, + metricKey = Environment.HUMIDITY, ), ) private val LEGEND_DATA_2 = listOf( - LegendData( - nameRes = Res.string.iaq, - color = Environment.IAQ.color, - isLine = true, - environmentMetric = Environment.IAQ, - ), + LegendData(nameRes = Res.string.iaq, color = Environment.IAQ.color, isLine = true, metricKey = Environment.IAQ), LegendData( nameRes = Res.string.baro_pressure, color = Environment.BAROMETRIC_PRESSURE.color, isLine = true, - environmentMetric = Environment.BAROMETRIC_PRESSURE, - ), - LegendData( - nameRes = Res.string.lux, - color = Environment.LUX.color, - isLine = true, - environmentMetric = Environment.LUX, + metricKey = Environment.BAROMETRIC_PRESSURE, ), + LegendData(nameRes = Res.string.lux, color = Environment.LUX.color, isLine = true, metricKey = Environment.LUX), LegendData( nameRes = Res.string.uv_lux, color = Environment.UV_LUX.color, isLine = true, - environmentMetric = Environment.UV_LUX, + metricKey = Environment.UV_LUX, + ), + LegendData( + nameRes = Res.string.wind_speed, + color = Environment.WIND_SPEED.color, + isLine = true, + metricKey = Environment.WIND_SPEED, + ), + LegendData( + nameRes = Res.string.radiation, + color = Environment.RADIATION.color, + isLine = true, + metricKey = Environment.RADIATION, ), ) @@ -95,16 +103,37 @@ private val LEGEND_DATA_3 = nameRes = Res.string.soil_temperature, color = Environment.SOIL_TEMPERATURE.color, isLine = true, - environmentMetric = Environment.SOIL_TEMPERATURE, + metricKey = Environment.SOIL_TEMPERATURE, ), LegendData( nameRes = Res.string.soil_moisture, color = Environment.SOIL_MOISTURE.color, isLine = true, - environmentMetric = Environment.SOIL_MOISTURE, + metricKey = Environment.SOIL_MOISTURE, ), ) +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( @@ -125,13 +154,24 @@ fun EnvironmentMetricsChart( val onSurfaceColor = MaterialTheme.colorScheme.onSurface val allLegendData = - (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { - graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] + (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter { + graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } - val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } + // Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering. + var hiddenMetrics by remember { mutableStateOf(emptySet()) } + val hiddenIndices = + remember(hiddenMetrics, allLegendData) { + allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet() + } + + val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } + + val showPressure = + shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics val pressureData = - remember(telemetries) { + remember(telemetries, showPressure) { + if (!showPressure) return@remember emptyList() telemetries.filter { val v = Environment.BAROMETRIC_PRESSURE.getValue(it) it.time != 0 && v != null && !v.isNaN() @@ -139,9 +179,10 @@ fun EnvironmentMetricsChart( } val otherMetrics = - remember(telemetries, shouldPlot) { + remember(telemetries, shouldPlot, hiddenMetrics) { Environment.entries.filter { metric -> metric != Environment.BAROMETRIC_PRESSURE && + metric !in hiddenMetrics && shouldPlot[metric.ordinal] && telemetries.any { val v = metric.getValue(it) @@ -163,7 +204,7 @@ fun EnvironmentMetricsChart( LaunchedEffect(pressureData, otherMetricsData) { modelProducer.runTransaction { /* Pressure on its own layer/axis */ - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { lineSeries { series( x = pressureData.map { it.time }, @@ -187,34 +228,47 @@ fun EnvironmentMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - val label = colorToLabel[color.copy(alpha = 1f)] ?: "" + val label = colorToLabel[color] ?: "" formatString("%s: %.1f", label, value) }, ) + val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) } val layers = mutableListOf() - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { layers.add( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine( - Environment.BAROMETRIC_PRESSURE.color, - ChartStyling.MEDIUM_POINT_SIZE_DP, - ), + ChartStyling.createGradientLine(Environment.BAROMETRIC_PRESSURE.color), ), 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, ), ) } otherMetrics.forEach { metric -> + // Radiation and wind speed use fixed minY=0 per Oscar's UX guidance + val rangeProvider = + when (metric) { + Environment.RADIATION, + Environment.WIND_SPEED, + -> CartesianLayerRangeProvider.auto() + else -> null + } + val lineStyle = + if (metric == Environment.WIND_SPEED) { + ChartStyling.createDashedLine(metric.color) + } else { + ChartStyling.createStyledLine(metric.color) + } layers.add( rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), + lineProvider = LineCartesianLayer.LineProvider.series(lineStyle), verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(), ), ) } @@ -227,7 +281,7 @@ fun EnvironmentMetricsChart( modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), layers = layers, startAxis = - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color), valueFormatter = { _, value, _ -> formatString("%.0f hPa", value) }, @@ -236,17 +290,15 @@ fun EnvironmentMetricsChart( null }, endAxis = - VerticalAxis.rememberEnd( - label = ChartStyling.rememberAxisLabel(color = endAxisColor), - valueFormatter = { _, value, _ -> formatString("%.0f", value) }, - ), - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + if (otherMetrics.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = endAxisColor), + valueFormatter = { _, value, _ -> formatString("%.0f", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, @@ -254,6 +306,14 @@ fun EnvironmentMetricsChart( ) } - Legend(legendData = allLegendData, modifier = Modifier.padding(top = 0.dp)) + Legend( + legendData = allLegendData, + 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 + }, + ) } } 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 863e09eec..d09bdc8d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -18,8 +18,6 @@ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,10 +29,6 @@ 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.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -42,14 +36,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark 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 +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.current import org.meshtastic.core.resources.env_metrics_log @@ -58,15 +55,23 @@ 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 import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature import org.meshtastic.core.resources.temperature import org.meshtastic.core.resources.uv_lux import org.meshtastic.core.resources.voltage +import org.meshtastic.core.resources.wind_direction +import org.meshtastic.core.resources.wind_gust +import org.meshtastic.core.resources.wind_lull +import org.meshtastic.core.resources.wind_speed import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry @Composable @@ -77,6 +82,10 @@ 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, @@ -86,6 +95,7 @@ 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, @@ -120,7 +130,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun TemperatureDisplay( envMetrics: org.meshtastic.proto.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean, @@ -142,7 +151,6 @@ private fun TemperatureDisplay( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true @@ -158,7 +166,10 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), + text = + "${stringResource( + Res.string.humidity, + )} ${MetricFormatter.percent(humidity, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -171,7 +182,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%.2f hPa", pressure), + text = MetricFormatter.pressure(pressure, decimalPlaces = 2), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -183,7 +194,6 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SoilMetricsDisplay( envMetrics: org.meshtastic.proto.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean, @@ -236,7 +246,6 @@ private fun SoilMetricsDisplay( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN() val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN() @@ -272,7 +281,6 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN() val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN() @@ -282,7 +290,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), + text = "${stringResource(Res.string.voltage)} ${MetricFormatter.voltage(voltage)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -290,7 +298,10 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), + text = + "${stringResource( + Res.string.current, + )} ${MetricFormatter.current(currentValue, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -300,7 +311,6 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val iaqValue = envMetrics.iaq val gasResistance = envMetrics.gas_resistance @@ -336,13 +346,112 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.radiation?.let { radiation -> if (!radiation.isNaN() && radiation > 0f) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(Environment.RADIATION.color) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } + } + } +} + +@Composable +private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN() + val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN() + val hasLull = envMetrics.wind_lull != null && !envMetrics.wind_lull!!.isNaN() + + if (hasSpeed || hasGust || hasLull) { + Column(modifier = Modifier.fillMaxWidth()) { + if (hasSpeed) WindSpeedRow(envMetrics) + if (hasGust || hasLull) WindGustLullRow(envMetrics, hasGust, hasLull) + } + } +} + +@Composable +private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(Environment.WIND_SPEED.color) + Spacer(Modifier.width(4.dp)) + val dirText = + if (envMetrics.wind_direction != null) { + formatString( + "%s %.1f m/s (%s %d°)", + stringResource(Res.string.wind_speed), + envMetrics.wind_speed!!, + stringResource(Res.string.wind_direction), + envMetrics.wind_direction!!, + ) + } else { + formatString( + "%s %s", + stringResource(Res.string.wind_speed), + MetricFormatter.windSpeed(envMetrics.wind_speed!!), + ) + } + Text( + text = dirText, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + +@Composable +private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + if (hasGust) { + Text( + text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(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!!)}", + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + +@Composable +private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN() + val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN() + + if (has1h || has24h) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + if (has1h) { Text( - text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation), + text = + "${stringResource( + Res.string.rainfall_1h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + if (has24h) { + Text( + text = + "${stringResource( + Res.string.rainfall_24h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -352,34 +461,51 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +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, environmentDisplayFahrenheit: Boolean, isSelected: Boolean, onClick: () -> Unit, ) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() val time = telemetry.time.toLong() * MS_PER_SEC @@ -387,7 +513,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa /* Time and Temperature */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - text = CommonCharts.formatDateTime(time), + text = DateFormatter.formatDateTime(time), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold, ) @@ -406,12 +532,15 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa VoltageCurrentDisplay(envMetrics) RadiationDisplay(envMetrics) + WindDisplay(envMetrics) + RainfallDisplay(envMetrics) + OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } } -@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PreviewEnvironmentMetricsContent() { val fakeEnvMetrics = org.meshtastic.proto.EnvironmentMetrics( @@ -427,9 +556,13 @@ private fun PreviewEnvironmentMetricsContent() { iaq = 100, radiation = 0.15f, gas_resistance = 1200.0f, + wind_speed = 5.2f, + wind_direction = 225, + wind_gust = 8.1f, + wind_lull = 2.3f, + rainfall_1h = 1.5f, + rainfall_24h = 12.3f, ) val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics) - MaterialTheme { - Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } - } + AppTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } } } 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 1d0524500..686a228b2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -18,15 +18,25 @@ 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 @Suppress("MagicNumber") @@ -59,6 +69,44 @@ enum class Environment(val color: Color) { }, UV_LUX(Orange) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.uv_lux + }, + WIND_SPEED(Teal) { + 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) }, ; abstract fun getValue(telemetry: Telemetry): Float? @@ -114,9 +162,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Relative Humidity - val humidities = telemetries.mapNotNull { - it.environment_metrics?.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f } - } + val humidities = + telemetries.mapNotNull { it.environment_metrics?.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f } } if (humidities.isNotEmpty()) { minValues.add(humidities.minOf { it }) maxValues.add(humidities.maxOf { it }) @@ -124,9 +171,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Soil Temperature - val soilTemperatures = telemetries.mapNotNull { - it.environment_metrics?.soil_temperature?.takeIf { !it.isNaN() } - } + val soilTemperatures = + telemetries.mapNotNull { it.environment_metrics?.soil_temperature?.takeIf { !it.isNaN() } } if (soilTemperatures.isNotEmpty()) { var minSoilTemperatureValue = soilTemperatures.minOf { it } var maxSoilTemperatureValue = soilTemperatures.maxOf { it } @@ -140,9 +186,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Soil Moisture - val soilMoistures = telemetries.mapNotNull { - it.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE } - } + val soilMoistures = + telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE } } if (soilMoistures.isNotEmpty()) { minValues.add(soilMoistures.minOf { it.toFloat() }) maxValues.add(soilMoistures.maxOf { it.toFloat() }) @@ -183,6 +228,50 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp shouldPlot[Environment.UV_LUX.ordinal] = true } + // Wind Speed + val windSpeeds = telemetries.mapNotNull { it.environment_metrics?.wind_speed?.takeIf { !it.isNaN() } } + if (windSpeeds.isNotEmpty()) { + minValues.add(windSpeeds.minOf { it }) + maxValues.add(windSpeeds.maxOf { it }) + shouldPlot[Environment.WIND_SPEED.ordinal] = true + } + + // Radiation (uses separate fixed axis with minY=0 per Oscar's guidance) + val radiationValues = + telemetries.mapNotNull { it.environment_metrics?.radiation?.takeIf { !it.isNaN() && it > 0f } } + if (radiationValues.isNotEmpty()) { + minValues.add(radiationValues.minOf { it }) + maxValues.add(radiationValues.maxOf { it }) + 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/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt new file mode 100644 index 000000000..d4f362ca4 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt @@ -0,0 +1,218 @@ +/* + * 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("MagicNumber") + +package org.meshtastic.feature.node.metrics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider +import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.free_memory +import org.meshtastic.core.resources.free_memory_description +import org.meshtastic.core.resources.load_15_min +import org.meshtastic.core.resources.load_15_min_description +import org.meshtastic.core.resources.load_1_min +import org.meshtastic.core.resources.load_1_min_description +import org.meshtastic.core.resources.load_5_min +import org.meshtastic.core.resources.load_5_min_description +import org.meshtastic.core.ui.theme.GraphColors +import org.meshtastic.proto.Telemetry + +/** Chart series colours for the four host metrics. */ +private enum class HostMetric(val color: Color) { + LOAD_1(GraphColors.Blue), + LOAD_5(GraphColors.Green), + LOAD_15(GraphColors.Orange), + FREE_MEM(GraphColors.Teal), +} + +/** Legend entries for the host metrics chart. */ +internal val HOST_METRICS_LEGEND_DATA = + listOf( + LegendData(nameRes = Res.string.load_1_min, color = HostMetric.LOAD_1.color, isLine = true), + LegendData(nameRes = Res.string.load_5_min, color = HostMetric.LOAD_5.color, isLine = true), + LegendData(nameRes = Res.string.load_15_min, color = HostMetric.LOAD_15.color, isLine = true), + LegendData(nameRes = Res.string.free_memory, color = HostMetric.FREE_MEM.color, isLine = true), + ) + +/** Info-dialog entries describing each host metric for the legend help overlay. */ +internal val HOST_METRICS_INFO_DATA = + listOf( + InfoDialogData( + titleRes = Res.string.load_1_min, + definitionRes = Res.string.load_1_min_description, + color = HostMetric.LOAD_1.color, + ), + InfoDialogData( + titleRes = Res.string.load_5_min, + definitionRes = Res.string.load_5_min_description, + color = HostMetric.LOAD_5.color, + ), + InfoDialogData( + titleRes = Res.string.load_15_min, + definitionRes = Res.string.load_15_min_description, + color = HostMetric.LOAD_15.color, + ), + InfoDialogData( + titleRes = Res.string.free_memory, + definitionRes = Res.string.free_memory_description, + color = HostMetric.FREE_MEM.color, + ), + ) + +/** + * Vico chart composable that renders load averages (1m, 5m, 15m) and free memory as dual-axis line series: load on the + * start axis (fixed min 0), free memory in MB on the end axis. + * + * Load values from the proto are in 1/100ths (e.g. 150 = 1.50 load). They are divided by 100 for display. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun HostMetricsChart( + modifier: Modifier = Modifier, + data: List, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, +) { + MetricChartScaffold(isEmpty = data.isEmpty(), legendData = HOST_METRICS_LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> + val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } } + val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } } + val load15Data = + remember(data) { data.filter { it.host_metrics?.load15 != null && it.host_metrics!!.load15 > 0 } } + val memData = + remember(data) { + data.filter { it.host_metrics?.freemem_bytes != null && it.host_metrics!!.freemem_bytes > 0 } + } + + LaunchedEffect(load1Data, load5Data, load15Data, memData) { + modelProducer.runTransaction { + val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty() + if (hasLoad) { + lineSeries { + if (load1Data.isNotEmpty()) { + series(x = load1Data.map { it.time }, y = load1Data.map { it.host_metrics!!.load1 / 100.0 }) + } + if (load5Data.isNotEmpty()) { + series(x = load5Data.map { it.time }, y = load5Data.map { it.host_metrics!!.load5 / 100.0 }) + } + if (load15Data.isNotEmpty()) { + series( + x = load15Data.map { it.time }, + y = load15Data.map { it.host_metrics!!.load15 / 100.0 }, + ) + } + } + } + if (memData.isNotEmpty()) { + lineSeries { + series( + x = memData.map { it.time }, + y = memData.map { it.host_metrics!!.freemem_bytes.toDouble() / BYTES_IN_MB }, + ) + } + } + } + } + + val load1Color = HostMetric.LOAD_1.color + val load5Color = HostMetric.LOAD_5.color + val load15Color = HostMetric.LOAD_15.color + val memColor = HostMetric.FREE_MEM.color + + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color) { + load1Color -> formatString("L1: %.2f", value) + load5Color -> formatString("L5: %.2f", value) + load15Color -> formatString("L15: %.2f", value) + else -> formatString("Mem: %.0f MB", value) + } + }, + ) + + val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty() + val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null + val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null + val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null + val loadStyles = listOfNotNull(load1Style, load5Style, load15Style) + + val loadLayer = + rememberConditionalLayer( + hasData = hasLoad, + lineProvider = LineCartesianLayer.LineProvider.series(loadStyles), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + + val memLayer = + rememberConditionalLayer( + hasData = memData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + + val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) } + + if (layers.isNotEmpty()) { + GenericMetricChart( + modelProducer = modelProducer, + modifier = chartModifier, + layers = layers, + startAxis = + if (hasLoad) { + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = load1Color), + valueFormatter = { _, value, _ -> formatString("%.1f", value) }, + ) + } else { + null + }, + endAxis = + if (memData.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = memColor), + valueFormatter = { _, value, _ -> formatString("%.0f MB", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) + } + } +} 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 2d0a9584e..2cbf008e1 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 @@ -14,201 +14,208 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign 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.formatString import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disk_free_indexed import org.meshtastic.core.resources.free_memory +import org.meshtastic.core.resources.host_metrics_log import org.meshtastic.core.resources.load_indexed import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.user_string -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.icon.DataArray -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.theme.GraphColors import org.meshtastic.proto.Telemetry +/** + * Full-screen host metrics log with chart and card list, built on [BaseMetricScreen]. Shows load averages and free + * memory over time with time-frame filtering, chart expand/collapse, and card-to-chart synchronisation. + */ @OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod") @Composable -fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) { - val state by metricsViewModel.state.collectAsStateWithLifecycle() +fun HostMetricsLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() - val hostMetrics = state.hostMetrics + val threshold = timeFrame.timeThreshold() + val filteredData = + remember(state.hostMetrics, threshold) { state.hostMetrics.filter { it.time.toLong() >= threshold } } - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.HOST) }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.HOST, + titleRes = Res.string.host_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = filteredData, + timeProvider = { it.time.toDouble() }, + infoData = HOST_METRICS_INFO_DATA, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.HOST) }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - ) { innerPadding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp), + chartPart = { chartModifier, selectedX, vicoScrollState, onPointSelected -> + HostMetricsChart( + modifier = chartModifier, + data = filteredData.reversed(), + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, + ) + }, + listPart = { listModifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = listModifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(filteredData, key = { index, t -> "${t.time}_$index" }) { _, telemetry -> + HostMetricsCard( + telemetry = telemetry, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { onCardClick(telemetry.time.toDouble()) }, + ) + } + } + }, + ) +} + +/** A selectable card summarising a single host metrics telemetry snapshot. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HostMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { + val hostMetrics = telemetry.host_metrics + val time = DateFormatter.formatDateTime(telemetry.time.toLong() * MS_PER_SEC) + var expanded by remember { mutableStateOf(false) } + + Box { + Card( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .combinedClickable(onClick = onClick, onLongClick = { expanded = true }), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { - items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) } + HostMetricsCardContent(time = time, hostMetrics = hostMetrics) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DeleteItem { expanded = false } } + } +} + +/** Card body showing timestamp, load averages with progress bars, memory, disk, and uptime. */ +@Composable +private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = time, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + + hostMetrics?.uptime_seconds?.let { + LogLine(label = stringResource(Res.string.uptime), value = formatUptime(it)) + } + hostMetrics?.freemem_bytes?.let { + LogLine(label = stringResource(Res.string.free_memory), value = formatBytes(it)) + } + + // Disk free rows + hostMetrics?.diskfree1_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 1), value = formatBytes(it)) + } + hostMetrics?.diskfree2_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 2), value = formatBytes(it)) + } + hostMetrics?.diskfree3_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 3), value = formatBytes(it)) + } + + // Load averages with coloured indicators and progress bars + hostMetrics?.load1?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 1), value = it, color = GraphColors.Blue) + } + hostMetrics?.load5?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 5), value = it, color = GraphColors.Green) + } + hostMetrics?.load15?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 15), value = it, color = GraphColors.Orange) + } + + hostMetrics?.user_string?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium) + Text(text = it, style = MaterialTheme.typography.bodySmall) } } } -@Suppress("LongMethod", "MagicNumber") +/** A load average row with coloured metric indicator, value text, and progress bar. */ @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) { - val hostMetrics = telemetry.host_metrics - val time = telemetry.time.toLong() * TimeConstants.MS_PER_SEC - Card( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Row(modifier = Modifier.padding(16.dp)) { - Icon(imageVector = MeshtasticIcons.DataArray, contentDescription = null, modifier = Modifier.width(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) - SelectionContainer { - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - text = DateFormatter.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - hostMetrics?.uptime_seconds?.let { - LogLine( - label = stringResource(Res.string.uptime), - value = formatUptime(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.freemem_bytes?.let { - LogLine( - label = stringResource(Res.string.free_memory), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree1_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 1), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree2_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 2), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree3_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 3), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.load1?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 1), - value = (hostMetrics.load1 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load1 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.load5?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 5), - value = (hostMetrics.load5 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load5 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.load15?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 15), - value = (hostMetrics.load15 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load15 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.user_string?.let { - Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium) - Text(text = it, style = TextStyle(fontFamily = FontFamily.Monospace)) - } - } - } - } +private fun LoadRow(label: String, value: Int, color: androidx.compose.ui.graphics.Color) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(color) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = formatString("%s: %.2f", label, value / 100.0), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + ) } + LinearProgressIndicator( + progress = { (value / 10000.0f).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + color = color, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) } @Composable 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 4a928b98a..92e929056 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 @@ -16,7 +16,9 @@ */ package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.BorderStroke 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.Row @@ -27,10 +29,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +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 @@ -38,6 +40,7 @@ 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -49,7 +52,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons /** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */ @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) { Card( modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp), @@ -99,3 +101,45 @@ fun DeleteItem(onClick: () -> Unit) { }, ) } + +/** + * A selectable [Card] for metric log items. Provides consistent selection styling (primary border + primaryContainer + * background) and text selection support across all metric screens. + */ +@Composable +fun SelectableMetricCard( + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + SelectionContainer { content() } + } +} + +/** A compact row displaying a colored [MetricIndicator] dot/line followed by a text value. */ +@Composable +fun MetricValueRow(color: Color, text: String, modifier: Modifier = Modifier) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + MetricIndicator(color) + Spacer(Modifier.width(4.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } +} 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 93bfb5212..10a3fe427 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull @@ -31,23 +30,23 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.evaluateTracerouteMapAvailability +import org.meshtastic.core.model.util.GeoConstants import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository @@ -60,12 +59,13 @@ 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.map.model.TracerouteOverlay 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 +104,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty) getNodeDetailsUseCase(nodeId).map { it.metricsState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty) + .stateInWhileSubscribed(initialValue = MetricsState.Empty) private val environmentState: StateFlow = activeNodeId @@ -112,7 +112,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState()) getNodeDetailsUseCase(nodeId).map { it.environmentState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState()) + .stateInWhileSubscribed(initialValue = EnvironmentMetricsState()) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) @@ -148,6 +148,8 @@ open class MetricsViewModel( temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + one_wire_temperature = + em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) }, ), ) } @@ -181,7 +183,8 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) - fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun deleteLog(uuid: String) = + safeLaunch(context = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] @@ -216,7 +219,7 @@ open class MetricsViewModel( private fun List.numSet(): Set = map { it.num }.toSet() init { - viewModelScope.launch { + safeLaunch(tag = "tracerouteCollector") { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( @@ -232,7 +235,7 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel created" } } - fun clearPosition() = viewModelScope.launch(dispatchers.io) { + fun clearPosition() = safeLaunch(context = dispatchers.io, tag = "clearPosition") { (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } @@ -275,9 +278,8 @@ open class MetricsViewModel( responseLogUuid: String, overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, - onShowError: (StringResource) -> Unit, ) { - viewModelScope.launch { + safeLaunch(tag = "showTracerouteDetail") { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, @@ -298,7 +300,11 @@ open class MetricsViewModel( ) val errorRes = availability.toMessageRes() if (errorRes != null) { - onShowError(errorRes) + // 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) + } } else { onViewOnMap(requestId, responseLogUuid) } @@ -319,35 +325,114 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel cleared" } } - fun savePositionCSV(uri: MeshtasticUri) { - viewModelScope.launch(dispatchers.main) { - val positions = state.value.positionLogs + // 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") { fileService.write(uri) { sink -> - 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) * 1e-7 - val longitude = (position.longitude_i ?: 0) * 1e-7 - val altitude = position.altitude - val satsInView = position.sats_in_view - val speed = position.ground_speed - val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5) - - sink.writeUtf8( - "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n", - ) + sink.writeUtf8(header) + rows.forEach { item -> + val dt = + Instant.fromEpochSeconds(epochSeconds(item)).toLocalDateTime(TimeZone.currentSystemDefault()) + sink.writeUtf8("\"${dt.date}\",\"${dt.time}\",${rowMapper(item)}\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 { @@ -377,4 +462,8 @@ open class MetricsViewModel( } protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0) + + companion object { + private const val ONE_WIRE_SENSOR_COUNT = 8 + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index c2dc2058d..b3b0b36e0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -28,9 +26,6 @@ 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.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,9 +40,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState -import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -57,17 +51,24 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ble_devices import org.meshtastic.core.resources.no_pax_metrics_logs import org.meshtastic.core.resources.pax +import org.meshtastic.core.resources.pax_ble_format +import org.meshtastic.core.resources.pax_ble_marker import org.meshtastic.core.resources.pax_metrics_log +import org.meshtastic.core.resources.pax_total_format +import org.meshtastic.core.resources.pax_total_marker +import org.meshtastic.core.resources.pax_wifi_format +import org.meshtastic.core.resources.pax_wifi_marker import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.IconInfo import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Paxcount +import org.meshtastic.core.ui.icon.PeopleCount import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.proto.Paxcount as ProtoPaxcount @@ -80,14 +81,13 @@ private enum class PaxSeries(val color: Color, val legendRes: StringResource) { private val LEGEND_DATA = listOf( - LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null), - LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null), - LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null), + LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color), + LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color), + LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color), ) @Suppress("LongMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PaxMetricsChart( modifier: Modifier = Modifier, totalSeries: List>, @@ -97,10 +97,10 @@ private fun PaxMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (totalSeries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = totalSeries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val paxColor = PaxSeries.PAX.color val bleColor = PaxSeries.BLE.color val wifiColor = PaxSeries.WIFI.color @@ -116,22 +116,26 @@ private fun PaxMetricsChart( } val axisLabel = ChartStyling.rememberAxisLabel() + val bleMarkerTemplate = stringResource(Res.string.pax_ble_marker) + val wifiMarkerTemplate = stringResource(Res.string.pax_wifi_marker) + val paxMarkerTemplate = stringResource(Res.string.pax_total_marker) val marker = ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { - bleColor -> formatString("BLE: %.0f", value) - wifiColor -> formatString("WiFi: %.0f", value) - paxColor -> formatString("PAX: %.0f", value) - else -> formatString("%.0f", value) + val formatted = formatString("%.0f", value) + when (color) { + bleColor -> bleMarkerTemplate.replace("%1\$s", formatted) + wifiColor -> wifiMarkerTemplate.replace("%1\$s", formatted) + paxColor -> paxMarkerTemplate.replace("%1\$s", formatted) + else -> formatted } }, ) GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), + modifier = chartModifier, layers = listOf( rememberLineCartesianLayer( @@ -139,34 +143,27 @@ private fun PaxMetricsChart( LineCartesianLayer.LineProvider.series( ChartStyling.createGradientLine( lineColor = bleColor, - pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + interpolator = LineCartesianLayer.Interpolator.Sharp, ), ChartStyling.createGradientLine( lineColor = wifiColor, - pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + interpolator = LineCartesianLayer.Interpolator.Sharp, ), ChartStyling.createBoldLine( lineColor = paxColor, - pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + interpolator = LineCartesianLayer.Interpolator.Sharp, ), ), + rangeProvider = CartesianLayerRangeProvider.auto(), ), ), startAxis = VerticalAxis.rememberStart(label = axisLabel), - bottomAxis = - HorizontalAxis.rememberBottom( - label = axisLabel, - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, ) - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp)) } } @@ -183,7 +180,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni remember(paxMetrics) { paxMetrics .map { - val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt() + val t = (it.first.received_date / MS_PER_SEC).toInt() Triple(t, it.second.ble, it.second.wifi) } .sortedBy { it.first } @@ -198,7 +195,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni titleRes = Res.string.pax_metrics_log, nodeName = state.node?.user?.long_name ?: "", data = paxMetrics, - timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() }, + timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() }, onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }, controlPart = { TimeFrameSelector( @@ -238,8 +235,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni PaxMetricsItem( log = log, pax = pax, - isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX, - onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) }, + isSelected = (log.received_date / MS_PER_SEC).toDouble() == selectedX, + onClick = { onCardClick((log.received_date / MS_PER_SEC).toDouble()) }, ) } } @@ -256,7 +253,7 @@ fun PaxcountInfo( ) { IconInfo( modifier = modifier, - icon = MeshtasticIcons.Paxcount, + icon = MeshtasticIcons.PeopleCount, contentDescription = stringResource(Res.string.pax_metrics_log), text = pax, contentColor = contentColor, @@ -264,21 +261,8 @@ fun PaxcountInfo( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Text( text = DateFormatter.formatDateTime(log.received_date), @@ -292,17 +276,20 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - MetricIndicator(PaxSeries.PAX.color) - Spacer(Modifier.width(4.dp)) - Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.PAX.color, + text = stringResource(Res.string.pax_total_format, pax.ble + pax.wifi), + ) Spacer(Modifier.width(8.dp)) - MetricIndicator(PaxSeries.BLE.color) - Spacer(Modifier.width(4.dp)) - Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.BLE.color, + text = stringResource(Res.string.pax_ble_format, pax.ble), + ) Spacer(Modifier.width(8.dp)) - MetricIndicator(PaxSeries.WIFI.color) - Spacer(Modifier.width(4.dp)) - Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.WIFI.color, + text = stringResource(Res.string.pax_wifi_format, pax.wifi), + ) } Text( 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 2a79f2fb1..e2f95f04b 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,25 +14,32 @@ * 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.ColumnScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme 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.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight 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 @@ -41,72 +48,95 @@ 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.resources.timestamp -import org.meshtastic.core.ui.util.formatPositionTime +import org.meshtastic.core.ui.theme.GraphColors 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 -private fun RowScope.PositionText(text: String, weight: Float) { - Text( - text = text, - modifier = Modifier.weight(weight), - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) -} - -private const val WEIGHT_10 = .10f -private const val WEIGHT_15 = .15f -private const val WEIGHT_20 = .20f -private const val WEIGHT_40 = .40f - -@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) - } -} - -const val DEG_D = 1e-7 -const val HEADING_DEG = 1e-5 - -@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, +@Suppress("LongMethod") +fun PositionCard( + position: Position, displayUnits: Config.DisplayConfig.DisplayUnits, + isSelected: Boolean, + onClick: () -> Unit, ) { - LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { - items(positions) { position -> PositionItem(compactWidth, position, displayUnits) } + 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) + + 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, + ) + + 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, + ) + } + } + } } } 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 a67d5d7dd..e414ea26d 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,126 +16,69 @@ */ package org.meshtastic.feature.node.metrics -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.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed 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.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.save -import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.resources.position_log import org.meshtastic.core.ui.icon.Delete 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 = - org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) } + val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } - var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) } + val trackMap = LocalNodeTrackMapProvider.current + val destNum = state.node?.num ?: 0 - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - IconButton(onClick = { viewModel.requestPosition() }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, - ) - }, - ) { innerPadding -> - BoxWithConstraints(modifier = Modifier.padding(innerPadding)) { - 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) + 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)) } - - ActionButtons( - clearButtonEnabled = clearButtonEnabled, - onClear = { - clearButtonEnabled = false - viewModel.clearPosition() - }, - saveButtonEnabled = state.hasPositionLogs(), - onSave = { exportPositionLauncher("position.csv", "text/csv") }, - ) } - } - } + if (!state.isLocal) { + IconButton(onClick = { viewModel.requestPosition() }) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } + }, + 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()) }, + ) + } + } + }, + ) } 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 5501554bf..5a71659f8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -18,8 +18,7 @@ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,24 +27,19 @@ import androidx.compose.foundation.layout.fillMaxSize 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -54,26 +48,31 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis -import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +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.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 import org.meshtastic.core.resources.channel_2 import org.meshtastic.core.resources.channel_3 +import org.meshtastic.core.resources.channel_4 +import org.meshtastic.core.resources.channel_5 +import org.meshtastic.core.resources.channel_6 +import org.meshtastic.core.resources.channel_7 +import org.meshtastic.core.resources.channel_8 import org.meshtastic.core.resources.current 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.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class PowerMetric(val color: Color) { @@ -85,22 +84,17 @@ private enum class PowerChannel(val strRes: StringResource) { ONE(Res.string.channel_1), TWO(Res.string.channel_2), THREE(Res.string.channel_3), + FOUR(Res.string.channel_4), + FIVE(Res.string.channel_5), + SIX(Res.string.channel_6), + SEVEN(Res.string.channel_7), + EIGHT(Res.string.channel_8), } private val LEGEND_DATA = listOf( - LegendData( - nameRes = Res.string.current, - color = PowerMetric.CURRENT.color, - isLine = true, - environmentMetric = null, - ), - LegendData( - nameRes = Res.string.voltage, - color = PowerMetric.VOLTAGE.color, - isLine = true, - environmentMetric = null, - ), + LegendData(nameRes = Res.string.current, color = PowerMetric.CURRENT.color, isLine = true), + LegendData(nameRes = Res.string.voltage, color = PowerMetric.VOLTAGE.color, isLine = true), ) @Suppress("LongMethod") @@ -110,7 +104,16 @@ 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() } - var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } + + 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) } BaseMetricScreen( onNavigateUp = onNavigateUp, @@ -120,6 +123,7 @@ 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( @@ -130,10 +134,11 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { ) Spacer(modifier = Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - PowerChannel.entries.forEach { channel -> + availableChannels.forEach { channel -> FilterChip( selected = selectedChannel == channel, onClick = { selectedChannel = channel }, @@ -169,7 +174,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerMetricsChart( modifier: Modifier = Modifier, telemetries: List, @@ -178,20 +182,20 @@ private fun PowerMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (telemetries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val currentColor = PowerMetric.CURRENT.color val voltageColor = PowerMetric.VOLTAGE.color val marker = ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { - currentColor -> formatString("Current: %.0f mA", value) - voltageColor -> formatString("Voltage: %.1f V", value) - else -> formatString("%.1f", value) + when (color) { + currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}" + voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V" + else -> NumberFormatter.format(value.toFloat(), 1) } }, ) @@ -205,7 +209,7 @@ private fun PowerMetricsChart( telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() } } - LaunchedEffect(currentData, voltageData) { + LaunchedEffect(selectedChannel, currentData, voltageData) { modelProducer.runTransaction { if (currentData.isNotEmpty()) { lineSeries { @@ -227,43 +231,31 @@ private fun PowerMetricsChart( } val currentLayer = - if (currentData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } else { - null - } + rememberConditionalLayer( + hasData = currentData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) val voltageLayer = - if (voltageData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = voltageData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) } if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (currentData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = currentColor), - valueFormatter = { _, value, _ -> formatString("%.0f mA", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) }, ) } else { null @@ -272,77 +264,43 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, ) } - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } @Composable -@Suppress("CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Suppress("CyclomaticComplexMethod", "LongMethod") private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val time = telemetry.time.toLong() * MS_PER_SEC - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface { - SelectionContainer { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - /* Time */ - Row { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + /* Time */ + Row { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - val pm = telemetry.power_metrics - if (pm != null) { - if (pm.ch1_current != null || pm.ch1_voltage != null) { - PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f) - } - if (pm.ch2_current != null || pm.ch2_voltage != null) { - PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f) - } - if (pm.ch3_current != null || pm.ch3_voltage != null) { - PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f) - } - } - } - } + val pm = telemetry.power_metrics + if (pm != null) { + PowerChannelsRow1(pm) + PowerChannelsExtraRows(pm) } } } @@ -350,7 +308,59 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (pm.ch1_current != null || pm.ch1_voltage != null) { + PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f) + } + if (pm.ch2_current != null || pm.ch2_voltage != null) { + PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f) + } + if (pm.ch3_current != null || pm.ch3_voltage != null) { + PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f) + } + } +} + +@Composable +@Suppress("CyclomaticComplexMethod") +private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) { + val hasCh456 = + hasChannelData(pm.ch4_voltage, pm.ch4_current) || + hasChannelData(pm.ch5_voltage, pm.ch5_current) || + hasChannelData(pm.ch6_voltage, pm.ch6_current) + val hasCh78 = hasChannelData(pm.ch7_voltage, pm.ch7_current) || hasChannelData(pm.ch8_voltage, pm.ch8_current) + + if (hasCh456) { + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (hasChannelData(pm.ch4_voltage, pm.ch4_current)) { + PowerChannelColumn(Res.string.channel_4, pm.ch4_voltage ?: 0f, pm.ch4_current ?: 0f) + } + if (hasChannelData(pm.ch5_voltage, pm.ch5_current)) { + PowerChannelColumn(Res.string.channel_5, pm.ch5_voltage ?: 0f, pm.ch5_current ?: 0f) + } + if (hasChannelData(pm.ch6_voltage, pm.ch6_current)) { + PowerChannelColumn(Res.string.channel_6, pm.ch6_voltage ?: 0f, pm.ch6_current ?: 0f) + } + } + } + if (hasCh78) { + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (hasChannelData(pm.ch7_voltage, pm.ch7_current)) { + PowerChannelColumn(Res.string.channel_7, pm.ch7_voltage ?: 0f, pm.ch7_current ?: 0f) + } + if (hasChannelData(pm.ch8_voltage, pm.ch8_current)) { + PowerChannelColumn(Res.string.channel_8, pm.ch8_voltage ?: 0f, pm.ch8_current ?: 0f) + } + } + } +} + +private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null + +@Composable private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) { Column { Text( @@ -358,39 +368,33 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.labelLarge.fontSize, ) - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(PowerMetric.VOLTAGE.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.2fV", voltage), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(PowerMetric.CURRENT.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.1fmA", current), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } + MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage)) + MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current)) } } /** Retrieves the appropriate voltage depending on `channelSelected`. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Suppress("CyclomaticComplexMethod") private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN PowerChannel.THREE -> telemetry.power_metrics?.ch3_voltage ?: Float.NaN + PowerChannel.FOUR -> telemetry.power_metrics?.ch4_voltage ?: Float.NaN + PowerChannel.FIVE -> telemetry.power_metrics?.ch5_voltage ?: Float.NaN + PowerChannel.SIX -> telemetry.power_metrics?.ch6_voltage ?: Float.NaN + PowerChannel.SEVEN -> telemetry.power_metrics?.ch7_voltage ?: Float.NaN + PowerChannel.EIGHT -> telemetry.power_metrics?.ch8_voltage ?: Float.NaN } /** Retrieves the appropriate current depending on `channelSelected`. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Suppress("CyclomaticComplexMethod") private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN PowerChannel.THREE -> telemetry.power_metrics?.ch3_current ?: Float.NaN + PowerChannel.FOUR -> telemetry.power_metrics?.ch4_current ?: Float.NaN + PowerChannel.FIVE -> telemetry.power_metrics?.ch5_current ?: Float.NaN + PowerChannel.SIX -> telemetry.power_metrics?.ch6_current ?: Float.NaN + PowerChannel.SEVEN -> telemetry.power_metrics?.ch7_current ?: Float.NaN + PowerChannel.EIGHT -> telemetry.power_metrics?.ch8_current ?: Float.NaN } 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 f9c3d6955..4931d8c59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,12 +29,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.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -50,24 +43,21 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis -import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res 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.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.MeshPacket private enum class SignalMetric(val color: Color) { @@ -77,8 +67,8 @@ private enum class SignalMetric(val color: Color) { private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null), - LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null), + LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color), + LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color), ) @Suppress("LongMethod") @@ -89,6 +79,8 @@ 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, @@ -97,11 +89,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { data = data, timeProvider = { it.rx_time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }, - infoData = - listOf( - InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color), - InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color), - ), + onExportCsv = { exportLauncher("signal_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, @@ -135,7 +123,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SignalMetricsChart( modifier: Modifier = Modifier, meshPackets: List, @@ -143,10 +130,10 @@ private fun SignalMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (meshPackets.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val rssiColor = SignalMetric.RSSI.color val snrColor = SignalMetric.SNR.color @@ -169,52 +156,40 @@ private fun SignalMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - if (color.copy(alpha = 1f) == rssiColor) { - formatString("RSSI: %.0f dBm", value) + if (color == rssiColor) { + "RSSI: ${MetricFormatter.rssi(value.toInt())}" } else { - formatString("SNR: %.1f dB", value) + "SNR: ${MetricFormatter.snr(value.toFloat())}" } }, ) val rssiLayer = - if (rssiData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP), - ), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } else { - null - } + rememberConditionalLayer( + hasData = rssiData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) val snrLayer = - if (snrData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP), - ), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = snrData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) } if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (rssiData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = rssiColor), - valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) }, ) } else { null @@ -223,88 +198,53 @@ private fun SignalMetricsChart( if (snrData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = snrColor), - valueFormatter = { _, value, _ -> formatString("%.1f dB", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) }, ) } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, ) } - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) { val time = meshPacket.rx_time.toLong() * MS_PER_SEC - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - /* Data */ - Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) { - Column(modifier = Modifier.padding(12.dp)) { - /* Time */ - Row(horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - /* SNR and RSSI */ - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(SignalMetric.RSSI.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), - style = MaterialTheme.typography.labelLarge, - ) - Spacer(Modifier.width(12.dp)) - MetricIndicator(SignalMetric.SNR.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.1f dB", meshPacket.rx_snr), - style = MaterialTheme.typography.labelLarge, - ) - } - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + /* Data */ + Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) { + Column(modifier = Modifier.padding(12.dp)) { + /* Time */ + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) } - /* Signal Indicator */ - Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) { - LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi) + Spacer(modifier = Modifier.height(8.dp)) + + /* SNR and RSSI */ + Row(verticalAlignment = Alignment.CenterVertically) { + MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi)) + Spacer(Modifier.width(12.dp)) + MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr)) } } } + + /* Signal Indicator */ + Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) { + LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi) + } } } } 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 new file mode 100644 index 000000000..c27f111d1 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -0,0 +1,255 @@ +/* + * 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("MagicNumber", "MatchingDeclarationName") + +package org.meshtastic.feature.node.metrics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider +import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.traceroute_duration +import org.meshtastic.core.resources.traceroute_forward_hops +import org.meshtastic.core.resources.traceroute_outgoing_route +import org.meshtastic.core.resources.traceroute_return_hops +import org.meshtastic.core.resources.traceroute_return_route +import org.meshtastic.core.resources.traceroute_round_trip +import org.meshtastic.core.ui.theme.GraphColors + +/** Resolved traceroute data point pairing a request with its optional response. */ +internal data class TraceroutePoint( + val request: MeshLog, + val result: MeshLog?, + /** Request timestamp in epoch seconds, used as the chart X coordinate. */ + val timeSeconds: Double, + /** Number of intermediate hops toward the destination, or null if no response received. */ + val forwardHops: Int?, + /** Number of intermediate hops on the return path, or null if unavailable. */ + val returnHops: Int?, + /** Round-trip duration in seconds between request sent and response received, or null. */ + val roundTripSeconds: Double?, +) + +/** Chart series colours for the three traceroute metrics. */ +private enum class TracerouteMetric(val color: Color) { + FORWARD_HOPS(GraphColors.Blue), + RETURN_HOPS(GraphColors.Green), + ROUND_TRIP(GraphColors.Orange), +} + +/** Legend entries for the traceroute chart — forward hops, return hops, and round-trip duration. */ +internal val TRACEROUTE_LEGEND_DATA = + listOf( + LegendData( + nameRes = Res.string.traceroute_forward_hops, + color = TracerouteMetric.FORWARD_HOPS.color, + isLine = true, + ), + LegendData( + nameRes = Res.string.traceroute_return_hops, + color = TracerouteMetric.RETURN_HOPS.color, + isLine = true, + ), + LegendData( + nameRes = Res.string.traceroute_round_trip, + color = TracerouteMetric.ROUND_TRIP.color, + isLine = true, + ), + ) + +/** Info-dialog entries describing each traceroute metric for the legend help overlay. */ +internal val TRACEROUTE_INFO_DATA = + listOf( + InfoDialogData( + titleRes = Res.string.traceroute_forward_hops, + definitionRes = Res.string.traceroute_outgoing_route, + color = TracerouteMetric.FORWARD_HOPS.color, + ), + InfoDialogData( + titleRes = Res.string.traceroute_return_hops, + definitionRes = Res.string.traceroute_return_route, + color = TracerouteMetric.RETURN_HOPS.color, + ), + InfoDialogData( + titleRes = Res.string.traceroute_round_trip, + definitionRes = Res.string.traceroute_duration, + color = TracerouteMetric.ROUND_TRIP.color, + ), + ) + +/** + * Matches each traceroute request with its response (if any) and computes hop counts and round-trip duration. Results + * are ordered the same as [requests] — newest-first when coming from the ViewModel. + */ +internal fun resolveTraceroutePoints(requests: List, results: List): List = + requests.map { request -> + val requestPacketId = request.fromRadio.packet?.id + val result = results.find { it.fromRadio.packet?.decoded?.request_id == requestPacketId } + val route = result?.fromRadio?.packet?.fullRouteDiscovery + val timeSeconds = (request.received_date / MS_PER_SEC).toDouble() + + val forwardHops = route?.let { maxOf(0, it.route.size - 2) } + val returnHops = route?.let { if (it.route_back.isNotEmpty()) maxOf(0, it.route_back.size - 2) else null } + val roundTrip = + if (result != null) { + (result.received_date - request.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC + } else { + null + } + + TraceroutePoint( + request = request, + result = result, + timeSeconds = timeSeconds, + forwardHops = forwardHops, + returnHops = returnHops, + roundTripSeconds = roundTrip, + ) + } + +/** + * Vico chart composable that renders forward hops, return hops, and round-trip duration as separate line series with + * dual Y-axes: hops on the start axis (fixed min 0) and RTT seconds on the end axis. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun TracerouteMetricsChart( + modifier: Modifier = Modifier, + points: List, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, +) { + MetricChartScaffold(isEmpty = points.isEmpty(), legendData = TRACEROUTE_LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> + val forwardData = remember(points) { points.filter { it.forwardHops != null } } + val returnData = remember(points) { points.filter { it.returnHops != null } } + val rttData = remember(points) { points.filter { it.roundTripSeconds != null } } + + LaunchedEffect(forwardData, returnData, rttData) { + modelProducer.runTransaction { + if (forwardData.isNotEmpty()) { + lineSeries { + series(x = forwardData.map { it.timeSeconds }, y = forwardData.map { it.forwardHops!! }) + } + } + if (returnData.isNotEmpty()) { + lineSeries { series(x = returnData.map { it.timeSeconds }, y = returnData.map { it.returnHops!! }) } + } + if (rttData.isNotEmpty()) { + lineSeries { series(x = rttData.map { it.timeSeconds }, y = rttData.map { it.roundTripSeconds!! }) } + } + } + } + + val forwardColor = TracerouteMetric.FORWARD_HOPS.color + val returnColor = TracerouteMetric.RETURN_HOPS.color + val rttColor = TracerouteMetric.ROUND_TRIP.color + + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color) { + forwardColor -> formatString("Fwd: %.0f hops", value) + returnColor -> formatString("Ret: %.0f hops", value) + else -> formatString("RTT: %.1f s", value) + } + }, + ) + + val forwardLayer = + rememberConditionalLayer( + hasData = forwardData.isNotEmpty(), + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createStyledLine( + forwardColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.auto(), + ) + + val returnLayer = + rememberConditionalLayer( + hasData = returnData.isNotEmpty(), + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createDashedLine(returnColor, interpolator = LineCartesianLayer.Interpolator.Sharp), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.auto(), + ) + + val rttLayer = + rememberConditionalLayer( + hasData = rttData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) + + val layers = + remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) } + + if (layers.isNotEmpty()) { + GenericMetricChart( + modelProducer = modelProducer, + modifier = chartModifier, + layers = layers, + startAxis = + if (forwardData.isNotEmpty() || returnData.isNotEmpty()) { + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = forwardColor), + valueFormatter = { _, value, _ -> formatString("%.0f", value) }, + ) + } else { + null + }, + endAxis = + if (rttData.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = rttColor), + valueFormatter = { _, value, _ -> formatString("%.1f s", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 4d00c684a..d4d8c0d17 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -14,65 +14,86 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +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.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +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.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.routing_error_no_response -import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.traceroute_diff import org.meshtastic.core.resources.traceroute_direct import org.meshtastic.core.resources.traceroute_duration +import org.meshtastic.core.resources.traceroute_forward_hops import org.meshtastic.core.resources.traceroute_hops import org.meshtastic.core.resources.traceroute_log +import org.meshtastic.core.resources.traceroute_no_response +import org.meshtastic.core.resources.traceroute_return_hops +import org.meshtastic.core.resources.traceroute_round_trip import org.meshtastic.core.resources.traceroute_route_back_to_us import org.meshtastic.core.resources.traceroute_route_towards_dest -import org.meshtastic.core.resources.traceroute_time_and_text -import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.Group import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PersonOff import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Route +import org.meshtastic.core.ui.theme.GraphColors import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.core.ui.util.annotateTraceroute -import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.proto.RouteDiscovery +/** + * Full-screen traceroute log with chart and card list, built on [BaseMetricScreen]. Shows forward/return hops and + * round-trip duration over time. Supports time-frame filtering, chart expand/collapse, and card-to-chart + * synchronisation. + */ @OptIn(ExperimentalFoundationApi::class) -@Suppress("LongMethod", "CyclomaticComplexMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod", "UnusedParameter") @Composable fun TracerouteLogScreen( modifier: Modifier = Modifier, @@ -81,6 +102,9 @@ fun TracerouteLogScreen( onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> }, ) { val state by viewModel.state.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle() fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" } @@ -88,155 +112,273 @@ fun TracerouteLogScreen( val statusYellow = MaterialTheme.colorScheme.StatusYellow val statusOrange = MaterialTheme.colorScheme.StatusOrange - Scaffold( - topBar = { - val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle() - MainAppBar( - title = state.node?.user?.long_name ?: "", - subtitle = stringResource(Res.string.traceroute_log), - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - CooldownIconButton( - onClick = { viewModel.requestTraceroute() }, - cooldownTimestamp = lastTracerouteTime, - ) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, + val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest) + val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us) + val durationFormatStr = stringResource(Res.string.traceroute_duration) + + val threshold = timeFrame.timeThreshold() + val filteredRequests = + remember(state.tracerouteRequests, threshold) { + state.tracerouteRequests.filter { (it.received_date / MS_PER_SEC) >= threshold } + } + + val points = + remember(filteredRequests, state.tracerouteResults) { + resolveTraceroutePoints(filteredRequests, state.tracerouteResults) + } + + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = null, + titleRes = Res.string.traceroute_log, + nodeName = state.node?.user?.long_name ?: "", + data = points, + timeProvider = { it.timeSeconds }, + infoData = TRACEROUTE_INFO_DATA, + extraActions = { + if (!state.isLocal) { + CooldownIconButton( + onClick = { viewModel.requestTraceroute() }, + cooldownTimestamp = lastTracerouteTime, + ) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } + }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - ) { innerPadding -> - LazyColumn( - modifier = modifier.fillMaxSize().padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - items(state.tracerouteRequests, key = { it.uuid }) { log -> - val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest) - val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us) - val result = - remember(state.tracerouteRequests, log.fromRadio.packet?.id) { - state.tracerouteResults.find { - it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id - } - } - val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery } - - val time = DateFormatter.formatDateTime(log.received_date) - val (text, icon) = route.getTextAndIcon() - var expanded by remember { mutableStateOf(false) } - - val tracerouteDetailsAnnotated: AnnotatedString? = result?.let { res -> - if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) { - val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC - val annotatedBase = - annotateTraceroute( - res.fromRadio.packet?.getTracerouteResponse( - ::getUsername, - headerTowards = stringResource(Res.string.traceroute_route_towards_dest), - headerBack = headerBackStr, - ), + chartPart = { chartModifier, selectedX, vicoScrollState, onPointSelected -> + TracerouteMetricsChart( + modifier = chartModifier, + points = points.reversed(), + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, + ) + }, + listPart = { listModifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = listModifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(points, key = { _, point -> point.request.uuid }) { _, point -> + TracerouteCard( + point = point, + isSelected = point.timeSeconds == selectedX, + onClick = { onCardClick(point.timeSeconds) }, + onLongClick = { viewModel.deleteLog(point.request.uuid) }, + onShowDetail = { + showTracerouteDetail( + point = point, + viewModel = viewModel, + getUsername = ::getUsername, + headerTowards = headerTowardsStr, + headerBack = headerBackStr, + durationTemplate = durationFormatStr, statusGreen = statusGreen, statusYellow = statusYellow, statusOrange = statusOrange, + onViewOnMap = onViewOnMap, ) - val durationText = stringResource(Res.string.traceroute_duration, formatString("%.1f", seconds)) - buildAnnotatedString { - append(annotatedBase) - append("\n\n$durationText") - } - } else { - // For cases where there's a result but no full route, display plain text - res.fromRadio.packet - ?.getTracerouteResponse( - ::getUsername, - headerTowards = stringResource(Res.string.traceroute_route_towards_dest), - headerBack = headerBackStr, - ) - ?.let { AnnotatedString(it) } - } - } - val overlay = route?.let { - TracerouteOverlay( - requestId = log.fromRadio.packet?.id ?: 0, - forwardRoute = it.route, - returnRoute = it.route_back, - ) - } - - Box { - MetricLogItem( - icon = icon, - text = stringResource(Res.string.traceroute_time_and_text, time, text), - contentDescription = stringResource(Res.string.traceroute), - modifier = - Modifier.combinedClickable(onLongClick = { expanded = true }) { - val dialogMessage = - tracerouteDetailsAnnotated - ?: result - ?.fromRadio - ?.packet - ?.getTracerouteResponse( - ::getUsername, - headerTowards = headerTowardsStr, - headerBack = headerBackStr, - ) - ?.let { - annotateTraceroute( - it, - statusGreen = statusGreen, - statusYellow = statusYellow, - statusOrange = statusOrange, - ) - } - dialogMessage?.let { - val responseLogUuid = result?.uuid ?: return@combinedClickable - viewModel.showTracerouteDetail( - annotatedMessage = it, - requestId = log.fromRadio.packet?.id ?: 0, - responseLogUuid = responseLogUuid, - overlay = overlay, - onViewOnMap = onViewOnMap, - onShowError = { /* Handle error */ }, - ) - } }, ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DeleteItem { - viewModel.deleteLog(log.uuid) - expanded = false - } - } } } + }, + ) +} + +/** A selectable card summarising a single traceroute request/response pair. */ +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun TracerouteCard( + point: TraceroutePoint, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onShowDetail: () -> Unit, +) { + val route = point.result?.fromRadio?.packet?.fullRouteDiscovery + val time = DateFormatter.formatDateTime(point.request.received_date) + val (summaryText, icon) = route.getTextAndIcon() + var expanded by remember { mutableStateOf(false) } + + Box { + Card( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .combinedClickable( + onLongClick = { expanded = true }, + onClick = { + onClick() + onShowDetail() + }, + ), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + TracerouteCardContent(time = time, summaryText = summaryText, icon = icon, point = point) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DeleteItem { + onLongClick() + expanded = false + } } } } +/** Card body showing timestamp, route summary text/icon, and metric indicators. */ +@Composable +private fun TracerouteCardContent(time: String, summaryText: String, icon: ImageVector, point: TraceroutePoint) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = time, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold) + Icon(imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = summaryText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TracerouteCardMetrics(point) + } +} + +/** Compact coloured metric indicators (forward hops / return hops / RTT) shown at the bottom of a card. */ +@Composable +private fun TracerouteCardMetrics(point: TraceroutePoint) { + if (point.forwardHops == null && point.returnHops == null && point.roundTripSeconds == null) return + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + point.forwardHops?.let { hops -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Blue) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %d", stringResource(Res.string.traceroute_forward_hops), hops), + style = MaterialTheme.typography.labelLarge, + ) + } + } + point.returnHops?.let { hops -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Green) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %d", stringResource(Res.string.traceroute_return_hops), hops), + style = MaterialTheme.typography.labelLarge, + ) + } + } + point.roundTripSeconds?.let { rtt -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Orange) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %.1f s", stringResource(Res.string.traceroute_round_trip), rtt), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } +} + +/** Builds annotated route text and opens the traceroute detail dialog via the ViewModel. */ +@Suppress("LongParameterList") +private fun showTracerouteDetail( + point: TraceroutePoint, + viewModel: MetricsViewModel, + getUsername: (Int) -> String, + headerTowards: String, + headerBack: String, + durationTemplate: String, + statusGreen: Color, + statusYellow: Color, + statusOrange: Color, + onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit, +) { + val result = point.result ?: return + val route = result.fromRadio.packet?.fullRouteDiscovery + + val annotated: AnnotatedString = + if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) { + val seconds = point.roundTripSeconds ?: 0.0 + val annotatedBase = + annotateTraceroute( + result.fromRadio.packet?.getTracerouteResponse( + getUsername, + headerTowards = headerTowards, + headerBack = headerBack, + ), + statusGreen = statusGreen, + statusYellow = statusYellow, + statusOrange = statusOrange, + ) + val durationText = formatString(durationTemplate, NumberFormatter.format(seconds, 1)) + buildAnnotatedString { + append(annotatedBase) + append("\n\n$durationText") + } + } else { + result.fromRadio.packet + ?.getTracerouteResponse(getUsername, headerTowards = headerTowards, headerBack = headerBack) + ?.let { AnnotatedString(it) } ?: return + } + + val overlay = + route?.let { + TracerouteOverlay( + requestId = point.request.fromRadio.packet?.id ?: 0, + forwardRoute = it.route, + returnRoute = it.route_back, + ) + } + + viewModel.showTracerouteDetail( + annotatedMessage = annotated, + requestId = point.request.fromRadio.packet?.id ?: 0, + responseLogUuid = result.uuid, + overlay = overlay, + onViewOnMap = onViewOnMap, + ) +} + /** Generates a display string and icon based on the route discovery information. */ @Composable private fun RouteDiscovery?.getTextAndIcon(): Pair = when { this == null -> { - stringResource(Res.string.routing_error_no_response) to MeshtasticIcons.PersonOff + stringResource(Res.string.traceroute_no_response) to MeshtasticIcons.PersonOff } - // A direct route means the sender and receiver are the only two nodes in the route. - route.size <= 2 && route_back.size <= 2 -> { // also check route_back size for direct to be more robust + route.size <= 2 && route_back.size <= 2 -> { stringResource(Res.string.traceroute_direct) to MeshtasticIcons.Group } - route.size == route_back.size -> { val hops = route.size - 2 pluralStringResource(Res.plurals.traceroute_hops, hops, hops) to MeshtasticIcons.Route } - else -> { - // Asymmetric route val towards = maxOf(0, route.size - 2) val back = maxOf(0, route_back.size - 2) stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt index 930a7b826..fdc01bce6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -16,43 +16,38 @@ */ package org.meshtastic.feature.node.model -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ChargingStation -import androidx.compose.material.icons.rounded.Groups -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.Map -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.Power -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.Thermostat -import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource -import org.meshtastic.core.navigation.NodeDetailRoutes +import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.device_metrics_log import org.meshtastic.core.resources.env_metrics_log import org.meshtastic.core.resources.host_metrics_log +import org.meshtastic.core.resources.ic_charging_station +import org.meshtastic.core.resources.ic_group +import org.meshtastic.core.resources.ic_groups +import org.meshtastic.core.resources.ic_location_on +import org.meshtastic.core.resources.ic_memory +import org.meshtastic.core.resources.ic_power +import org.meshtastic.core.resources.ic_route +import org.meshtastic.core.resources.ic_signal_cellular_alt +import org.meshtastic.core.resources.ic_thermostat import org.meshtastic.core.resources.neighbor_info -import org.meshtastic.core.resources.node_map import org.meshtastic.core.resources.pax_metrics_log import org.meshtastic.core.resources.position_log import org.meshtastic.core.resources.power_metrics_log import org.meshtastic.core.resources.signal_quality import org.meshtastic.core.resources.traceroute_log -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Paxcount -import org.meshtastic.core.ui.icon.Route -enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val routeFactory: (Int) -> Route) { - DEVICE(Res.string.device_metrics_log, Icons.Rounded.ChargingStation, { NodeDetailRoutes.DeviceMetrics(it) }), - NODE_MAP(Res.string.node_map, Icons.Rounded.Map, { NodeDetailRoutes.NodeMap(it) }), - POSITIONS(Res.string.position_log, Icons.Rounded.LocationOn, { NodeDetailRoutes.PositionLog(it) }), - ENVIRONMENT(Res.string.env_metrics_log, Icons.Rounded.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }), - SIGNAL(Res.string.signal_quality, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }), - POWER(Res.string.power_metrics_log, Icons.Rounded.Power, { NodeDetailRoutes.PowerMetrics(it) }), - TRACEROUTE(Res.string.traceroute_log, MeshtasticIcons.Route, { NodeDetailRoutes.TracerouteLog(it) }), - NEIGHBOR_INFO(Res.string.neighbor_info, Icons.Rounded.Groups, { NodeDetailRoutes.NeighborInfoLog(it) }), - HOST(Res.string.host_metrics_log, Icons.Rounded.Memory, { NodeDetailRoutes.HostMetricsLog(it) }), - PAX(Res.string.pax_metrics_log, MeshtasticIcons.Paxcount, { NodeDetailRoutes.PaxMetrics(it) }), +enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, val routeFactory: (Int) -> Route) { + DEVICE(Res.string.device_metrics_log, Res.drawable.ic_charging_station, { NodeDetailRoute.DeviceMetrics(it) }), + POSITIONS(Res.string.position_log, Res.drawable.ic_location_on, { NodeDetailRoute.PositionLog(it) }), + ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }), + SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }), + POWER(Res.string.power_metrics_log, Res.drawable.ic_power, { NodeDetailRoute.PowerMetrics(it) }), + TRACEROUTE(Res.string.traceroute_log, Res.drawable.ic_route, { NodeDetailRoute.TracerouteLog(it) }), + NEIGHBOR_INFO(Res.string.neighbor_info, Res.drawable.ic_groups, { NodeDetailRoute.NeighborInfoLog(it) }), + HOST(Res.string.host_metrics_log, Res.drawable.ic_memory, { NodeDetailRoute.HostMetricsLog(it) }), + PAX(Res.string.pax_metrics_log, Res.drawable.ic_group, { NodeDetailRoute.PaxMetrics(it) }), } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index ce8bd665e..dc72fac5e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -21,8 +21,8 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.ChannelsRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.ChannelsRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.feature.node.list.NodeListScreen import org.meshtastic.feature.node.list.NodeListViewModel @@ -31,14 +31,14 @@ import org.meshtastic.feature.node.list.NodeListViewModel fun AdaptiveNodeListScreen( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() NodeListScreen( viewModel = nodeListViewModel, - navigateToNodeDetails = { nodeId -> backStack.add(NodesRoutes.NodeDetail(nodeId)) }, - onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + navigateToNodeDetails = { nodeId -> backStack.add(NodesRoute.NodeDetail(nodeId)) }, + onNavigateToChannels = { backStack.add(ChannelsRoute.ChannelsGraph) }, scrollToTopEvents = scrollToTopEvents, activeNodeId = null, onHandleDeepLink = onHandleDeepLink, 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 276e2892e..233942f00 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -16,36 +16,36 @@ */ package org.meshtastic.feature.node.navigation -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CellTower -import androidx.compose.material.icons.rounded.Groups -import androidx.compose.material.icons.rounded.LightMode -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.People -import androidx.compose.material.icons.rounded.PermScanWifi -import androidx.compose.material.icons.rounded.Power -import androidx.compose.material.icons.rounded.Router import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.core.navigation.NodeDetailRoutes -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.device import org.meshtastic.core.resources.environment import org.meshtastic.core.resources.host +import org.meshtastic.core.resources.ic_cell_tower +import org.meshtastic.core.resources.ic_group +import org.meshtastic.core.resources.ic_groups +import org.meshtastic.core.resources.ic_light_mode +import org.meshtastic.core.resources.ic_location_on +import org.meshtastic.core.resources.ic_memory +import org.meshtastic.core.resources.ic_perm_scan_wifi +import org.meshtastic.core.resources.ic_power +import org.meshtastic.core.resources.ic_router import org.meshtastic.core.resources.neighbor_info import org.meshtastic.core.resources.pax import org.meshtastic.core.resources.position_log @@ -73,9 +73,9 @@ import kotlin.reflect.KClass fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, @@ -83,7 +83,7 @@ fun EntryProviderScope.nodesGraph( ) } - entry(metadata = { ListDetailSceneStrategy.listPane() }) { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, @@ -99,9 +99,9 @@ fun EntryProviderScope.nodesGraph( fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, @@ -109,7 +109,7 @@ fun EntryProviderScope.nodeDetailGraph( ) } - entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> val nodeDetailViewModel: NodeDetailViewModel = koinViewModel() val compassViewModel: CompassViewModel = koinViewModel() val destNum = args.destNum ?: 0 // Handle nullable destNum if needed @@ -117,27 +117,22 @@ fun EntryProviderScope.nodeDetailGraph( nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, - navigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, - onNavigate = { backStack.add(it) }, - onNavigateUp = { backStack.removeLastOrNull() }, + navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, + onNavigate = { route -> backStack.add(route) }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> - val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current - mapScreen(args.destNum) { backStack.removeLastOrNull() } - } - - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val metricsViewModel = koinViewModel { parametersOf(args.destNum) } metricsViewModel.setNodeId(args.destNum) TracerouteLogScreen( viewModel = metricsViewModel, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, onViewOnMap = { requestId, responseLogUuid -> backStack.add( - NodeDetailRoutes.TracerouteMap( + NodeDetailRoute.TracerouteMap( destNum = args.destNum, requestId = requestId, logUuid = responseLogUuid, @@ -147,40 +142,40 @@ fun EntryProviderScope.nodeDetailGraph( ) } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } } - NodeDetailRoute.entries.forEach { routeInfo -> + NodeDetailScreen.entries.forEach { routeInfo -> when (routeInfo.routeClass) { - NodeDetailRoutes.DeviceMetrics::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.PositionLog::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.EnvironmentMetrics::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.SignalMetrics::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.PowerMetrics::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.HostMetricsLog::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.PaxMetrics::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoutes.NeighborInfoLog::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.DeviceMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.PositionLog::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.EnvironmentMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.SignalMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.PowerMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.HostMetricsLog::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.PaxMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.NeighborInfoLog::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } else -> Unit } } } -fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass } +fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailScreen.entries.any { this::class == it.routeClass } @OptIn(ExperimentalMaterial3AdaptiveApi::class) private inline fun EntryProviderScope.addNodeDetailScreenComposable( backStack: NavBackStack, - routeInfo: NodeDetailRoute, + routeInfo: NodeDetailScreen, crossinline getDestNum: (R) -> Int, ) { entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> @@ -188,69 +183,69 @@ private inline fun EntryProviderScope.addNodeDetailS val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } + routeInfo.screenComposable(metricsViewModel, dropUnlessResumed { backStack.removeLastOrNull() }) } } /** Expect declaration for the platform-specific traceroute map screen. */ -enum class NodeDetailRoute( +enum class NodeDetailScreen( val title: StringResource, val routeClass: KClass, - val icon: ImageVector?, + val icon: DrawableResource? = null, val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit, ) { DEVICE( Res.string.device, - NodeDetailRoutes.DeviceMetrics::class, - Icons.Rounded.Router, + NodeDetailRoute.DeviceMetrics::class, + Res.drawable.ic_router, { metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) }, ), POSITION_LOG( Res.string.position_log, - NodeDetailRoutes.PositionLog::class, - Icons.Rounded.LocationOn, + NodeDetailRoute.PositionLog::class, + Res.drawable.ic_location_on, { metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) }, ), ENVIRONMENT( Res.string.environment, - NodeDetailRoutes.EnvironmentMetrics::class, - Icons.Rounded.LightMode, + NodeDetailRoute.EnvironmentMetrics::class, + Res.drawable.ic_light_mode, { metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) }, ), SIGNAL( Res.string.signal, - NodeDetailRoutes.SignalMetrics::class, - Icons.Rounded.CellTower, + NodeDetailRoute.SignalMetrics::class, + Res.drawable.ic_cell_tower, { metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) }, ), TRACEROUTE( Res.string.traceroute, - NodeDetailRoutes.TracerouteLog::class, - Icons.Rounded.PermScanWifi, + NodeDetailRoute.TracerouteLog::class, + Res.drawable.ic_perm_scan_wifi, { metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) }, ), NEIGHBOR_INFO( Res.string.neighbor_info, - NodeDetailRoutes.NeighborInfoLog::class, - Icons.Rounded.Groups, + NodeDetailRoute.NeighborInfoLog::class, + Res.drawable.ic_groups, { metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) }, ), POWER( Res.string.power, - NodeDetailRoutes.PowerMetrics::class, - Icons.Rounded.Power, + NodeDetailRoute.PowerMetrics::class, + Res.drawable.ic_power, { metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) }, ), HOST( Res.string.host, - NodeDetailRoutes.HostMetricsLog::class, - Icons.Rounded.Memory, + NodeDetailRoute.HostMetricsLog::class, + Res.drawable.ic_memory, { metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) }, ), PAX( Res.string.pax, - NodeDetailRoutes.PaxMetrics::class, - Icons.Rounded.People, + NodeDetailRoute.PaxMetrics::class, + Res.drawable.ic_group, { metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) }, ), } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt new file mode 100644 index 000000000..6bca8822b --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.feature.node.component.NodeMenuAction +import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase +import org.meshtastic.feature.node.model.NodeDetailAction +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse + +@OptIn(ExperimentalCoroutinesApi::class) +class HandleNodeActionTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val nodeManagementActions: NodeManagementActions = mock() + private val nodeRequestActions: NodeRequestActions = mock() + private val serviceRepository: ServiceRepository = mock() + private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { getNodeDetailsUseCase(any()) } returns emptyFlow() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) { + val node = Node(num = 1234, user = User(id = "!1234")) + every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit + val viewModel = createViewModel() + var navigateUpCalled = false + + handleNodeAction( + action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)), + uiState = NodeDetailUiState(), + navigateToMessages = {}, + onNavigateUp = { navigateUpCalled = true }, + onNavigate = {}, + viewModel = viewModel, + ) + + verify { nodeManagementActions.requestRemoveNode(any(), node, any()) } + assertFalse(navigateUpCalled) + } + + private fun createViewModel() = NodeDetailViewModel( + savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)), + nodeManagementActions = nodeManagementActions, + nodeRequestActions = nodeRequestActions, + serviceRepository = serviceRepository, + getNodeDetailsUseCase = getNodeDetailsUseCase, + ) +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 89015c807..3212a313e 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User import kotlin.test.Test +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NodeManagementActionsTest { @@ -69,4 +70,23 @@ class NodeManagementActionsTest { ) } } + + @Test + fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() { + val realAlertManager = AlertManager() + val actionsWithRealAlert = + NodeManagementActions( + nodeRepository = nodeRepository, + serviceRepository = serviceRepository, + radioController = radioController, + alertManager = realAlertManager, + ) + val node = Node(num = 123, user = User(long_name = "Test Node")) + var afterRemoveCalled = false + + actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true } + realAlertManager.currentAlert.value?.onConfirm?.invoke() + + assertTrue(afterRemoveCalled) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 602134aa0..9511a2da1 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeRadioInterfaceService import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -45,6 +46,7 @@ class NodeListViewModelTest { private lateinit var viewModel: NodeListViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController + private lateinit var radioInterfaceService: FakeRadioInterfaceService private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) @@ -55,6 +57,7 @@ class NodeListViewModelTest { fun setUp() { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() + radioInterfaceService = FakeRadioInterfaceService() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) @@ -79,6 +82,7 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, + radioInterfaceService = radioInterfaceService, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt new file mode 100644 index 000000000..98f7d3bbe --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt @@ -0,0 +1,185 @@ +/* + * 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.metrics + +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.MeshLog +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.meshtastic.proto.Paxcount as ProtoPaxcount + +/** + * Tests for `MetricsViewModel.decodePaxFromLog()`. + * + * Uses a minimal testable subclass to access the protected function without wiring the full ViewModel dependency graph. + */ +class DecodePaxFromLogTest { + + /** + * Minimal subclass that exposes `decodePaxFromLog` without requiring all ViewModel dependencies. `MetricsViewModel` + * is open, so we override with no-op constructor arguments are not needed — we only call the self-contained + * `decodePaxFromLog` method. + */ + private val decoder = + object { + /** Delegates to MetricsViewModel logic extracted into a standalone helper for testing. */ + fun decode(log: MeshLog): ProtoPaxcount? = decodePaxFromLogStandalone(log) + } + + // ---- Binary proto path ---- + + @Test + fun binaryProto_validPaxcount_decoded() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 3600) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false) + + val result = decoder.decode(log) + assertNotNull(result) + assertEquals(10, result.wifi) + assertEquals(5, result.ble) + assertEquals(3600, result.uptime) + } + + @Test + fun binaryProto_wantResponse_returnsNull() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = true) + + assertNull(decoder.decode(log)) + } + + @Test + fun binaryProto_allZeroValues_returnsNull() { + val pax = ProtoPaxcount(wifi = 0, ble = 0, uptime = 0) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false) + + assertNull(decoder.decode(log)) + } + + @Test + fun binaryProto_wrongPortNum_returnsNull() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false, portNum = PortNum.POSITION_APP) + + assertNull(decoder.decode(log)) + } + + // ---- Base64 fallback path ---- + + @Test + fun base64Fallback_validPayload_decoded() { + val pax = ProtoPaxcount(wifi = 7, ble = 3, uptime = 500) + val bytes = ProtoPaxcount.ADAPTER.encode(pax) + val base64 = okio.ByteString.of(*bytes).base64() + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = base64) + + val result = decoder.decode(log) + assertNotNull(result) + assertEquals(7, result.wifi) + assertEquals(3, result.ble) + } + + // ---- Hex fallback path ---- + // Note: The hex path (`else if`) in the original code is unreachable for pure hex strings + // because hex chars [0-9a-fA-F] are a strict subset of base64 chars [A-Za-z0-9+/=]. + // The base64 `if` branch always matches first. The hex fallback would only trigger for + // strings that fail the base64 regex but pass the hex regex — which is impossible given + // the charsets. This is documented here as a known design characteristic of decodePaxFromLog(). + + // ---- Error handling ---- + + @Test + fun invalidRawMessage_returnsNull() { + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "not-valid-anything!@#") + + assertNull(decoder.decode(log)) + } + + @Test + fun emptyLog_returnsNull() { + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "") + + assertNull(decoder.decode(log)) + } + + // ---- Helpers ---- + + private fun meshLogWithPacket( + payload: ByteArray, + wantResponse: Boolean, + portNum: PortNum = PortNum.PAXCOUNTER_APP, + ): MeshLog { + val data = Data(portnum = portNum, payload = payload.toByteString(), want_response = wantResponse) + val packet = MeshPacket(decoded = data) + val fromRadio = FromRadio(packet = packet) + return MeshLog( + uuid = "test", + message_type = "packet", + received_date = nowSeconds * 1000, + raw_message = "", + fromRadio = fromRadio, + ) + } +} + +/** + * Standalone reimplementation of `MetricsViewModel.decodePaxFromLog()` for testing. + * + * This avoids needing to instantiate the full ViewModel with all its dependencies. The logic is identical to the + * ViewModel method. + */ +@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") +private fun decodePaxFromLogStandalone(log: MeshLog): ProtoPaxcount? { + try { + val packet = log.fromRadio.packet + val decoded = packet?.decoded + if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { + if (decoded.want_response == true) return null + val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) + if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax + } + } catch (e: Exception) { + // Swallow, fall through to alternative parsing + } + try { + val base64 = log.raw_message.trim() + if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) { + val bytes = base64.okioDecodeBase64() + return ProtoPaxcount.ADAPTER.decode(bytes) + } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { + val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return ProtoPaxcount.ADAPTER.decode(bytes) + } + } catch (e: Exception) { + // Swallow + } + return null +} + +private fun String.okioDecodeBase64(): ByteArray = this.decodeBase64()?.toByteArray() ?: ByteArray(0) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt new file mode 100644 index 000000000..10cdb42d5 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt @@ -0,0 +1,275 @@ +/* + * 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.metrics + +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Telemetry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("MagicNumber") +class EnvironmentMetricsForGraphingTest { + + private val now = nowSeconds.toInt() + + private fun telemetry(time: Int = now, env: EnvironmentMetrics) = Telemetry(time = time, environment_metrics = env) + + // ---- Empty input ---- + + @Test + fun emptyMetrics_returnsDefaultGraphingData() { + val state = EnvironmentMetricsState(emptyList()) + val result = state.environmentMetricsForGraphing() + + assertTrue(result.metrics.isEmpty()) + assertTrue(result.shouldPlot.none { it }) + } + + // ---- Fahrenheit conversion ---- + + @Test + fun useFahrenheit_convertsTemperatureMinMax() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(temperature = 0f)), + telemetry(env = EnvironmentMetrics(temperature = 100f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + // 0C = 32F, 100C = 212F + assertEquals(32f, result.rightMinMax.first, 0.01f) + assertEquals(212f, result.rightMinMax.second, 0.01f) + } + + @Test + fun useFahrenheit_convertsSoilTemperature() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(soil_temperature = 20f)), + telemetry(env = EnvironmentMetrics(soil_temperature = 30f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertTrue(result.shouldPlot[Environment.SOIL_TEMPERATURE.ordinal]) + // 20C = 68F, 30C = 86F + assertEquals(68f, result.rightMinMax.first, 0.01f) + assertEquals(86f, result.rightMinMax.second, 0.01f) + } + + // ---- Humidity filtering ---- + + @Test + fun humidity_zeroFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = 0.0f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal]) + } + + @Test + fun humidity_nonZeroIncluded() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(relative_humidity = 45f)), + telemetry(env = EnvironmentMetrics(relative_humidity = 65f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal]) + assertEquals(45f, result.rightMinMax.first, 0.01f) + assertEquals(65f, result.rightMinMax.second, 0.01f) + } + + // ---- IAQ sentinel filtering ---- + + @Test + fun iaq_intMinValueFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(iaq = Int.MIN_VALUE))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.IAQ.ordinal]) + } + + @Test + fun iaq_validValueIncluded() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(iaq = 50)), telemetry(env = EnvironmentMetrics(iaq = 150))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.IAQ.ordinal]) + assertEquals(50f, result.rightMinMax.first, 0.01f) + assertEquals(150f, result.rightMinMax.second, 0.01f) + } + + // ---- Soil moisture sentinel filtering ---- + + @Test + fun soilMoisture_intMinValueFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(soil_moisture = Int.MIN_VALUE))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal]) + } + + @Test + fun soilMoisture_validValueIncluded() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(soil_moisture = 30)), + telemetry(env = EnvironmentMetrics(soil_moisture = 70)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal]) + } + + // ---- Barometric pressure (left axis) ---- + + @Test + fun barometricPressure_onLeftAxis() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)), + telemetry(env = EnvironmentMetrics(barometric_pressure = 1020.50f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) + assertEquals(1013.25f, result.leftMinMax.first, 0.01f) + assertEquals(1020.50f, result.leftMinMax.second, 0.01f) + } + + @Test + fun barometricPressure_doesNotAffectRightAxis() { + // Only pressure, no other metrics + val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + // rightMinMax should be 0/1 defaults since no right-axis metrics + assertEquals(0f, result.rightMinMax.first, 0.01f) + assertEquals(1f, result.rightMinMax.second, 0.01f) + } + + // ---- Lux, UV lux, wind speed, radiation ---- + + @Test + fun lux_plotted() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(lux = 500f)), telemetry(env = EnvironmentMetrics(lux = 1200f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.LUX.ordinal]) + assertEquals(500f, result.rightMinMax.first, 0.01f) + assertEquals(1200f, result.rightMinMax.second, 0.01f) + } + + @Test + fun uvLux_plotted() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(uv_lux = 2f)), telemetry(env = EnvironmentMetrics(uv_lux = 8f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.UV_LUX.ordinal]) + } + + @Test + fun windSpeed_plotted() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(wind_speed = 5f)), + telemetry(env = EnvironmentMetrics(wind_speed = 25f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.WIND_SPEED.ordinal]) + } + + @Test + fun radiation_positiveValuesOnly() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(radiation = 0f)), + telemetry(env = EnvironmentMetrics(radiation = 0.15f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.RADIATION.ordinal]) + // 0f is filtered out (radiation > 0f only), so min should be 0.15 + assertEquals(0.15f, result.rightMinMax.first, 0.01f) + assertEquals(0.15f, result.rightMinMax.second, 0.01f) + } + + // ---- NaN filtering ---- + + @Test + fun nanTemperature_filteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(temperature = Float.NaN))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + } + + @Test + fun nanPressure_filteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) + assertEquals(0f, result.leftMinMax.first, 0.01f) + assertEquals(0f, result.leftMinMax.second, 0.01f) + } + + // ---- Multiple metrics combined ---- + + @Test + fun multipleMetrics_rightAxisMinMaxSpansAll() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(temperature = 10f, relative_humidity = 80f)), + telemetry(env = EnvironmentMetrics(temperature = 30f, relative_humidity = 40f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal]) + // right min/max should span both: min(10, 40) = 10, max(30, 80) = 80 + assertEquals(10f, result.rightMinMax.first, 0.01f) + assertEquals(80f, result.rightMinMax.second, 0.01f) + } + + // ---- Gas resistance ---- + + // ---- Gas resistance (not currently graphed by environmentMetricsForGraphing) ---- + + @Test + fun gasResistance_notPlottedByGraphingFunction() { + // Note: GAS_RESISTANCE is defined in the Environment enum but environmentMetricsForGraphing() + // does not have explicit handling for it. This test documents that current behavior. + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(gas_resistance = 100f)), + telemetry(env = EnvironmentMetrics(gas_resistance = 500f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.GAS_RESISTANCE.ordinal]) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt new file mode 100644 index 000000000..aaa0d8631 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt @@ -0,0 +1,94 @@ +/* + * 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.metrics + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Tests for [formatBytes] — the pure function that formats byte counts into human-readable strings. */ +@Suppress("MagicNumber") +class FormatBytesTest { + + @Test + fun zero_bytes() { + assertEquals("0 B", formatBytes(0L)) + } + + @Test + fun small_byte_values() { + assertEquals("1 B", formatBytes(1L)) + assertEquals("512 B", formatBytes(512L)) + assertEquals("1023 B", formatBytes(1023L)) + } + + @Test + fun kilobyte_boundary() { + assertEquals("1 KB", formatBytes(1024L)) + } + + @Test + fun kilobyte_with_decimals() { + // 1536 bytes = 1.5 KB + assertEquals("1.5 KB", formatBytes(1536L)) + } + + @Test + fun megabyte_boundary() { + assertEquals("1 MB", formatBytes(1024L * 1024)) + } + + @Test + fun megabyte_with_decimals() { + // 1.5 MB = 1572864 bytes + assertEquals("1.5 MB", formatBytes(1_572_864L)) + } + + @Test + fun gigabyte_boundary() { + assertEquals("1 GB", formatBytes(1024L * 1024 * 1024)) + } + + @Test + fun gigabyte_with_decimals() { + // 2.5 GB + assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong())) + } + + @Test + fun negative_bytes_returns_na() { + assertEquals("N/A", formatBytes(-1L)) + assertEquals("N/A", formatBytes(-1024L)) + } + + @Test + fun large_values() { + // 100 GB + assertEquals("100 GB", formatBytes(100L * 1024 * 1024 * 1024)) + } + + @Test + fun custom_decimal_places_zero() { + // 1536 bytes = 1.5 KB, with 0 decimal places → 2 KB (rounded) + assertEquals("2 KB", formatBytes(1536L, decimalPlaces = 0)) + } + + @Test + fun custom_decimal_places_one() { + // 1536 bytes = 1.5 KB, with 1 decimal place → 1.5 KB + assertEquals("1.5 KB", formatBytes(1536L, decimalPlaces = 1)) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt new file mode 100644 index 000000000..d45840970 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt @@ -0,0 +1,47 @@ +/* + * 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.metrics + +import org.meshtastic.proto.HardwareModel +import kotlin.test.Test +import kotlin.test.assertEquals + +class HardwareModelSafeNumberTest { + + @Test + fun knownModel_returnsValue() { + assertEquals(HardwareModel.TBEAM.value, HardwareModel.TBEAM.safeNumber()) + } + + @Test + fun unset_returnsZero() { + assertEquals(0, HardwareModel.UNSET.safeNumber()) + } + + @Test + fun customFallback_used() { + // Known model with custom fallback — should still return real value + assertEquals(HardwareModel.HELTEC_V3.value, HardwareModel.HELTEC_V3.safeNumber(fallbackValue = 999)) + } + + @Test + fun defaultFallback_isNegativeOne() { + // For known models the fallback is never used, but verify the API default + val result = HardwareModel.UNSET.safeNumber() + assertEquals(0, result) // UNSET.value is 0, not the fallback + } +} 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 34e411af0..956c20175 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import okio.Buffer import okio.BufferedSink -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository @@ -210,8 +210,8 @@ class MetricsViewModelTest { awaitItem() // Empty awaitItem() // with position - val uri = MeshtasticUri("content://test") - vm.savePositionCSV(uri) + val uri = CommonUri.parse("content://test") + vm.savePositionCSV(uri, listOf(testPosition)) 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 new file mode 100644 index 000000000..a80b2172e --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt @@ -0,0 +1,278 @@ +/* + * 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.metrics + +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.RouteDiscovery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Tests for [resolveTraceroutePoints] — the pure function that pairs traceroute requests with their responses and + * computes hop counts and round-trip duration. + * + * Wire format note: The [RouteDiscovery] proto on the wire contains only **intermediate** hops (not endpoints). + * [MeshPacket.fullRouteDiscovery] prepends the destination and appends the source to produce the full route. For + * `route_back` to be wrapped with endpoints, `hop_start > 0` and `snr_back` must be non-empty. + */ +@Suppress("MagicNumber") +class TracerouteChartTest { + + companion object { + /** Node number for the local (requesting) node. */ + private const val LOCAL_NODE = 1 + + /** Node number for the remote (destination) node. */ + private const val REMOTE_NODE = 2 + + /** Dummy SNR value used to satisfy the snr_back requirement. */ + private const val DUMMY_SNR = 10 + } + + /** + * Creates a traceroute **request** MeshLog. + * + * @param id Packet ID used to correlate request with response. + * @param receivedDateMillis Timestamp in milliseconds. + */ + private fun makeRequest(id: Int, receivedDateMillis: Long): MeshLog = MeshLog( + uuid = "req-$id", + message_type = "TRACEROUTE", + received_date = receivedDateMillis, + raw_message = "", + fromRadio = + FromRadio( + packet = + MeshPacket( + id = id, + from = LOCAL_NODE, + to = REMOTE_NODE, + decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true), + ), + ), + ) + + /** + * Creates a traceroute **result** MeshLog that matches a request by [requestId]. + * + * @param intermediateRoute Intermediate hops on the forward path (wire format, no endpoints). + * @param intermediateRouteBack Intermediate hops on the return path (wire format, no endpoints). Pass `null` to + * omit route_back entirely (simulates no return route data). + * @param hopStart Non-zero hop_start is required (along with snr_back) for fullRouteDiscovery to wrap route_back + * with endpoints. Defaults to 3. + */ + private fun makeResult( + requestId: Int, + receivedDateMillis: Long, + intermediateRoute: List = listOf(3), + intermediateRouteBack: List? = listOf(3), + hopStart: Int = 3, + ): MeshLog { + // snr_back must have one entry per node in route_back for fullRouteDiscovery to wrap it + val snrBack = intermediateRouteBack?.map { DUMMY_SNR } ?: emptyList() + val rd = + RouteDiscovery( + route = intermediateRoute, + route_back = intermediateRouteBack ?: emptyList(), + snr_back = snrBack, + ) + return MeshLog( + uuid = "res-$requestId", + message_type = "TRACEROUTE", + received_date = receivedDateMillis, + raw_message = "", + fromRadio = + FromRadio( + packet = + MeshPacket( + from = REMOTE_NODE, + to = LOCAL_NODE, + hop_start = hopStart, + decoded = + Data( + portnum = PortNum.TRACEROUTE_APP, + request_id = requestId, + payload = RouteDiscovery.ADAPTER.encode(rd).toByteString(), + ), + ), + ), + ) + } + + @Test + fun matchesRequestToResult() { + val requestTime = 1000L * MS_PER_SEC + val resultTime = 1005L * MS_PER_SEC + val requests = listOf(makeRequest(id = 42, receivedDateMillis = requestTime)) + val results = listOf(makeResult(requestId = 42, receivedDateMillis = resultTime)) + + val points = resolveTraceroutePoints(requests, results) + + assertEquals(1, points.size) + val point = points.first() + assertEquals(requests.first(), point.request) + assertNotNull(point.result) + // timeSeconds = received_date (millis) / MS_PER_SEC + assertEquals(1000.0, point.timeSeconds) + } + + @Test + fun computesForwardHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // 2 intermediate hops → fullRoute = [dest, hop1, hop2, src] → size 4 → hops = 2 + val results = + listOf( + makeResult(requestId = 1, receivedDateMillis = 1005L * MS_PER_SEC, intermediateRoute = listOf(10, 20)), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(2, point.forwardHops) + } + + @Test + fun directRoute_yieldsZeroHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // Direct route: no intermediate hops → fullRoute = [dest, src] → size 2 → hops = 0 + // route_back also empty intermediate → fullRouteBack = [src, dest] → size 2 → hops = 0 + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1002L * MS_PER_SEC, + intermediateRoute = emptyList(), + intermediateRouteBack = emptyList(), + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(0, point.forwardHops) + // route_back with empty intermediateRouteBack: snr_back will be empty, + // so fullRouteDiscovery won't wrap it → raw route_back is empty → returnHops = null + assertNull(point.returnHops) + } + + @Test + fun computesRoundTripSeconds() { + val requestTime = 2000L * MS_PER_SEC // 2_000_000 ms + val resultTime = requestTime + 3500L // 3.5 seconds later in millis + val requests = listOf(makeRequest(id = 1, receivedDateMillis = requestTime)) + val results = listOf(makeResult(requestId = 1, receivedDateMillis = resultTime)) + + val point = resolveTraceroutePoints(requests, results).first() + + val rtt = assertNotNull(point.roundTripSeconds) + assertEquals(3.5, rtt, 0.01) + } + + @Test + fun noMatchingResult_yieldsNulls() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // Result has a different requestId, so it won't match + val results = listOf(makeResult(requestId = 99, receivedDateMillis = 1005L * MS_PER_SEC)) + + val point = resolveTraceroutePoints(requests, results).first() + + assertNull(point.result) + assertNull(point.forwardHops) + assertNull(point.returnHops) + assertNull(point.roundTripSeconds) + } + + @Test + fun emptyInputs_returnsEmpty() { + assertEquals(emptyList(), resolveTraceroutePoints(emptyList(), emptyList())) + } + + @Test + fun multipleRequests_preservesOrder() { + val req1 = makeRequest(id = 1, receivedDateMillis = 3000L * MS_PER_SEC) + val req2 = makeRequest(id = 2, receivedDateMillis = 4000L * MS_PER_SEC) + val res1 = makeResult(requestId = 1, receivedDateMillis = 3005L * MS_PER_SEC) + val res2 = makeResult(requestId = 2, receivedDateMillis = 4005L * MS_PER_SEC) + + val points = resolveTraceroutePoints(listOf(req1, req2), listOf(res1, res2)) + + assertEquals(2, points.size) + assertEquals(3000.0, points[0].timeSeconds) + assertEquals(4000.0, points[1].timeSeconds) + } + + @Test + fun emptyRouteBack_yieldsNullReturnHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // 1 intermediate hop forward, but null route_back → no return path data + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1005L * MS_PER_SEC, + intermediateRoute = listOf(3), + intermediateRouteBack = null, + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(1, point.forwardHops) + 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)) + // 1 intermediate hop on return path, with hop_start and snr_back set + // → fullRouteBack = [src, hop, dest] → size 3 → returnHops = 1 + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1005L * MS_PER_SEC, + intermediateRoute = listOf(3), + intermediateRouteBack = listOf(3), + hopStart = 3, + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(1, point.forwardHops) + assertEquals(1, point.returnHops) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt new file mode 100644 index 000000000..87579610d --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt @@ -0,0 +1,120 @@ +/* + * 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.model + +import org.meshtastic.core.common.util.nowSeconds +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("MagicNumber") +class TimeFrameTest { + + // ---- timeThreshold ---- + + @Test + fun allTime_thresholdIsZero() { + assertEquals(0L, TimeFrame.ALL_TIME.timeThreshold(now = 1000000L)) + } + + @Test + fun oneHour_thresholdIsNowMinus3600() { + val now = 1000000L + assertEquals(now - 3600, TimeFrame.ONE_HOUR.timeThreshold(now = now)) + } + + @Test + fun twentyFourHours_thresholdIsNowMinus86400() { + val now = 1000000L + assertEquals(now - 86400, TimeFrame.TWENTY_FOUR_HOURS.timeThreshold(now = now)) + } + + @Test + fun sevenDays_thresholdIsNowMinus604800() { + val now = 1000000L + assertEquals(now - 604800, TimeFrame.SEVEN_DAYS.timeThreshold(now = now)) + } + + @Test + fun twoWeeks_thresholdIsCorrect() { + val now = 2000000L + assertEquals(now - 1209600, TimeFrame.TWO_WEEKS.timeThreshold(now = now)) + } + + @Test + fun oneMonth_thresholdIsCorrect() { + val now = 3000000L + assertEquals(now - 2592000, TimeFrame.ONE_MONTH.timeThreshold(now = now)) + } + + // ---- isAvailable ---- + + @Test + fun allTime_alwaysAvailable() { + assertTrue(TimeFrame.ALL_TIME.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds)) + } + + @Test + fun oneHour_alwaysAvailable() { + assertTrue(TimeFrame.ONE_HOUR.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds)) + } + + @Test + fun twentyFourHours_availableWhenDataOlderThan24h() { + val now = 1000000L + val oldest = now - 90000 // 25 hours ago + assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun twentyFourHours_notAvailableWhenDataYoungerThan24h() { + val now = 1000000L + val oldest = now - 3600 // 1 hour ago + assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun sevenDays_notAvailableForTwoDayOldData() { + val now = 1000000L + val oldest = now - (2 * 86400) // 2 days ago + assertFalse(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun sevenDays_availableForEightDayOldData() { + val now = 1000000L + val oldest = now - (8 * 86400) // 8 days ago + assertTrue(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun isAvailable_exactBoundary_returnsTrue() { + val now = 1000000L + // Exactly 24 hours of data range + val oldest = now - 86400 + assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun isAvailable_justUnderBoundary_returnsFalse() { + val now = 1000000L + // One second less than 24 hours + val oldest = now - 86399 + assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } +} diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt deleted file mode 100644 index 99572b3a9..000000000 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt +++ /dev/null @@ -1,95 +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.metrics - -import androidx.compose.material3.Text -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.device_metrics_log -import org.meshtastic.core.ui.theme.AppTheme -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertTrue - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class BaseMetricScreenTest { - - @get:Rule val composeTestRule = createComposeRule() - - @Test - fun baseMetricScreen_displaysTitleAndNodeName() { - val nodeName = "Test Node 123" - val testData = listOf("Item 1", "Item 2") - - composeTestRule.setContent { - AppTheme { - BaseMetricScreen( - onNavigateUp = {}, - telemetryType = TelemetryType.DEVICE, - titleRes = Res.string.device_metrics_log, - nodeName = nodeName, - data = testData, - timeProvider = { 0.0 }, - chartPart = { _, _, _, _ -> Text("Chart Placeholder") }, - listPart = { _, _, _, _ -> Text("List Placeholder") }, - ) - } - } - - // Verify Node Name is displayed (MainAppBar title) - composeTestRule.onNodeWithText(nodeName).assertIsDisplayed() - - // Verify Placeholders are displayed - composeTestRule.onNodeWithText("Chart Placeholder").assertIsDisplayed() - composeTestRule.onNodeWithText("List Placeholder").assertIsDisplayed() - } - - @Test - fun baseMetricScreen_refreshButtonTriggersCallback() { - var refreshClicked = false - val testData = emptyList() - - composeTestRule.setContent { - AppTheme { - BaseMetricScreen( - onNavigateUp = {}, - telemetryType = TelemetryType.DEVICE, - titleRes = Res.string.device_metrics_log, - nodeName = "Node", - data = testData, - timeProvider = { 0.0 }, - onRequestTelemetry = { refreshClicked = true }, - chartPart = { _, _, _, _ -> }, - listPart = { _, _, _, _ -> }, - ) - } - } - - composeTestRule.onNodeWithTag("refresh_button").performClick() - - assertTrue("Refresh callback should be triggered", refreshClicked) - } -} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 5419e3276..c33a6f353 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) alias(libs.plugins.meshtastic.kotlinx.serialization) + id("meshtastic.kmp.jvm.android") } kotlin { @@ -26,7 +27,6 @@ kotlin { android { namespace = "org.meshtastic.feature.settings" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -58,21 +58,10 @@ kotlin { } commonTest.dependencies { - implementation(project(":core:testing")) implementation(project(":core:datastore")) + implementation(libs.compose.multiplatform.ui.test) } - val androidHostTest by getting { - dependencies { - implementation(project(":core:datastore")) - implementation(libs.junit) - implementation(libs.robolectric) - implementation(libs.turbine) - 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) - } - } + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugFiltersTest.kt b/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugFiltersTest.kt deleted file mode 100644 index aeef9129d..000000000 --- a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugFiltersTest.kt +++ /dev/null @@ -1,134 +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.debugging - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.getValue -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.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.unit.dp -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -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_filters -import org.meshtastic.core.resources.getString -import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog -import org.robolectric.annotation.Config - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34]) -class DebugFiltersTest { - - @get:Rule val composeTestRule = createComposeRule() - - @Test - fun debugFilterBar_showsFilterButtonAndMenu() { - val filterLabel = getString(Res.string.debug_filters) - composeTestRule.setContent { - var filterTexts by remember { mutableStateOf(listOf()) } - var customFilterText by remember { mutableStateOf("") } - val presetFilters = listOf("Error", "Warning", "Info") - val logs = - listOf( - UiMeshLog( - uuid = "1", - messageType = "Info", - formattedReceivedDate = "2024-01-01 12:00:00", - logMessage = "Sample log message", - ), - ) - DebugFilterBar( - filterTexts = filterTexts, - onFilterTextsChange = { filterTexts = it }, - customFilterText = customFilterText, - onCustomFilterTextChange = { customFilterText = it }, - presetFilters = presetFilters, - logs = logs, - ) - } - // The filter button should be visible - composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() - } - - @Test - fun debugFilterBar_addCustomFilter_displaysActiveFilter() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { - var filterTexts by remember { mutableStateOf(listOf()) } - var customFilterText by remember { mutableStateOf("") } - Column(modifier = Modifier.padding(16.dp)) { - DebugActiveFilters( - filterTexts = filterTexts, - onFilterTextsChange = { filterTexts = it }, - filterMode = FilterMode.OR, - onFilterModeChange = {}, - ) - DebugCustomFilterInput( - customFilterText = customFilterText, - onCustomFilterTextChange = { customFilterText = it }, - filterTexts = filterTexts, - onFilterTextsChange = { filterTexts = it }, - ) - } - } - with(composeTestRule) { - // Add a custom filter - onNodeWithText("Add custom filter").performTextInput("MyFilter") - onNodeWithContentDescription("Add filter").performClick() - // The active filters label and the filter chip should be visible - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("MyFilter").assertIsDisplayed() - } - } - - @Test - fun debugActiveFilters_clearAllFilters_removesFilters() { - val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { - var filterTexts by remember { mutableStateOf(listOf("A", "B")) } - DebugActiveFilters( - filterTexts = filterTexts, - onFilterTextsChange = { filterTexts = it }, - filterMode = FilterMode.OR, - onFilterModeChange = {}, - ) - } - // The active filters label and chips should be visible - composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() - composeTestRule.onNodeWithText("A").assertIsDisplayed() - composeTestRule.onNodeWithText("B").assertIsDisplayed() - // Click the clear all filters button - composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() - // The filter chips should no longer be visible - 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 6cc890098..82cd4b7be 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -25,26 +25,24 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes -import org.meshtastic.core.navigation.WifiProvisionRoutes +import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.core.navigation.WifiProvisionRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.export_configuration @@ -55,8 +53,11 @@ import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection @@ -72,7 +73,6 @@ import org.meshtastic.proto.DeviceProfile import java.text.SimpleDateFormat import java.util.Locale -@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen( @@ -90,14 +90,14 @@ fun SettingsScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var deviceProfile by remember { mutableStateOf(null) } - var showEditDeviceProfileDialog by remember { mutableStateOf(false) } + var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { showEditDeviceProfileDialog = true it.data?.data?.let { uri -> - viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile } + viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile } } } } @@ -105,7 +105,7 @@ fun SettingsScreen( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) } + it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) } } } @@ -144,12 +144,12 @@ fun SettingsScreen( ) } - var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) } if (showLanguagePickerDialog) { LanguagePickerDialog { showLanguagePickerDialog = false } } - var showThemePickerDialog by remember { mutableStateOf(false) } + var showThemePickerDialog by rememberSaveable { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -157,6 +157,14 @@ fun SettingsScreen( ) } + var showContrastPickerDialog by remember { mutableStateOf(false) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + Scaffold( topBar = { MainAppBar( @@ -229,11 +237,12 @@ fun SettingsScreen( AppearanceSection( onShowLanguagePicker = { showLanguagePickerDialog = true }, onShowThemePicker = { showThemePickerDialog = true }, + onShowContrastPicker = { showContrastPickerDialog = true }, ) ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { - ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) { - onNavigate(WifiProvisionRoutes.WifiProvision()) + ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) { + onNavigate(WifiProvisionRoute.WifiProvision()) } } @@ -241,7 +250,7 @@ fun SettingsScreen( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, nodeShortName = ourNode?.user?.short_name ?: "", - onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) }, + onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) }, ) AppInfoSection( @@ -249,7 +258,7 @@ fun SettingsScreen( excludedModulesUnlocked = excludedModulesUnlocked, onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() }, onShowAppIntro = { settingsViewModel.showAppIntro() }, - onNavigateToAbout = { onNavigate(SettingsRoutes.About) }, + onNavigateToAbout = { onNavigate(SettingsRoute.About) }, ) } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt index cf953651f..2ca75d645 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt @@ -21,13 +21,6 @@ import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.AppSettingsAlt -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.WavingHand import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,6 +43,13 @@ import org.meshtastic.core.resources.modules_already_unlocked import org.meshtastic.core.resources.modules_unlocked import org.meshtastic.core.resources.system_settings import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.AppSettingsAlt +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.Info +import org.meshtastic.core.ui.icon.Memory +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Notifications +import org.meshtastic.core.ui.icon.WavingHand import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.showToast import kotlin.time.Duration.Companion.seconds @@ -70,7 +70,7 @@ fun AppInfoSection( ExpressiveSection(title = stringResource(Res.string.info)) { ListItem( text = stringResource(Res.string.intro_show), - leadingIcon = Icons.Rounded.WavingHand, + leadingIcon = MeshtasticIcons.WavingHand, trailingIcon = null, ) { onShowAppIntro() @@ -78,7 +78,7 @@ fun AppInfoSection( ListItem( text = stringResource(Res.string.app_notifications), - leadingIcon = Icons.Rounded.Notifications, + leadingIcon = MeshtasticIcons.Notifications, trailingIcon = null, ) { val intent = @@ -90,7 +90,7 @@ fun AppInfoSection( ListItem( text = stringResource(Res.string.system_settings), - leadingIcon = Icons.Rounded.AppSettingsAlt, + leadingIcon = MeshtasticIcons.AppSettingsAlt, trailingIcon = null, ) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) @@ -100,8 +100,8 @@ fun AppInfoSection( ListItem( text = stringResource(Res.string.acknowledgements), - leadingIcon = Icons.Rounded.Info, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.Info, + trailingIcon = MeshtasticIcons.ChevronRight, ) { onNavigateToAbout() } @@ -137,7 +137,7 @@ private fun AppVersionButton( ListItem( text = stringResource(Res.string.app_version), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, supportingText = appVersionName, trailingIcon = null, ) { 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 48807d8fa..cb61c8295 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt @@ -21,10 +21,6 @@ import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.FormatPaint -import androidx.compose.material.icons.rounded.Language import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview @@ -32,14 +28,23 @@ 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 +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.FormatPaint +import org.meshtastic.core.ui.icon.Language +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme -/** Section for app appearance settings like language and theme. */ +/** Section for app appearance settings like language, theme, and contrast. */ @Composable -fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) { +fun AppearanceSection( + onShowLanguagePicker: () -> Unit, + onShowThemePicker: () -> Unit, + onShowContrastPicker: () -> Unit, +) { val context = LocalContext.current val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -51,8 +56,8 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> ExpressiveSection(title = stringResource(Res.string.app_settings)) { ListItem( text = stringResource(Res.string.preferences_language), - leadingIcon = Icons.Rounded.Language, - trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.Language, + trailingIcon = if (useInAppLangPicker) null else MeshtasticIcons.ChevronRight, ) { if (useInAppLangPicker) { onShowLanguagePicker() @@ -69,16 +74,24 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> ListItem( text = stringResource(Res.string.theme), - leadingIcon = Icons.Rounded.FormatPaint, + leadingIcon = MeshtasticIcons.FormatPaint, trailingIcon = null, ) { onShowThemePicker() } + + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + onShowContrastPicker() + } } } @Preview(showBackground = true) @Composable private fun AppearanceSectionPreview() { - AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) } + AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt index c22235bd2..cc0ea3710 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt @@ -20,8 +20,6 @@ import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity.RESULT_OK -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Output import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview @@ -38,6 +36,8 @@ import org.meshtastic.core.resources.export_data_csv import org.meshtastic.core.resources.save_rangetest import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Output import org.meshtastic.core.ui.theme.AppTheme import java.text.SimpleDateFormat import java.util.Locale @@ -81,7 +81,7 @@ fun PersistenceSection( ListItem( text = stringResource(Res.string.save_rangetest), - leadingIcon = Icons.Rounded.Output, + leadingIcon = MeshtasticIcons.Output, trailingIcon = null, ) { val intent = @@ -95,7 +95,7 @@ fun PersistenceSection( ListItem( text = stringResource(Res.string.export_data_csv), - leadingIcon = Icons.Rounded.Output, + leadingIcon = MeshtasticIcons.Output, trailingIcon = null, ) { val intent = diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index c251b4d5e..315ad1da8 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_export_failed import org.meshtastic.core.resources.debug_export_success @@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { try { if (logs.isEmpty()) { withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt deleted file mode 100644 index 4b9cf369d..000000000 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.navigation - -import org.meshtastic.core.navigation.SettingsRoutes - -actual fun getAboutLibrariesJson(): String = - SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" 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 611837422..063add0d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt @@ -21,9 +21,6 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Row -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -33,14 +30,26 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.import_label import org.meshtastic.core.resources.play +import org.meshtastic.core.resources.ringtone_file_empty +import org.meshtastic.core.resources.ringtone_import_error +import org.meshtastic.core.resources.ringtone_imported +import org.meshtastic.core.ui.icon.FolderOpen +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PlayArrow import java.io.File private const val MAX_RINGTONE_SIZE = 230 +private const val IMPORT_ERROR_PLACEHOLDER = "@@ERROR@@" @Suppress("TooGenericExceptionCaught") @Composable actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { val context = LocalContext.current + val importedText = stringResource(Res.string.ringtone_imported) + val emptyText = stringResource(Res.string.ringtone_file_empty) + // Pre-resolve the format pattern for use in the non-composable launcher callback. + // Using a sentinel placeholder that will be replaced at call-site. + val importErrorPrefix = stringResource(Res.string.ringtone_import_error, IMPORT_ERROR_PLACEHOLDER) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -52,22 +61,23 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri val read = reader.read(buffer) if (read > 0) { onRingtoneImported(String(buffer, 0, read)) - Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() + Toast.makeText(context, importedText, Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() + Toast.makeText(context, emptyText, Toast.LENGTH_SHORT).show() } } } } catch (e: Exception) { Logger.e(e) { "Error importing ringtone" } - Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() + val errorMsg = importErrorPrefix.replace(IMPORT_ERROR_PLACEHOLDER, e.message ?: e.toString()) + Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show() } } } Row { IconButton(onClick = { launcher.launch("*/*") }, enabled = enabled) { - Icon(Icons.Default.FolderOpen, contentDescription = stringResource(Res.string.import_label)) + Icon(MeshtasticIcons.FolderOpen, contentDescription = stringResource(Res.string.import_label)) } IconButton( @@ -89,7 +99,7 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri }, enabled = enabled, ) { - Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play)) + Icon(MeshtasticIcons.PlayArrow, contentDescription = stringResource(Res.string.play)) } } } 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 82ad76554..15cd0e11d 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt @@ -21,8 +21,6 @@ import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Warning import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,13 +30,15 @@ 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 import org.meshtastic.core.ui.component.MeshtasticResourceDialog +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Warning import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config @@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } + it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) } } } @@ -81,7 +81,7 @@ actual fun ExportSecurityConfigButton( modifier = Modifier.padding(horizontal = 8.dp), title = stringResource(Res.string.export_keys), enabled = enabled, - icon = Icons.TwoTone.Warning, + icon = MeshtasticIcons.Warning, onClick = { showEditSecurityConfigDialog = true }, ) } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index 9afde85e5..a28a57678 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher @Composable actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { @@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr return { fileName -> exportLauncher.launch(fileName) } } -private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { +private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { try { context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } Logger.i { "TAK data package exported successfully to $targetUri" } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 499874e26..723448897 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -23,7 +23,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -private const val SDK_INT_ANDROID_16 = 37 +private val SDK_INT_ANDROID_16 = Build.VERSION_CODES.BAKLAVA @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt deleted file mode 100644 index 9eb31a6e7..000000000 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ /dev/null @@ -1,98 +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.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/AdministrationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt index d63620ff7..d19387a2e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration @@ -135,7 +136,7 @@ private fun AdminRouteItems( ListItem( enabled = enabled, text = stringResource(route.title), - leadingIcon = route.icon, + leadingIcon = vectorResource(route.icon), leadingIconTint = MaterialTheme.colorScheme.error, textColor = MaterialTheme.colorScheme.error, trailingIcon = null, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt index 0c3ec91f7..1b522abb6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.device_configuration @@ -71,7 +72,7 @@ fun DeviceConfigurationScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni ConfigRoute.deviceConfigRoutes(state.metadata).forEach { ListItem( text = stringResource(it.title), - leadingIcon = it.icon, + leadingIcon = it.icon?.let { res -> vectorResource(res) }, enabled = state.connected && !state.responseState.isWaiting(), ) { onNavigate(it.route) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt index faf2f792e..7e59bba93 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.module_settings @@ -86,7 +87,7 @@ fun ModuleConfigurationScreen( modules.forEach { ListItem( text = stringResource(it.title), - leadingIcon = it.icon, + leadingIcon = it.icon?.let { res -> vectorResource(res) }, enabled = state.connected && !state.responseState.isWaiting(), ) { onNavigate(it.route) 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 a6c8abfb9..ddad8296e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.settings import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,22 +24,23 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase 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,6 +50,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig @@ -65,6 +66,7 @@ class SettingsViewModel( private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, private val setThemeUseCase: SetThemeUseCase, + private val setContrastLevelUseCase: SetContrastLevelUseCase, private val setLocaleUseCase: SetLocaleUseCase, private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, private val setProvideLocationUseCase: SetProvideLocationUseCase, @@ -84,7 +86,9 @@ class SettingsViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo val isConnected = - radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) + radioController.connectionState + .map { it is ConnectionState.Connected } + .stateInWhileSubscribed(initialValue = false) val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) @@ -143,12 +147,12 @@ class SettingsViewModel( val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() fun setMeshLogRetentionDays(days: Int) { - viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) } + safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) } _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) } fun setMeshLogLoggingEnabled(enabled: Boolean) { - viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } + safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } _meshLogLoggingEnabled.value = enabled } @@ -160,6 +164,10 @@ class SettingsViewModel( setThemeUseCase(theme) } + fun setContrastLevel(level: Int) { + setContrastLevelUseCase(level) + } + /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { setLocaleUseCase(languageTag) @@ -179,8 +187,10 @@ class SettingsViewModel( * @param uri The destination URI for the CSV file. * @param filterPortnum If provided, only packets with this port number will be exported. */ - fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { - viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } + fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) { + safeLaunch(tag = "saveDataCsv") { + fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } + } } private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index f479e3d26..c1d36e2ee 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.settings.channel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.RadioController @@ -30,6 +28,7 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -86,7 +85,7 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -97,12 +96,12 @@ class ChannelViewModel( } fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt new file mode 100644 index 000000000..c8adc418a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MatchingDeclarationName") + +package org.meshtastic.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.choose_contrast +import org.meshtastic.core.resources.contrast_high +import org.meshtastic.core.resources.contrast_medium +import org.meshtastic.core.resources.contrast_standard +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.theme.ContrastLevel + +/** Contrast level options matching [ContrastLevel] ordinal values. */ +enum class ContrastOption(val label: StringResource, val level: ContrastLevel) { + STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD), + MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM), + HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH), +} + +/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */ +@Composable +fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) { + MeshtasticDialog( + title = stringResource(Res.string.choose_contrast), + onDismiss = onDismiss, + text = { + Column { + ContrastOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickContrast(option.level.value) + onDismiss() + } + } + } + }, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt index 6184323fa..76b3932ad 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt @@ -16,20 +16,20 @@ */ package org.meshtastic.feature.settings.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Abc import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.use_homoglyph_characters_encoding import org.meshtastic.core.ui.component.SwitchListItem +import org.meshtastic.core.ui.icon.Abc +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) { SwitchListItem( text = stringResource(Res.string.use_homoglyph_characters_encoding), checked = homoglyphEncodingEnabled, - leadingIcon = Icons.Default.Abc, + leadingIcon = MeshtasticIcons.Abc, onClick = onToggle, ) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt index 96e848a12..ef628d09d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.feature.settings.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Message -import androidx.compose.material.icons.rounded.BatteryAlert -import androidx.compose.material.icons.rounded.PersonAdd import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res @@ -28,6 +24,10 @@ import org.meshtastic.core.resources.meshtastic_low_battery_notifications import org.meshtastic.core.resources.meshtastic_messages_notifications import org.meshtastic.core.resources.meshtastic_new_nodes_notifications import org.meshtastic.core.ui.component.SwitchListItem +import org.meshtastic.core.ui.icon.BatteryAlert +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Message +import org.meshtastic.core.ui.icon.PersonAdd /** * Notification settings section with in-app toggles. Primarily used on platforms without system notification channels. @@ -44,19 +44,19 @@ fun NotificationSection( ExpressiveSection(title = stringResource(Res.string.app_notifications)) { SwitchListItem( text = stringResource(Res.string.meshtastic_messages_notifications), - leadingIcon = Icons.AutoMirrored.Rounded.Message, + leadingIcon = MeshtasticIcons.Message, checked = messagesEnabled, onClick = { onToggleMessages(!messagesEnabled) }, ) SwitchListItem( text = stringResource(Res.string.meshtastic_new_nodes_notifications), - leadingIcon = Icons.Rounded.PersonAdd, + leadingIcon = MeshtasticIcons.PersonAdd, checked = nodeEventsEnabled, onClick = { onToggleNodeEvents(!nodeEventsEnabled) }, ) SwitchListItem( text = stringResource(Res.string.meshtastic_low_battery_notifications), - leadingIcon = Icons.Rounded.BatteryAlert, + leadingIcon = MeshtasticIcons.BatteryAlert, checked = lowBatteryEnabled, onClick = { onToggleLowBattery(!lowBatteryEnabled) }, ) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt similarity index 58% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index cecdc27b8..3930580d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -16,29 +16,24 @@ */ package org.meshtastic.feature.settings.component -import android.Manifest -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.rounded.LocationOn 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 import org.meshtastic.core.resources.location_disabled import org.meshtastic.core.resources.provide_location_to_mesh import org.meshtastic.core.ui.component.SwitchListItem -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.util.showToast +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 /** Section managing privacy settings like analytics and location sharing. */ -@OptIn(ExperimentalPermissionsApi::class) @Composable fun PrivacySection( analyticsAvailable: Boolean, @@ -51,21 +46,22 @@ fun PrivacySection( startProvideLocation: () -> Unit, stopProvideLocation: () -> Unit, ) { - val context = LocalContext.current - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - val isGpsDisabled = context.gpsDisabled() + val showToast = rememberShowToastResource() + val isLocationGranted = isLocationPermissionGranted() + val isGpsOff = isGpsDisabled() + val requestLocationPermission = + rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {}) - LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { + LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) { if (provideLocation) { - if (locationPermissionsState.allPermissionsGranted) { - if (!isGpsDisabled) { + if (isLocationGranted) { + if (!isGpsOff) { startProvideLocation() } else { - context.showToast(Res.string.location_disabled) + showToast(Res.string.location_disabled) } } else { - locationPermissionsState.launchMultiplePermissionRequest() + requestLocationPermission() } } else { stopProvideLocation() @@ -77,15 +73,15 @@ fun PrivacySection( SwitchListItem( text = stringResource(Res.string.analytics_okay), checked = analyticsEnabled, - leadingIcon = Icons.Default.BugReport, + leadingIcon = MeshtasticIcons.BugReport, onClick = onToggleAnalytics, ) } SwitchListItem( text = stringResource(Res.string.provide_location_to_mesh), - leadingIcon = Icons.Rounded.LocationOn, - enabled = !isGpsDisabled, + leadingIcon = MeshtasticIcons.LocationOn, + enabled = !isGpsOff, checked = provideLocation, onClick = { onToggleLocation(!provideLocation) }, ) @@ -93,21 +89,3 @@ fun PrivacySection( HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph) } } - -@Preview(showBackground = true) -@Composable -private fun PrivacySectionPreview() { - AppTheme { - PrivacySection( - analyticsAvailable = true, - analyticsEnabled = true, - onToggleAnalytics = {}, - provideLocation = true, - onToggleLocation = {}, - homoglyphEnabled = false, - onToggleHomoglyph = {}, - startProvideLocation = {}, - stopProvideLocation = {}, - ) - } -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 1316ebb49..dba15e1a4 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 @@ -30,14 +30,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.Card 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 @@ -85,6 +83,9 @@ import org.meshtastic.core.ui.component.CopyIconButton import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.SwitchPreference +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Settings import org.meshtastic.core.ui.theme.AnnotationColor import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import kotlin.time.Instant.Companion.fromEpochMilliseconds @@ -138,8 +139,8 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { - IconButton(onClick = { showSettings = !showSettings }) { - Icon(imageVector = Icons.Rounded.Settings, contentDescription = null) + IconToggleButton(checked = showSettings, onCheckedChange = { showSettings = it }) { + Icon(imageVector = MeshtasticIcons.Settings, contentDescription = null) } DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() }) }, @@ -165,15 +166,16 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { filterMode = filterMode, onFilterModeChange = { filterMode = it }, onExportLogs = { - val format = LocalDateTime.Format { - year() - monthNumber() - day() - char('_') - hour() - minute() - second() - } + val format = + LocalDateTime.Format { + year() + monthNumber() + day() + char('_') + hour() + minute() + second() + } val timestamp = fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format) val fileName = "meshtastic_debug_$timestamp.txt" @@ -388,7 +390,7 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann @Composable fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) { IconButton(onClick = deleteLogs, modifier = modifier.padding(4.dp)) { - Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.debug_clear)) + Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.debug_clear)) } } 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 2429f0abd..df4a0965f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt @@ -28,13 +28,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.twotone.FilterAlt -import androidx.compose.material.icons.twotone.FilterAltOff import androidx.compose.material3.DropdownMenu import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -64,8 +57,16 @@ 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 +import org.meshtastic.core.ui.icon.FilterAlt +import org.meshtastic.core.ui.icon.FilterAltOff +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog @Composable @@ -104,7 +105,7 @@ fun DebugCustomFilterInput( }, enabled = customFilterText.isNotBlank(), ) { - Icon(imageVector = Icons.Rounded.Add, contentDescription = stringResource(Res.string.debug_filter_add)) + Icon(imageVector = MeshtasticIcons.Add, contentDescription = stringResource(Res.string.debug_filter_add)) } } } @@ -117,13 +118,14 @@ fun DebugPresetFilters( onFilterTextsChange: (List) -> Unit, modifier: Modifier = Modifier, ) { - val availableFilters = presetFilters.filter { filter -> - logs.any { log -> - log.logMessage.contains(filter, ignoreCase = true) || - log.messageType.contains(filter, ignoreCase = true) || - log.formattedReceivedDate.contains(filter, ignoreCase = true) + val availableFilters = + presetFilters.filter { filter -> + logs.any { log -> + log.logMessage.contains(filter, ignoreCase = true) || + log.messageType.contains(filter, ignoreCase = true) || + log.formattedReceivedDate.contains(filter, ignoreCase = true) + } } - } Column(modifier = modifier) { Text( text = stringResource(Res.string.debug_filter_preset_title), @@ -151,7 +153,7 @@ fun DebugPresetFilters( leadingIcon = { if (filter in filterTexts) { Icon( - imageVector = Icons.Filled.Done, + imageVector = MeshtasticIcons.Check, contentDescription = stringResource(Res.string.debug_filter_included), ) } @@ -188,9 +190,9 @@ fun DebugFilterBar( Icon( imageVector = if (filterTexts.isNotEmpty()) { - Icons.TwoTone.FilterAlt + MeshtasticIcons.FilterAlt } else { - Icons.TwoTone.FilterAltOff + MeshtasticIcons.FilterAltOff }, contentDescription = stringResource(Res.string.debug_filters), ) @@ -266,7 +268,7 @@ fun DebugActiveFilters( } IconButton(onClick = { onFilterTextsChange(emptyList()) }) { Icon( - imageVector = Icons.Rounded.Clear, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.debug_filter_clear), ) } @@ -281,8 +283,18 @@ fun DebugActiveFilters( selected = true, onClick = { onFilterTextsChange(filterTexts - filter) }, label = { Text(filter) }, - leadingIcon = { Icon(imageVector = Icons.TwoTone.FilterAlt, contentDescription = null) }, - trailingIcon = { Icon(imageVector = Icons.Filled.Clear, contentDescription = null) }, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.FilterAlt, + contentDescription = stringResource(Res.string.filter_icon), + ) + }, + trailingIcon = { + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.remove_filter), + ) + }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt index 9bb261efa..1600ce947 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt @@ -27,11 +27,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.FileDownload -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -40,7 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,6 +50,11 @@ import org.meshtastic.core.resources.debug_logs_export import org.meshtastic.core.resources.debug_search_clear import org.meshtastic.core.resources.debug_search_next import org.meshtastic.core.resources.debug_search_prev +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.FileDownload +import org.meshtastic.core.ui.icon.KeyboardArrowDown +import org.meshtastic.core.ui.icon.KeyboardArrowUp +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState @@ -77,14 +77,14 @@ fun DebugSearchNavigation( ) IconButton(onClick = onPreviousMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) { Icon( - imageVector = Icons.Rounded.KeyboardArrowUp, + imageVector = MeshtasticIcons.KeyboardArrowUp, contentDescription = stringResource(Res.string.debug_search_prev), modifier = Modifier.size(16.dp), ) } IconButton(onClick = onNextMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) { Icon( - imageVector = Icons.Rounded.KeyboardArrowDown, + imageVector = MeshtasticIcons.KeyboardArrowDown, contentDescription = stringResource(Res.string.debug_search_next), modifier = Modifier.size(16.dp), ) @@ -130,7 +130,7 @@ fun DebugSearchBar( if (searchState.searchText.isNotEmpty()) { IconButton(onClick = onClearSearch, modifier = Modifier.size(32.dp)) { Icon( - imageVector = Icons.Rounded.Clear, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.debug_search_clear), modifier = Modifier.size(16.dp), ) @@ -158,7 +158,7 @@ fun DebugSearchState( onExportLogs: (() -> Unit)? = null, ) { val colorScheme = MaterialTheme.colorScheme - var customFilterText by remember { mutableStateOf("") } + var customFilterText by rememberSaveable { mutableStateOf("") } Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) { Row( @@ -186,7 +186,7 @@ fun DebugSearchState( onExportLogs?.let { onExport -> IconButton(onClick = onExport, modifier = Modifier) { Icon( - imageVector = Icons.Outlined.FileDownload, + imageVector = MeshtasticIcons.FileDownload, contentDescription = stringResource(Res.string.debug_logs_export), modifier = Modifier.size(24.dp), ) 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 59ab4d4cf..f04ade2e8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter @@ -47,6 +45,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -62,15 +61,6 @@ import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint -data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) - -data class SearchState( - val searchText: String = "", - val currentMatchIndex: Int = -1, - val allMatches: List = emptyList(), - val hasMatches: Boolean = false, -) - enum class FilterMode { AND, OR, @@ -142,7 +132,7 @@ class LogSearchManager { return filteredLogs .flatMapIndexed { logIndex, log -> searchText.split(" ").flatMap { term -> - val escapedTerm = term // Simple regex escape or just use contains + val escapedTerm = Regex.escape(term) val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE) val messageMatches = regex.findAll(log.logMessage).map { @@ -265,16 +255,18 @@ class DebugViewModel( val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) meshLogPrefs.setRetentionDays(clamped) _retentionDays.value = clamped - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) } + safeLaunch(tag = "setRetentionDays") { meshLogRepository.deleteLogsOlderThan(clamped) } } fun setLoggingEnabled(enabled: Boolean) { meshLogPrefs.setLoggingEnabled(enabled) _loggingEnabled.value = enabled if (!enabled) { - viewModelScope.launch { meshLogRepository.deleteAll() } + safeLaunch(tag = "disableLogging") { meshLogRepository.deleteAll() } } else { - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) } + safeLaunch(tag = "enableLogging") { + meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) + } } } @@ -286,7 +278,7 @@ class DebugViewModel( init { Logger.d { "DebugViewModel created" } - viewModelScope.launch { + safeLaunch(tag = "searchMatchUpdater") { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } @@ -386,17 +378,15 @@ class DebugViewModel( val nodeIdStr = nodeId.toUInt().toString() // Only match if whitespace before and after val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") - regex.find(this)?.let { _ -> - regex.findAll(this).toList().asReversed().forEach { - val idx = it.range.last + 1 - insert(idx, " (${nodeId.toHex(8)})") - } - return true + if (!regex.containsMatchIn(this)) return false + regex.findAll(this).toList().asReversed().forEach { + val idx = it.range.last + 1 + insert(idx, " (${nodeId.toHex(8)})") } - return false + return true } - private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0') + private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}" fun requestDeleteAllLogs() { alertManager.showAlert( @@ -406,7 +396,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } + fun deleteAllLogs() = safeLaunch(context = ioDispatcher, tag = "deleteAllLogs") { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt index 0a6b4d814..ab36a1f51 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt @@ -25,9 +25,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -60,6 +57,9 @@ import org.meshtastic.core.resources.filter_whole_word import org.meshtastic.core.resources.filter_words import org.meshtastic.core.resources.filter_words_summary import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable fun FilterSettingsScreen(viewModel: FilterSettingsViewModel, onBack: () -> Unit) { @@ -155,7 +155,7 @@ private fun FilterWordsInputCard(newWord: String, onNewWordChange: (String) -> U keyboardActions = KeyboardActions(onDone = { onAddWord() }), ) IconButton(onClick = onAddWord) { - Icon(Icons.Rounded.Add, contentDescription = stringResource(Res.string.add)) + Icon(MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add)) } } } @@ -183,7 +183,7 @@ private fun FilterWordItem(word: String, onRemove: () -> Unit) { ) } IconButton(onClick = onRemove) { - Icon(Icons.Rounded.Delete, contentDescription = stringResource(Res.string.delete)) + Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt index 9c6bb2cc8..600554ba3 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt @@ -16,26 +16,25 @@ */ package org.meshtastic.feature.settings.navigation -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.CellTower -import androidx.compose.material.icons.filled.DisplaySettings -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Power -import androidx.compose.material.icons.filled.Router -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bluetooth import org.meshtastic.core.resources.channels import org.meshtastic.core.resources.device import org.meshtastic.core.resources.display +import org.meshtastic.core.resources.ic_bluetooth +import org.meshtastic.core.resources.ic_cell_tower +import org.meshtastic.core.resources.ic_display_settings +import org.meshtastic.core.resources.ic_list +import org.meshtastic.core.resources.ic_location_on +import org.meshtastic.core.resources.ic_person +import org.meshtastic.core.resources.ic_power +import org.meshtastic.core.resources.ic_router +import org.meshtastic.core.resources.ic_security +import org.meshtastic.core.resources.ic_wifi import org.meshtastic.core.resources.lora import org.meshtastic.core.resources.network import org.meshtastic.core.resources.position @@ -45,40 +44,50 @@ import org.meshtastic.core.resources.user import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.DeviceMetadata -enum class ConfigRoute(val title: StringResource, val route: Route, val icon: ImageVector?, val type: Int = 0) { - USER(Res.string.user, SettingsRoutes.User, Icons.Default.Person, 0), - CHANNELS(Res.string.channels, SettingsRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0), - DEVICE(Res.string.device, SettingsRoutes.Device, Icons.Default.Router, AdminMessage.ConfigType.DEVICE_CONFIG.value), +enum class ConfigRoute( + val title: StringResource, + val route: Route, + val icon: DrawableResource? = null, + val type: Int = 0, +) { + USER(Res.string.user, SettingsRoute.User, Res.drawable.ic_person, 0), + CHANNELS(Res.string.channels, SettingsRoute.ChannelConfig, Res.drawable.ic_list, 0), + DEVICE( + Res.string.device, + SettingsRoute.Device, + Res.drawable.ic_router, + AdminMessage.ConfigType.DEVICE_CONFIG.value, + ), POSITION( Res.string.position, - SettingsRoutes.Position, - Icons.Default.LocationOn, + SettingsRoute.Position, + Res.drawable.ic_location_on, AdminMessage.ConfigType.POSITION_CONFIG.value, ), - POWER(Res.string.power, SettingsRoutes.Power, Icons.Default.Power, AdminMessage.ConfigType.POWER_CONFIG.value), + POWER(Res.string.power, SettingsRoute.Power, Res.drawable.ic_power, AdminMessage.ConfigType.POWER_CONFIG.value), NETWORK( Res.string.network, - SettingsRoutes.Network, - Icons.Default.Wifi, + SettingsRoute.Network, + Res.drawable.ic_wifi, AdminMessage.ConfigType.NETWORK_CONFIG.value, ), DISPLAY( Res.string.display, - SettingsRoutes.Display, - Icons.Default.DisplaySettings, + SettingsRoute.Display, + Res.drawable.ic_display_settings, AdminMessage.ConfigType.DISPLAY_CONFIG.value, ), - LORA(Res.string.lora, SettingsRoutes.LoRa, Icons.Default.CellTower, AdminMessage.ConfigType.LORA_CONFIG.value), + LORA(Res.string.lora, SettingsRoute.LoRa, Res.drawable.ic_cell_tower, AdminMessage.ConfigType.LORA_CONFIG.value), BLUETOOTH( Res.string.bluetooth, - SettingsRoutes.Bluetooth, - Icons.Default.Bluetooth, + SettingsRoute.Bluetooth, + Res.drawable.ic_bluetooth, AdminMessage.ConfigType.BLUETOOTH_CONFIG.value, ), SECURITY( Res.string.security, - SettingsRoutes.Security, - Icons.Default.Security, + SettingsRoute.Security, + Res.drawable.ic_security, AdminMessage.ConfigType.SECURITY_CONFIG.value, ), ; diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt index fd7eae24c..4213a4263 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt @@ -16,31 +16,31 @@ */ package org.meshtastic.feature.settings.navigation -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Forward -import androidx.compose.material.icons.automirrored.filled.Message -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.rounded.Cloud -import androidx.compose.material.icons.rounded.DataUsage -import androidx.compose.material.icons.rounded.LightMode -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.People -import androidx.compose.material.icons.rounded.PermScanWifi -import androidx.compose.material.icons.rounded.Sensors -import androidx.compose.material.icons.rounded.SettingsRemote -import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material.icons.rounded.Usb -import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.model.Capabilities import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ambient_lighting import org.meshtastic.core.resources.audio import org.meshtastic.core.resources.canned_message import org.meshtastic.core.resources.detection_sensor import org.meshtastic.core.resources.external_notification +import org.meshtastic.core.resources.ic_alt_route +import org.meshtastic.core.resources.ic_cloud +import org.meshtastic.core.resources.ic_data_usage +import org.meshtastic.core.resources.ic_group +import org.meshtastic.core.resources.ic_light_mode +import org.meshtastic.core.resources.ic_message +import org.meshtastic.core.resources.ic_notifications +import org.meshtastic.core.resources.ic_perm_scan_wifi +import org.meshtastic.core.resources.ic_sensors +import org.meshtastic.core.resources.ic_settings_remote +import org.meshtastic.core.resources.ic_speed +import org.meshtastic.core.resources.ic_terminal +import org.meshtastic.core.resources.ic_usb +import org.meshtastic.core.resources.ic_volume_up import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.neighbor_info import org.meshtastic.core.resources.paxcounter @@ -59,102 +59,102 @@ import org.meshtastic.proto.DeviceMetadata enum class ModuleRoute( val title: StringResource, val route: Route, - val icon: ImageVector?, + val icon: DrawableResource? = null, val type: Int = 0, val isSupported: (Capabilities) -> Boolean = { true }, val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true }, ) { - MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value), + MQTT(Res.string.mqtt, SettingsRoute.MQTT, Res.drawable.ic_cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value), SERIAL( Res.string.serial, - SettingsRoutes.Serial, - Icons.Rounded.Usb, + SettingsRoute.Serial, + Res.drawable.ic_usb, AdminMessage.ModuleConfigType.SERIAL_CONFIG.value, ), EXT_NOTIFICATION( Res.string.external_notification, - SettingsRoutes.ExtNotification, - Icons.Rounded.Notifications, + SettingsRoute.ExtNotification, + Res.drawable.ic_notifications, AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG.value, ), STORE_FORWARD( Res.string.store_forward, - SettingsRoutes.StoreForward, - Icons.AutoMirrored.Default.Forward, + SettingsRoute.StoreForward, + Res.drawable.ic_terminal, AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG.value, ), RANGE_TEST( Res.string.range_test, - SettingsRoutes.RangeTest, - Icons.Rounded.Speed, + SettingsRoute.RangeTest, + Res.drawable.ic_speed, AdminMessage.ModuleConfigType.RANGETEST_CONFIG.value, ), TELEMETRY( Res.string.telemetry, - SettingsRoutes.Telemetry, - Icons.Rounded.DataUsage, + SettingsRoute.Telemetry, + Res.drawable.ic_data_usage, AdminMessage.ModuleConfigType.TELEMETRY_CONFIG.value, ), CANNED_MESSAGE( Res.string.canned_message, - SettingsRoutes.CannedMessage, - Icons.AutoMirrored.Default.Message, + SettingsRoute.CannedMessage, + Res.drawable.ic_message, AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG.value, ), AUDIO( Res.string.audio, - SettingsRoutes.Audio, - Icons.AutoMirrored.Default.VolumeUp, + SettingsRoute.Audio, + Res.drawable.ic_volume_up, AdminMessage.ModuleConfigType.AUDIO_CONFIG.value, ), REMOTE_HARDWARE( Res.string.remote_hardware, - SettingsRoutes.RemoteHardware, - Icons.Rounded.SettingsRemote, + SettingsRoute.RemoteHardware, + Res.drawable.ic_settings_remote, AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG.value, ), NEIGHBOR_INFO( Res.string.neighbor_info, - SettingsRoutes.NeighborInfo, - Icons.Rounded.People, + SettingsRoute.NeighborInfo, + Res.drawable.ic_group, AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG.value, ), AMBIENT_LIGHTING( Res.string.ambient_lighting, - SettingsRoutes.AmbientLighting, - Icons.Rounded.LightMode, + SettingsRoute.AmbientLighting, + Res.drawable.ic_light_mode, AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG.value, ), DETECTION_SENSOR( Res.string.detection_sensor, - SettingsRoutes.DetectionSensor, - Icons.Rounded.Sensors, + SettingsRoute.DetectionSensor, + Res.drawable.ic_sensors, AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG.value, ), PAXCOUNTER( Res.string.paxcounter, - SettingsRoutes.Paxcounter, - Icons.Rounded.PermScanWifi, + SettingsRoute.Paxcounter, + Res.drawable.ic_perm_scan_wifi, AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG.value, ), STATUS_MESSAGE( Res.string.status_message, - SettingsRoutes.StatusMessage, - Icons.AutoMirrored.Default.Message, + SettingsRoute.StatusMessage, + Res.drawable.ic_message, AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value, isSupported = { it.supportsStatusMessage }, ), TRAFFIC_MANAGEMENT( Res.string.traffic_management, - SettingsRoutes.TrafficManagement, - Icons.Rounded.Speed, + SettingsRoute.TrafficManagement, + Res.drawable.ic_alt_route, AdminMessage.ModuleConfigType.TRAFFICMANAGEMENT_CONFIG.value, isSupported = { it.supportsTrafficManagementConfig }, ), TAK( Res.string.tak, - SettingsRoutes.TAK, - Icons.Rounded.People, + SettingsRoute.TAK, + Res.drawable.ic_group, AdminMessage.ModuleConfigType.TAK_CONFIG.value, isSupported = { it.supportsTakConfig }, isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER }, 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 ac713ae7e..1ee791620 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -18,17 +18,17 @@ 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 import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.feature.settings.AboutScreen import org.meshtastic.feature.settings.AdministrationScreen import org.meshtastic.feature.settings.DeviceConfigurationScreen @@ -74,59 +74,62 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod val viewModel = koinViewModel() val destNum = remember(backStack.toList()) { - backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum } + backStack.lastOrNull { it is SettingsRoute.Settings }?.let { (it as SettingsRoute.Settings).destNum } ?: backStack - .lastOrNull { it is SettingsRoutes.SettingsGraph } - ?.let { (it as SettingsRoutes.SettingsGraph).destNum } + .lastOrNull { it is SettingsRoute.SettingsGraph } + ?.let { (it as SettingsRoute.SettingsGraph).destNum } } - SideEffect { viewModel.initDestNum(destNum) } + LaunchedEffect(destNum) { viewModel.initDestNum(destNum) } return viewModel } @Suppress("LongMethod", "CyclomaticComplexMethod") fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { - entry { + entry { SettingsMainScreen( settingsViewModel = koinViewModel(), radioConfigViewModel = getRadioConfigViewModel(backStack), - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetail(it)) }, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigate = { backStack.add(it) }, ) } - entry { + entry { SettingsMainScreen( settingsViewModel = koinViewModel(), radioConfigViewModel = getRadioConfigViewModel(backStack), - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetail(it)) }, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigate = { backStack.add(it) }, ) } - entry { + entry { DeviceConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } - entry { + entry { val settingsViewModel: SettingsViewModel = koinViewModel() val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() ModuleConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), excludedModulesUnlocked = excludedModulesUnlocked, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } - entry { - AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() }) + entry { + AdministrationScreen( + viewModel = getRadioConfigViewModel(backStack), + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) } - entry { + entry { val viewModel: CleanNodeDatabaseViewModel = koinViewModel() CleanNodeDatabaseScreen(viewModel = viewModel) } @@ -135,16 +138,26 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> DeviceConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> PositionConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.SECURITY -> SecurityConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.USER -> + UserConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.CHANNELS -> + ChannelConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DEVICE -> + DeviceConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POSITION -> + PositionConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POWER -> + PowerConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.NETWORK -> + NetworkConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DISPLAY -> + DisplayConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.LORA -> + LoRaConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.BLUETOOTH -> + BluetoothConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.SECURITY -> + SecurityConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } @@ -153,50 +166,63 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.MQTT -> + MQTTConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.SERIAL -> + SerialConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreenCommon( viewModel = viewModel, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) ModuleRoute.STORE_FORWARD -> - StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StoreForwardConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.RANGE_TEST -> + RangeTestConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.TELEMETRY -> + TelemetryConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.CANNED_MESSAGE -> - CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + CannedMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.AUDIO -> + AudioConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.REMOTE_HARDWARE -> - RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + RemoteHardwareConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.NEIGHBOR_INFO -> - NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + NeighborInfoConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.AMBIENT_LIGHTING -> - AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + AmbientLightingConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.DETECTION_SENSOR -> - DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + DetectionSensorConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.PAXCOUNTER -> + PaxcounterConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.STATUS_MESSAGE -> - StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StatusMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.TRAFFIC_MANAGEMENT -> - TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + TrafficManagementConfigScreen( + viewModel, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + ModuleRoute.TAK -> + TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } - entry { + entry { val viewModel: DebugViewModel = koinViewModel() - DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + DebugScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } - entry { - AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() }) + entry { + AboutScreen( + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + jsonProvider = { getAboutLibrariesJson() }, + ) } - entry { + entry { val viewModel: FilterSettingsViewModel = koinViewModel() - FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) + FilterSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index d47791300..26bacd139 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,10 +17,8 @@ package org.meshtastic.feature.settings.radio import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowSeconds @@ -31,6 +29,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation import org.meshtastic.core.resources.clean_now import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch private const val MIN_DAYS_THRESHOLD = 7f @@ -65,7 +64,7 @@ class CleanNodeDatabaseViewModel( /** Updates the list of nodes to be deleted based on the current filter criteria. */ fun getNodesToDelete() { - viewModelScope.launch { + safeLaunch(tag = "getNodesToDelete") { _nodesToDelete.value = cleanNodeDatabaseUseCase.getNodesToClean( olderThanDays = _olderThanDays.value, @@ -76,7 +75,7 @@ class CleanNodeDatabaseViewModel( } fun requestCleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "requestCleanNodes") { val count = _nodesToDelete.value.size val message = getString(Res.string.clean_node_database_confirmation, count) alertManager.showAlert( @@ -93,7 +92,7 @@ class CleanNodeDatabaseViewModel( * them. */ fun cleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "cleanNodes") { val nodeNums = _nodesToDelete.value.map { it.num } cleanNodeDatabaseUseCase.cleanNodes(nodeNums) // Clear the list after deletion or if it was empty diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index 0ff5326fc..fe555abf5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -19,31 +19,18 @@ package org.meshtastic.feature.settings.radio import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.AdminPanelSettings -import androidx.compose.material.icons.rounded.AppSettingsAlt -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.CleaningServices -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.PowerSettingsNew -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Restore -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.Storage -import androidx.compose.material.icons.rounded.SystemUpdate -import androidx.compose.material.icons.rounded.Upload import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.navigation.FirmwareRoutes +import org.jetbrains.compose.resources.vectorResource +import org.meshtastic.core.navigation.FirmwareRoute import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration import org.meshtastic.core.resources.advanced_title @@ -54,6 +41,10 @@ import org.meshtastic.core.resources.device_configuration import org.meshtastic.core.resources.export_configuration import org.meshtastic.core.resources.factory_reset import org.meshtastic.core.resources.firmware_update_title +import org.meshtastic.core.resources.ic_power_settings_new +import org.meshtastic.core.resources.ic_restart_alt +import org.meshtastic.core.resources.ic_restore +import org.meshtastic.core.resources.ic_storage import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.message_device_managed import org.meshtastic.core.resources.module_settings @@ -62,6 +53,16 @@ import org.meshtastic.core.resources.radio_configuration import org.meshtastic.core.resources.reboot import org.meshtastic.core.resources.shutdown import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.AdminPanelSettings +import org.meshtastic.core.ui.icon.AppSettingsAlt +import org.meshtastic.core.ui.icon.BugReport +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.CleaningServices +import org.meshtastic.core.ui.icon.Download +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Settings +import org.meshtastic.core.ui.icon.SystemUpdate +import org.meshtastic.core.ui.icon.Upload import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.navigation.ConfigRoute @@ -101,7 +102,13 @@ private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClic ManagedMessage() } ConfigRoute.radioConfigRoutes.forEach { - ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) } + ListItem( + text = stringResource(it.title), + leadingIcon = it.icon?.let { res -> vectorResource(res) }, + enabled = enabled, + ) { + onRouteClick(it) + } } } } @@ -114,11 +121,11 @@ private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate } ListItem( text = stringResource(Res.string.device_configuration), - leadingIcon = Icons.Rounded.AppSettingsAlt, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.AppSettingsAlt, + trailingIcon = MeshtasticIcons.ChevronRight, enabled = enabled, ) { - onNavigate(SettingsRoutes.DeviceConfiguration) + onNavigate(SettingsRoute.DeviceConfiguration) } } } @@ -131,11 +138,11 @@ private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNaviga } ListItem( text = stringResource(Res.string.module_settings), - leadingIcon = Icons.Rounded.Settings, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.Settings, + trailingIcon = MeshtasticIcons.ChevronRight, enabled = enabled, ) { - onNavigate(SettingsRoutes.ModuleConfiguration) + onNavigate(SettingsRoute.ModuleConfiguration) } } } @@ -149,13 +156,13 @@ private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: ListItem( text = stringResource(Res.string.import_configuration), - leadingIcon = Icons.Rounded.Download, + leadingIcon = MeshtasticIcons.Download, enabled = enabled, onClick = onImport, ) ListItem( text = stringResource(Res.string.export_configuration), - leadingIcon = Icons.Rounded.Upload, + leadingIcon = MeshtasticIcons.Upload, enabled = enabled, onClick = onExport, ) @@ -167,14 +174,14 @@ private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) ExpressiveSection(title = stringResource(Res.string.administration)) { ListItem( text = stringResource(Res.string.administration), - leadingIcon = Icons.Rounded.AdminPanelSettings, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.AdminPanelSettings, + trailingIcon = MeshtasticIcons.ChevronRight, leadingIconTint = MaterialTheme.colorScheme.error, textColor = MaterialTheme.colorScheme.error, trailingIconTint = MaterialTheme.colorScheme.error, enabled = enabled, ) { - onNavigate(SettingsRoutes.Administration) + onNavigate(SettingsRoute.Administration) } } } @@ -189,33 +196,33 @@ private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: if (isOtaCapable) { ListItem( text = stringResource(Res.string.firmware_update_title), - leadingIcon = Icons.Rounded.SystemUpdate, + leadingIcon = MeshtasticIcons.SystemUpdate, enabled = enabled, - onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) }, + onClick = { onNavigate(FirmwareRoute.FirmwareUpdate) }, ) } ListItem( text = stringResource(Res.string.clean_node_database_title), - leadingIcon = Icons.Rounded.CleaningServices, + leadingIcon = MeshtasticIcons.CleaningServices, enabled = enabled, - onClick = { onNavigate(SettingsRoutes.CleanNodeDb) }, + onClick = { onNavigate(SettingsRoute.CleanNodeDb) }, ) ListItem( text = stringResource(Res.string.debug_panel), - leadingIcon = Icons.Rounded.BugReport, + leadingIcon = MeshtasticIcons.BugReport, enabled = enabled, - onClick = { onNavigate(SettingsRoutes.DebugPanel) }, + onClick = { onNavigate(SettingsRoute.DebugPanel) }, ) } } -enum class AdminRoute(val icon: ImageVector, val title: StringResource) { - REBOOT(Icons.Rounded.RestartAlt, Res.string.reboot), - SHUTDOWN(Icons.Rounded.PowerSettingsNew, Res.string.shutdown), - FACTORY_RESET(Icons.Rounded.Restore, Res.string.factory_reset), - NODEDB_RESET(Icons.Rounded.Storage, Res.string.nodedb_reset), +enum class AdminRoute(val icon: DrawableResource, val title: StringResource) { + REBOOT(Res.drawable.ic_restart_alt, Res.string.reboot), + SHUTDOWN(Res.drawable.ic_power_settings_new, Res.string.shutdown), + FACTORY_RESET(Res.drawable.ic_restore, Res.string.factory_reset), + NODEDB_RESET(Res.drawable.ic_storage, Res.string.nodedb_reset), } @Composable 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 dadc165dd..c59f00b56 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -20,9 +20,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -32,7 +34,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -44,6 +46,8 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -53,6 +57,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository @@ -62,6 +67,7 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage @@ -125,8 +131,9 @@ open class RadioConfigViewModel( private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, + private val mqttManager: MqttManager, ) : ViewModel() { - var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed + val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { toggleAnalyticsUseCase() @@ -138,6 +145,41 @@ open class RadioConfigViewModel( toggleHomoglyphEncodingUseCase() } + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + + private val _mqttProbeStatus = MutableStateFlow(null) + + /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ + val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() + + private var probeJob: Job? = null + + /** + * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting + * a new one. Result is exposed via [mqttProbeStatus]. + */ + fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) { + probeJob?.cancel() + _mqttProbeStatus.value = MqttProbeStatus.Probing + probeJob = + viewModelScope.launch { + val result = + runCatching { mqttManager.probe(address, tlsEnabled, username, password) } + .getOrElse { e -> + Logger.w(e) { "MQTT probe threw" } + MqttProbeStatus.Other(message = e.message) + } + _mqttProbeStatus.value = result + } + } + + /** Clear the latest probe result (e.g. when the user edits the address). */ + fun clearMqttProbeStatus() { + probeJob?.cancel() + _mqttProbeStatus.value = null + } + private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) fun initDestNum(id: Int?) { @@ -155,7 +197,7 @@ open class RadioConfigViewModel( val radioConfigState: StateFlow = _radioConfigState fun setPreserveFavorites(preserveFavorites: Boolean) { - viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } + _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } private val _currentDeviceProfile = MutableStateFlow(DeviceProfile()) @@ -242,7 +284,7 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setOwner") { _radioConfigState.update { it.copy(userConfig = user) } val packetId = radioConfigUseCase.setOwner(destNum, user) registerRequestId(packetId) @@ -252,14 +294,14 @@ open class RadioConfigViewModel( fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> - viewModelScope.launch { + safeLaunch(tag = "setRemoteChannel") { val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) registerRequestId(packetId) } } if (destNum == myNodeNum) { - viewModelScope.launch { + safeLaunch(tag = "migrateChannels") { packetRepository.migrateChannelsByPSK(old, new) radioConfigRepository.replaceAllSettings(new) } @@ -269,7 +311,7 @@ open class RadioConfigViewModel( fun setConfig(config: Config) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setConfig") { _radioConfigState.update { state -> state.copy( radioConfig = @@ -293,7 +335,7 @@ open class RadioConfigViewModel( @Suppress("CyclomaticComplexMethod") fun setModuleConfig(config: ModuleConfig) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setModuleConfig") { _radioConfigState.update { state -> state.copy( moduleConfig = @@ -326,13 +368,13 @@ open class RadioConfigViewModel( fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) } + safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } } fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) } + safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } } private fun sendAdminRequest(destNum: Int) { @@ -343,7 +385,7 @@ open class RadioConfigViewModel( when (route) { AdminRoute.REBOOT.name -> - viewModelScope.launch { + safeLaunch(tag = "reboot") { val packetId = adminActionsUseCase.reboot(destNum) registerRequestId(packetId) } @@ -352,7 +394,7 @@ open class RadioConfigViewModel( if (metadata?.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { - viewModelScope.launch { + safeLaunch(tag = "shutdown") { val packetId = adminActionsUseCase.shutdown(destNum) registerRequestId(packetId) } @@ -360,13 +402,13 @@ open class RadioConfigViewModel( } AdminRoute.FACTORY_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "factoryReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.factoryReset(destNum, isLocal) registerRequestId(packetId) } AdminRoute.NODEDB_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "nodedbReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) registerRequestId(packetId) @@ -376,55 +418,43 @@ open class RadioConfigViewModel( fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) } + safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } } fun removeFixedPosition() { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) } + safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } } - fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { - viewModelScope.launch { - try { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } catch (ex: Exception) { - Logger.e { "Import DeviceProfile error: ${ex.message}" } + fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { + safeLaunch(tag = "importProfile") { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } + } + profile?.let { onResult(it) } + } + } + + fun exportProfile(uri: CommonUri, profile: DeviceProfile) { + safeLaunch(tag = "exportProfile") { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } } } } - fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write file error: ${ex.message}" } - } - } - } - - fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write security keys JSON error: ${ex.message}" } + fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { + safeLaunch(tag = "exportSecurityConfig") { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } } } } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } fun clearPacketResponse() { @@ -439,17 +469,17 @@ open class RadioConfigViewModel( when (route) { ConfigRoute.USER -> - viewModelScope.launch { + safeLaunch(tag = "getOwner") { val packetId = radioConfigUseCase.getOwner(destNum) registerRequestId(packetId) } ConfigRoute.CHANNELS -> { - viewModelScope.launch { + safeLaunch(tag = "getChannel0") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } - viewModelScope.launch { + safeLaunch(tag = "getLoraConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) registerRequestId(packetId) } @@ -458,7 +488,7 @@ open class RadioConfigViewModel( } is AdminRoute -> { - viewModelScope.launch { + safeLaunch(tag = "getSessionKeyConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) registerRequestId(packetId) @@ -468,18 +498,18 @@ open class RadioConfigViewModel( is ConfigRoute -> { if (route == ConfigRoute.LORA) { - viewModelScope.launch { + safeLaunch(tag = "getChannel0ForLora") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } } if (route == ConfigRoute.NETWORK) { - viewModelScope.launch { + safeLaunch(tag = "getConnectionStatus") { val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getConfig") { val packetId = radioConfigUseCase.getConfig(destNum, route.type) registerRequestId(packetId) } @@ -487,18 +517,18 @@ open class RadioConfigViewModel( is ModuleRoute -> { if (route == ModuleRoute.CANNED_MESSAGE) { - viewModelScope.launch { + safeLaunch(tag = "getCannedMessages") { val packetId = radioConfigUseCase.getCannedMessages(destNum) registerRequestId(packetId) } } if (route == ModuleRoute.EXT_NOTIFICATION) { - viewModelScope.launch { + safeLaunch(tag = "getRingtone") { val packetId = radioConfigUseCase.getRingtone(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getModuleConfig") { val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type) registerRequestId(packetId) } @@ -568,7 +598,7 @@ open class RadioConfigViewModel( } val requestTimeout = 30.seconds - viewModelScope.launch { + safeLaunch(tag = "requestTimeout") { delay(requestTimeout) if (requestIds.value.contains(packetId)) { requestIds.update { it.apply { remove(packetId) } } @@ -585,7 +615,12 @@ open class RadioConfigViewModel( val route = radioConfigState.value.route when (result) { - is RadioResponseResult.Error -> sendError(result.message) + is RadioResponseResult.Error -> { + sendError(result.message) + // Abort the AdminRoute flow — do not fire the destructive action + // (reboot/shutdown/factory_reset) if the metadata preflight failed. + return + } is RadioResponseResult.Success -> { if (route.isEmpty()) { val data = packet.decoded!! @@ -623,7 +658,7 @@ open class RadioConfigViewModel( val index = response.index if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel - viewModelScope.launch { + safeLaunch(tag = "getNextChannel") { val packetId = radioConfigUseCase.getChannel(destNum, index + 1) registerRequestId(packetId) } @@ -705,6 +740,12 @@ open class RadioConfigViewModel( } } + // Routing ACKs (Success) share the same request_id as the upcoming ADMIN_APP response. + // Removing the id here would cause the actual admin response to be silently dropped, + // because processRadioResponseUseCase checks `request_id in requestIds`. + // The Success branch already handles its own id removal when route is empty (set flow). + if (result is RadioResponseResult.Success) return + if (AdminRoute.entries.any { it.name == route }) { sendAdminRequest(destNum) } 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 b50a8e312..885e64219 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Add import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold @@ -64,6 +62,8 @@ import org.meshtastic.core.ui.component.PreferenceFooter import org.meshtastic.core.ui.component.dragContainer import org.meshtastic.core.ui.component.dragDropItemsIndexed import org.meshtastic.core.ui.component.rememberDragDropState +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.ResponseState import org.meshtastic.feature.settings.radio.channel.component.ChannelCard @@ -113,9 +113,9 @@ private fun ChannelConfigScreen( onPositiveClicked: (List) -> Unit, ) { val primarySettings = settingsList.getOrNull(0) ?: return - val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } - val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } - val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) } + val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name } + val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) } + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -141,7 +141,7 @@ private fun ChannelConfigScreen( if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( - channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() }, + channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(), modemPresetName = modemPresetName, onAddClick = { if (settingsListInput.size > index) { @@ -182,7 +182,7 @@ private fun ChannelConfigScreen( }, modifier = Modifier.padding(16.dp), ) { - Icon(Icons.TwoTone.Add, stringResource(Res.string.add)) + Icon(MeshtasticIcons.Add, stringResource(Res.string.add)) } } }, 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 55ca713fe..8c7386db5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -30,9 +30,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.twotone.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -88,6 +85,9 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.component.PreferenceFooter import org.meshtastic.core.ui.component.QrDialog +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.QrCode import org.meshtastic.core.ui.qr.ScannedQrCodeDialog import org.meshtastic.core.ui.util.rememberQrCodePainter import org.meshtastic.core.ui.util.rememberShowToastResource @@ -124,7 +124,7 @@ fun ChannelScreen( val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) } - var showResetDialog by remember { mutableStateOf(false) } + var showResetDialog by rememberSaveable { mutableStateOf(false) } var shouldAddChannelsState by remember { mutableStateOf(true) } @@ -211,7 +211,7 @@ fun ChannelScreen( requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) } - var showShareDialog by remember { mutableStateOf(false) } + var showShareDialog by rememberSaveable { mutableStateOf(false) } if (showShareDialog) { ChannelShareDialog( @@ -353,7 +353,7 @@ private fun ChannelListView( second = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Button(onClick = onClickShare, modifier = Modifier.padding(16.dp), enabled = enabled) { - Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = null) + Icon(imageVector = MeshtasticIcons.QrCode, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(Res.string.generate_qr_code)) } @@ -378,7 +378,7 @@ private fun ModemPresetInfo(modemPresetName: String, onClick: () -> Unit) { } Spacer(modifier = Modifier.width(16.dp)) Icon( - imageVector = Icons.Rounded.ChevronRight, + imageVector = MeshtasticIcons.ChevronRight, contentDescription = stringResource(Res.string.navigate_into_label), modifier = Modifier.padding(end = 16.dp), ) 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 9966ca24e..8ec5f593e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -16,28 +16,29 @@ */ 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 import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.ChannelsRoutes +import org.meshtastic.core.navigation.ChannelsRoute import org.meshtastic.feature.settings.radio.RadioConfigViewModel -/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */ +/** Navigation graph for for the top level ChannelScreen - [ChannelsRoute.Channels]. */ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { - entry { + entry { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } - entry { + entry { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt index 71dd10fe2..b01809291 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt @@ -20,18 +20,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.delete import org.meshtastic.core.ui.component.ChannelItem import org.meshtastic.core.ui.component.SecurityIcon +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config @@ -48,21 +49,21 @@ internal fun ChannelCard( ) = ChannelItem(index = index, title = title, enabled = enabled, onClick = onEditClick) { if (sharesLocation) { Icon( - imageVector = ChannelIcons.LOCATION.icon, + imageVector = vectorResource(ChannelIcons.LOCATION.icon), contentDescription = stringResource(ChannelIcons.LOCATION.descriptionResId), modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), ) } if (channelSettings.uplink_enabled) { Icon( - imageVector = ChannelIcons.UPLINK.icon, + imageVector = vectorResource(ChannelIcons.UPLINK.icon), contentDescription = stringResource(ChannelIcons.UPLINK.descriptionResId), modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), ) } if (channelSettings.downlink_enabled) { Icon( - imageVector = ChannelIcons.DOWNLINK.icon, + imageVector = vectorResource(ChannelIcons.DOWNLINK.icon), contentDescription = stringResource(ChannelIcons.DOWNLINK.descriptionResId), modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), ) @@ -71,7 +72,7 @@ internal fun ChannelCard( Spacer(modifier = Modifier.width(10.dp)) IconButton(onClick = { onDeleteClick() }) { Icon( - imageVector = Icons.TwoTone.Close, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.delete), modifier = Modifier.wrapContentSize(), ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt index dd51cd82d..99085ec1b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt @@ -24,11 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CloudUpload -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -36,15 +31,19 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +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.model.Capabilities import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_features import org.meshtastic.core.resources.downlink_enabled import org.meshtastic.core.resources.downlink_feature_description +import org.meshtastic.core.resources.ic_cloud_download +import org.meshtastic.core.resources.ic_cloud_upload +import org.meshtastic.core.resources.ic_location_on import org.meshtastic.core.resources.icon_meanings import org.meshtastic.core.resources.info import org.meshtastic.core.resources.location_sharing @@ -59,6 +58,8 @@ import org.meshtastic.core.resources.security_icon_help_dismiss import org.meshtastic.core.resources.uplink_enabled import org.meshtastic.core.resources.uplink_feature_description import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.Info +import org.meshtastic.core.ui.icon.MeshtasticIcons @Composable internal fun ChannelLegend(onClick: () -> Unit) { @@ -67,7 +68,7 @@ internal fun ChannelLegend(onClick: () -> Unit) { horizontalArrangement = Arrangement.SpaceEvenly, ) { Row { - Icon(imageVector = Icons.Filled.Info, contentDescription = stringResource(Res.string.info)) + Icon(imageVector = MeshtasticIcons.Info, contentDescription = stringResource(Res.string.info)) Text( text = stringResource(Res.string.primary), color = MaterialTheme.colorScheme.primary, @@ -83,22 +84,22 @@ internal fun ChannelLegend(onClick: () -> Unit) { } internal enum class ChannelIcons( - val icon: ImageVector, + val icon: DrawableResource, val descriptionResId: StringResource, val additionalInfoResId: StringResource, ) { LOCATION( - icon = Icons.Filled.LocationOn, + icon = Res.drawable.ic_location_on, descriptionResId = Res.string.location_sharing, additionalInfoResId = Res.string.periodic_position_broadcast, ), UPLINK( - icon = Icons.Filled.CloudUpload, + icon = Res.drawable.ic_cloud_upload, descriptionResId = Res.string.uplink_enabled, additionalInfoResId = Res.string.uplink_feature_description, ), DOWNLINK( - icon = Icons.Filled.CloudDownload, + icon = Res.drawable.ic_cloud_download, descriptionResId = Res.string.downlink_enabled, additionalInfoResId = Res.string.downlink_feature_description, ), @@ -157,7 +158,7 @@ private fun IconDefinitions() { Text(text = stringResource(Res.string.icon_meanings), style = MaterialTheme.typography.titleLarge) ChannelIcons.entries.forEach { icon -> Row(verticalAlignment = Alignment.CenterVertically) { - Icon(imageVector = icon.icon, contentDescription = stringResource(icon.descriptionResId)) + Icon(imageVector = vectorResource(icon.icon), contentDescription = stringResource(icon.descriptionResId)) Column(modifier = Modifier.padding(start = 16.dp)) { Text(text = stringResource(icon.descriptionResId), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(icon.additionalInfoResId), style = MaterialTheme.typography.bodyMedium) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt index 202cacd22..fed34368d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.channel_name import org.meshtastic.core.resources.default_ import org.meshtastic.core.resources.downlink_enabled +import org.meshtastic.core.resources.psk import org.meshtastic.core.resources.save import org.meshtastic.core.resources.uplink_enabled import org.meshtastic.core.ui.component.EditBase64Preference @@ -99,7 +100,7 @@ fun EditChannelDialog( ) EditBase64Preference( - title = "PSK", + title = stringResource(Res.string.psk), value = channelInput.psk, enabled = true, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), 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 ee2dc19fb..a614c1f99 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -25,9 +25,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material3.AlertDialog import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -62,6 +59,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.button_gpio import org.meshtastic.core.resources.buzzer_gpio import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.clear_time_zone import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary @@ -109,7 +107,9 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.InsetDivider import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.Close import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PhoneAndroid import org.meshtastic.core.ui.icon.role import org.meshtastic.core.ui.util.annotatedStringFromHtml import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -270,7 +270,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, trailingIcon = { IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { - Icon(imageVector = Icons.Rounded.Clear, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.clear_time_zone), + ) } }, ) @@ -283,7 +286,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit shape = RectangleShape, onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) }, ) { - Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.PhoneAndroid, + contentDescription = stringResource(Res.string.config_device_use_phone_tz), + ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index e4f91ece6..f57306799 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val primarySettings = state.channelList.getOrNull(0) ?: return val formState = rememberConfigState(initialValue = loraConfig) - val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) } + val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) } val focusManager = LocalFocusManager.current RadioConfigScreenList( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 8039dc37d..2646b20cb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -73,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), + text = MetricFormatter.percent(progress * PERCENTAGE_FACTOR, decimalPlaces = 0), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 0427f9520..e1f407679 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -18,17 +18,37 @@ package org.meshtastic.feature.settings.radio.component +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -38,6 +58,23 @@ import org.meshtastic.core.resources.map_reporting import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.mqtt_config import org.meshtastic.core.resources.mqtt_enabled +import org.meshtastic.core.resources.mqtt_probe_dns_failure +import org.meshtastic.core.resources.mqtt_probe_other_failure +import org.meshtastic.core.resources.mqtt_probe_rejected +import org.meshtastic.core.resources.mqtt_probe_running +import org.meshtastic.core.resources.mqtt_probe_success +import org.meshtastic.core.resources.mqtt_probe_success_with_info +import org.meshtastic.core.resources.mqtt_probe_tcp_failure +import org.meshtastic.core.resources.mqtt_probe_timeout +import org.meshtastic.core.resources.mqtt_probe_tls_failure +import org.meshtastic.core.resources.mqtt_status_connected +import org.meshtastic.core.resources.mqtt_status_connecting +import org.meshtastic.core.resources.mqtt_status_disconnected +import org.meshtastic.core.resources.mqtt_status_disconnected_with_reason +import org.meshtastic.core.resources.mqtt_status_inactive +import org.meshtastic.core.resources.mqtt_status_reconnecting +import org.meshtastic.core.resources.mqtt_status_reconnecting_with_attempt +import org.meshtastic.core.resources.mqtt_test_connection import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -54,6 +91,8 @@ import org.meshtastic.proto.ModuleConfig fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() + val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle() + val probeStatus by viewModel.mqttProbeStatus.collectAsStateWithLifecycle() val destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -86,6 +125,8 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { viewModel.setModuleConfig(config) }, ) { + item { MqttStatusRow(mqttProxyState) } + item { TitledCard(title = stringResource(Res.string.mqtt_config)) { SwitchPreference( @@ -96,16 +137,13 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.address), - value = formState.value.address, - maxSize = 63, // address max_size:64 + MqttAddressAndProbe( enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(address = it) }, + formState = formState, + probeStatus = probeStatus, + focusManager = focusManager, + onProbe = viewModel::probeMqttConnection, + onClearProbe = viewModel::clearMqttProbeStatus, ) HorizontalDivider() EditTextPreference( @@ -210,3 +248,129 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } private const val MIN_INTERVAL_SECS = 3600 + +private val AmberColor = Color(0xFFFFA000) +private val GreenColor = Color(0xFF4CAF50) + +@Composable +private fun MqttStatusRow(state: MqttConnectionState) { + val (label, color) = + when (state) { + is MqttConnectionState.Inactive -> + stringResource(Res.string.mqtt_status_inactive) to MaterialTheme.colorScheme.outline + is MqttConnectionState.Disconnected -> { + val text = + state.reason?.let { stringResource(Res.string.mqtt_status_disconnected_with_reason, it) } + ?: stringResource(Res.string.mqtt_status_disconnected) + text to MaterialTheme.colorScheme.error + } + is MqttConnectionState.Connecting -> stringResource(Res.string.mqtt_status_connecting) to AmberColor + is MqttConnectionState.Connected -> stringResource(Res.string.mqtt_status_connected) to GreenColor + is MqttConnectionState.Reconnecting -> { + val err = state.lastError + val text = + if (err != null) { + stringResource(Res.string.mqtt_status_reconnecting_with_attempt, state.attempt, err) + } else { + stringResource(Res.string.mqtt_status_reconnecting) + } + text to AmberColor + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(color)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun MqttAddressAndProbe( + enabled: Boolean, + formState: ConfigState, + probeStatus: MqttProbeStatus?, + focusManager: FocusManager, + onProbe: (address: String, tlsEnabled: Boolean, username: String, password: String) -> Unit, + onClearProbe: () -> Unit, +) { + EditTextPreference( + title = stringResource(Res.string.address), + value = formState.value.address, + maxSize = 63, // address max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + formState.value = formState.value.copy(address = it) + onClearProbe() + }, + ) + HorizontalDivider() + MqttProbeRow( + enabled = enabled && formState.value.address.isNotBlank(), + status = probeStatus, + onTestClick = { + focusManager.clearFocus() + onProbe( + formState.value.address, + formState.value.tls_enabled, + formState.value.username, + formState.value.password, + ) + }, + ) +} + +@Composable +private fun MqttProbeRow(enabled: Boolean, status: MqttProbeStatus?, onTestClick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Button(onClick = onTestClick, enabled = enabled && status !is MqttProbeStatus.Probing) { + Text(stringResource(Res.string.mqtt_test_connection)) + } + val (probeText, probeColor) = status.toLabel() ?: return@Row + Text(text = probeText, style = MaterialTheme.typography.bodySmall, color = probeColor) + } + } +} + +@Composable +private fun MqttProbeStatus?.toLabel(): Pair? = when (this) { + null -> null + is MqttProbeStatus.Probing -> + stringResource(Res.string.mqtt_probe_running) to MaterialTheme.colorScheme.onSurfaceVariant + is MqttProbeStatus.Success -> { + val text = + serverInfo?.let { stringResource(Res.string.mqtt_probe_success_with_info, it) } + ?: stringResource(Res.string.mqtt_probe_success) + text to GreenColor + } + is MqttProbeStatus.Rejected -> + stringResource(Res.string.mqtt_probe_rejected, reason ?: reasonCode.toString()) to + MaterialTheme.colorScheme.error + is MqttProbeStatus.DnsFailure -> + stringResource(Res.string.mqtt_probe_dns_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TcpFailure -> + stringResource(Res.string.mqtt_probe_tcp_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TlsFailure -> + stringResource(Res.string.mqtt_probe_tls_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Timeout -> + stringResource(Res.string.mqtt_probe_timeout, timeoutMs.toInt()) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Other -> + stringResource(Res.string.mqtt_probe_other_failure) to MaterialTheme.colorScheme.error +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index 4e471be24..584f8eedc 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,6 +22,8 @@ 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 @@ -46,6 +48,7 @@ import org.meshtastic.core.resources.config_network_eth_enabled_summary import org.meshtastic.core.resources.config_network_udp_enabled_summary import org.meshtastic.core.resources.config_network_wifi_enabled_summary import org.meshtastic.core.resources.connection_status +import org.meshtastic.core.resources.dns import org.meshtastic.core.resources.error import org.meshtastic.core.resources.ethernet_config import org.meshtastic.core.resources.ethernet_enabled @@ -219,12 +222,19 @@ 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() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(48.dp), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(mediumHeight), enabled = state.connected, ) { - Text(text = stringResource(Res.string.wifi_qr_code_scan)) + Text( + text = stringResource(Res.string.wifi_qr_code_scan), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } } } @@ -271,29 +281,31 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO SwitchPreference( title = stringResource(Res.string.udp_enabled), summary = stringResource(Res.string.config_network_udp_enabled_summary), - checked = formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - onCheckedChange = { - formState.value = - formState.value.copy( - address_mode = - if (it) { - Config.NetworkConfig.AddressMode.STATIC - } else { - Config.NetworkConfig.AddressMode.DHCP - }, - ) + checked = + formState.value.enabled_protocols and Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value != + 0, + onCheckedChange = { enabled -> + val flags = + if (enabled) { + formState.value.enabled_protocols or + Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value + } else { + formState.value.enabled_protocols and + Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value.inv() + } + formState.value = formState.value.copy(enabled_protocols = flags) }, enabled = state.connected, ) + HorizontalDivider() + DropDownPreference( + title = stringResource(Res.string.ipv4_mode), + enabled = state.connected, + selectedItem = formState.value.address_mode, + onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, + itemLabel = { it.name }, + ) if (formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC) { - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.ipv4_mode), - enabled = state.connected, - selectedItem = formState.value.address_mode, - onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, - itemLabel = { it.name }, - ) HorizontalDivider() val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config() EditIPv4Preference( @@ -323,6 +335,14 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO }, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), ) + HorizontalDivider() + EditIPv4Preference( + title = stringResource(Res.string.dns), + value = ipv4.dns, + enabled = state.connected, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) }, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) } } } 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 fe9675e6d..fa6d9a8fb 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,9 +24,10 @@ 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 @@ -37,14 +38,22 @@ import androidx.compose.ui.unit.dp @Composable fun NodeActionButton( - modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp), + modifier: Modifier = Modifier, title: String, enabled: Boolean, icon: ImageVector? = null, iconTint: Color? = null, onClick: () -> Unit, ) { - Button(onClick = { onClick() }, enabled = enabled, modifier = modifier) { + @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)), + ) { Row(verticalAlignment = Alignment.CenterVertically) { if (icon != null) { Icon( @@ -55,7 +64,7 @@ fun NodeActionButton( ) Spacer(modifier = Modifier.width(8.dp)) } - Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(text = title, style = ButtonDefaults.textStyleFor(mediumHeight), 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 ec8cb798d..c319c4f7f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -22,9 +22,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -38,7 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -46,6 +43,9 @@ import org.meshtastic.core.resources.delivery_confirmed import org.meshtastic.core.resources.delivery_confirmed_reboot_warning import org.meshtastic.core.resources.error import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.Error +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Success import org.meshtastic.feature.settings.radio.ResponseState private const val AUTO_DISMISS_DELAY_MS = 1500L @@ -111,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = formatString("%.0f%%", progress * 100f), + text = MetricFormatter.percent(progress * 100f, decimalPlaces = 0), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) @@ -135,7 +135,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) @Composable private fun SuccessContent() { Icon( - imageVector = Icons.Filled.CheckCircle, + imageVector = MeshtasticIcons.Success, contentDescription = null, modifier = Modifier.size(84.dp), tint = MaterialTheme.colorScheme.primary, @@ -158,7 +158,7 @@ private fun SuccessContent() { @Composable private fun ErrorContent(state: ResponseState.Error) { Icon( - imageVector = Icons.Filled.Error, + imageVector = MeshtasticIcons.Error, contentDescription = null, modifier = Modifier.size(84.dp), tint = MaterialTheme.colorScheme.error, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 94e25df9b..cbc09f1be 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Warning import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable @@ -63,6 +61,8 @@ import org.meshtastic.core.ui.component.EditListPreference import org.meshtastic.core.ui.component.MeshtasticResourceDialog import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Warning import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config import kotlin.random.Random @@ -150,7 +150,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un modifier = Modifier.padding(horizontal = 8.dp), title = stringResource(Res.string.regenerate_private_key), enabled = state.connected, - icon = Icons.TwoTone.Warning, + icon = MeshtasticIcons.Warning, onClick = { showKeyGenerationDialog = true }, ) ExportSecurityConfigButton( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt index 29f29e7eb..e5b527944 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt @@ -32,6 +32,8 @@ import org.meshtastic.core.resources.serial_baud_rate import org.meshtastic.core.resources.serial_config import org.meshtastic.core.resources.serial_enabled import org.meshtastic.core.resources.serial_mode +import org.meshtastic.core.resources.serial_rx_pin +import org.meshtastic.core.resources.serial_tx_pin import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.EditTextPreference @@ -78,7 +80,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) HorizontalDivider() EditTextPreference( - title = "RX", + title = stringResource(Res.string.serial_rx_pin), value = formState.value.rxd, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), @@ -86,7 +88,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) HorizontalDivider() EditTextPreference( - title = "TX", + title = stringResource(Res.string.serial_tx_pin), value = formState.value.txd, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt index f99b31055..29c0745ca 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt @@ -21,8 +21,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,6 +37,8 @@ import org.meshtastic.core.resources.send import org.meshtastic.core.resources.shutdown_node_name import org.meshtastic.core.resources.shutdown_warning import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Warning @Composable fun ShutdownConfirmationDialog( @@ -46,14 +46,15 @@ fun ShutdownConfirmationDialog( node: Node?, onDismiss: () -> Unit, isShutdown: Boolean = true, - icon: ImageVector? = Icons.Rounded.Warning, + icon: ImageVector? = null, onConfirm: () -> Unit, ) { val nodeLongName = node?.user?.long_name ?: "Unknown Node" + val resolvedIcon = icon ?: MeshtasticIcons.Warning MeshtasticDialog( onDismiss = onDismiss, - icon = icon, + icon = resolvedIcon, title = title, text = { ShutdownDialogContent(nodeLongName = nodeLongName, isShutdown = isShutdown) }, confirmText = stringResource(Res.string.send), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt index a81867265..2c1b61216 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt @@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -38,6 +36,8 @@ import org.meshtastic.core.resources.status_message import org.meshtastic.core.resources.status_message_config import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.settings.radio.RadioConfigViewModel @Composable @@ -90,7 +90,7 @@ fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni if (formState.value.node_status.isNotEmpty()) { IconButton(onClick = { formState.value = formState.value.copy(node_status = "") }) { Icon( - imageVector = Icons.Default.Clear, + imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear), ) } 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 714513e7d..526bd63ef 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.settings.radio.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,6 +30,7 @@ import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.export_tak_data_package import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config import org.meshtastic.core.resources.tak_role @@ -42,6 +41,8 @@ import org.meshtastic.core.takserver.TAKDataPackageGenerator import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Share import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.tak.TakPermissionHandler import org.meshtastic.feature.settings.tak.rememberDataPackageExporter @@ -74,7 +75,10 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onBack = onBack, actions = { IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { - Icon(imageVector = Icons.Default.Share, contentDescription = "Export TAK Data Package") + Icon( + imageVector = MeshtasticIcons.Share, + contentDescription = stringResource(Res.string.export_tak_data_package), + ) } }, configState = formState, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/WarningDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/WarningDialog.kt index 6a3575a19..bdca0a46d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/WarningDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/WarningDialog.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.feature.settings.radio.component -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Warning import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.stringResource @@ -25,18 +23,22 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.send import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Warning @Composable fun WarningDialog( - icon: ImageVector? = Icons.Rounded.Warning, + icon: ImageVector? = null, title: String, text: @Composable () -> Unit = {}, onDismiss: () -> Unit, onConfirm: () -> Unit, ) { + val resolvedIcon = icon ?: MeshtasticIcons.Warning + MeshtasticDialog( onDismiss = onDismiss, - icon = icon, + icon = resolvedIcon, title = title, text = text, confirmText = stringResource(Res.string.send), diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 64eab2f80..0ba5c3a79 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -96,6 +97,7 @@ class SettingsViewModelTest { val uiPrefs = appPreferences.ui val setThemeUseCase = SetThemeUseCase(uiPrefs) + val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs) val setLocaleUseCase = SetLocaleUseCase(uiPrefs) val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) @@ -116,6 +118,7 @@ class SettingsViewModelTest { meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, setThemeUseCase = setThemeUseCase, + setContrastLevelUseCase = setContrastLevelUseCase, setLocaleUseCase = setLocaleUseCase, setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, setProvideLocationUseCase = setProvideLocationUseCase, diff --git a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt similarity index 71% rename from feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index b768528e9..83bcddee1 100644 --- a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -23,17 +23,14 @@ 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 @@ -42,18 +39,15 @@ 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 org.robolectric.annotation.Config +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34]) +@OptIn(ExperimentalTestApi::class) class DebugSearchTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun debugSearchBar_showsPlaceholder() { + fun debugSearchBar_showsPlaceholder() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState(), onSearchTextChange = {}, @@ -62,13 +56,13 @@ class DebugSearchTest { onClearSearch = {}, ) } - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_showsClearButtonWhenTextEntered() { + fun debugSearchBar_showsClearButtonWhenTextEntered() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { var searchText by remember { mutableStateOf("test") } DebugSearchBar( searchState = SearchState(searchText = searchText), @@ -78,17 +72,17 @@ class DebugSearchTest { onClearSearch = { searchText = "" }, ) } - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_searchFor_showsArrowsClearAndValues() { + fun debugSearchBar_searchFor_showsArrowsClearAndValues() = runComposeUiTest { val searchText = "test" val matchCount = 3 val currentMatchIndex = 1 - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState( @@ -104,18 +98,18 @@ class DebugSearchTest { ) } // Check the match count display (e.g., '2/3') - composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() + onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() // Check the navigation arrows - composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed() + onNodeWithContentDescription("Previous match").assertIsDisplayed() + onNodeWithContentDescription("Next match").assertIsDisplayed() // Check the clear button - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed() } @Test - fun debugFilterBar_showsFilterButtonAndMenu() { + fun debugFilterBar_showsFilterButtonAndMenu() = runComposeUiTest { val filterLabel = getString(Res.string.debug_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } val presetFilters = listOf("Error", "Warning", "Info") @@ -138,13 +132,13 @@ class DebugSearchTest { ) } // The filter button should be visible - composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() + onNodeWithText(filterLabel).assertIsDisplayed() } @Test - fun debugFilterBar_addCustomFilter_displaysActiveFilter() { + fun debugFilterBar_addCustomFilter_displaysActiveFilter() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { @@ -162,18 +156,16 @@ class DebugSearchTest { ) } } - with(composeTestRule) { - onNodeWithText("Add custom filter").performTextInput("MyFilter") - onNodeWithContentDescription("Add filter").performClick() - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("MyFilter").assertIsDisplayed() - } + onNodeWithText("Add custom filter").performTextInput("MyFilter") + onNodeWithContentDescription("Add filter").performClick() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("MyFilter").assertIsDisplayed() } @Test - fun debugActiveFilters_clearAllFilters_removesFilters() { + fun debugActiveFilters_clearAllFilters_removesFilters() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf("A", "B")) } DebugActiveFilters( filterTexts = filterTexts, @@ -183,13 +175,13 @@ class DebugSearchTest { ) } // The active filters label and chips should be visible - composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() - composeTestRule.onNodeWithText("A").assertIsDisplayed() - composeTestRule.onNodeWithText("B").assertIsDisplayed() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("A").assertIsDisplayed() + onNodeWithText("B").assertIsDisplayed() // Click the clear all filters button - composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() + onNodeWithContentDescription("Clear all filters").performClick() // The filter chips should no longer be visible - composeTestRule.onNodeWithText("A").assertDoesNotExist() - composeTestRule.onNodeWithText("B").assertDoesNotExist() + onNodeWithText("A").assertDoesNotExist() + onNodeWithText("B").assertDoesNotExist() } } 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 007061d47..c1b7d8a9e 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -53,6 +53,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository @@ -99,6 +100,7 @@ class RadioConfigViewModelTest { private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill) private val locationService: LocationService = mock(MockMode.autofill) private val fileService: FileService = mock(MockMode.autofill) + private val mqttManager: MqttManager = mock(MockMode.autofill) private val uiPrefs: UiPrefs = mock(MockMode.autofill) private lateinit var viewModel: RadioConfigViewModel @@ -121,6 +123,9 @@ class RadioConfigViewModelTest { every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) + every { mqttManager.mqttConnectionState } returns + MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) + every { uiPrefs.showQuickChat } returns MutableStateFlow(false) viewModel = createViewModel() @@ -152,6 +157,7 @@ class RadioConfigViewModelTest { processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, + mqttManager = mqttManager, ) @Test @@ -233,13 +239,15 @@ class RadioConfigViewModelTest { } @Test - fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest { + fun `setResponseStateLoading for REBOOT calls useCase after config response`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success + // AdminRoute first sends a session key config request; the admin action fires + // only after the actual ConfigResponse (not a routing ACK / Success). + every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) viewModel = createViewModel() @@ -247,20 +255,22 @@ class RadioConfigViewModelTest { viewModel.setResponseStateLoading(AdminRoute.REBOOT) - // Emit a packet to trigger processPacketResponse -> sendAdminRequest + // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.reboot(123) } } @Test - fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest { + fun `setResponseStateLoading for FACTORY_RESET calls useCase after config response`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success + // AdminRoute first sends a session key config request; the admin action fires + // only after the actual ConfigResponse (not a routing ACK / Success). + every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) viewModel = createViewModel() @@ -268,7 +278,7 @@ class RadioConfigViewModelTest { viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET) - // Emit a packet to trigger processPacketResponse -> sendAdminRequest + // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.factoryReset(123, any()) } @@ -449,7 +459,6 @@ class RadioConfigViewModelTest { nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success viewModel = createViewModel() @@ -461,13 +470,16 @@ class RadioConfigViewModelTest { packetFlow.emit(MeshPacket()) viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN) - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success + // AdminRoute fires sendAdminRequest after receiving ConfigResponse (session key), + // not after a routing ACK (Success). + every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.shutdown(123) } // NODEDB_RESET everySuspend { adminActionsUseCase.nodedbReset(any(), any(), any()) } returns 42 viewModel.setResponseStateLoading(AdminRoute.NODEDB_RESET) + every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.nodedbReset(123, any(), any()) } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt similarity index 54% rename from feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 1f390e44e..cffeab006 100644 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -16,27 +16,24 @@ */ 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.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString 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 -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class EditDeviceProfileDialogTest { - @get:Rule val composeTestRule = createComposeRule() - private val title = "Export configuration" private val deviceProfile = DeviceProfile( @@ -46,61 +43,61 @@ class EditDeviceProfileDialogTest { fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138), ) - private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) = - composeTestRule.setContent { + @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 { EditDeviceProfileDialog( title = title, deviceProfile = deviceProfile, - onConfirm = onConfirm, - onDismiss = onDismiss, + onConfirm = {}, + onDismiss = { onDismissClicked = true }, ) } - @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() - } + // Click the "Cancel" button + onNodeWithText(getString(Res.string.cancel)).performClick() // Verify onDismiss is called - Assert.assertTrue(onDismissClicked) + assertTrue(onDismissClicked) } @Test - fun testEditDeviceProfileDialog_addChannels() { + fun testEditDeviceProfileDialog_addChannels() = runComposeUiTest { var actualDeviceProfile: DeviceProfile? = null - composeTestRule.apply { - testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it }) - - onNodeWithText(getString(Res.string.save)).performClick() + setContent { + EditDeviceProfileDialog( + title = title, + deviceProfile = deviceProfile, + onConfirm = { actualDeviceProfile = it }, + onDismiss = {}, + ) } + onNodeWithText(getString(Res.string.save)).performClick() + // Verify onConfirm is called with the correct DeviceProfile - Assert.assertEquals(deviceProfile, actualDeviceProfile) + assertEquals(deviceProfile, actualDeviceProfile) } } 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 new file mode 100644 index 000000000..42a67a6a0 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -0,0 +1,99 @@ +/* + * 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/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt similarity index 85% rename from feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename to feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt index 4b9cf369d..0a35599f5 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt +++ b/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt @@ -16,7 +16,7 @@ */ package org.meshtastic.feature.settings.navigation -import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.SettingsRoute actual fun getAboutLibrariesJson(): String = - SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" + SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" 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 b4b0fdee7..2e358a58c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -23,13 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.FormatPaint -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,13 +39,14 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.DatabaseConstants import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes -import org.meshtastic.core.navigation.WifiProvisionRoutes +import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.core.navigation.WifiProvisionRoute import org.meshtastic.core.resources.Res 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 @@ -66,7 +60,15 @@ import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.FormatPaint +import org.meshtastic.core.ui.icon.Info +import org.meshtastic.core.ui.icon.Language +import org.meshtastic.core.ui.icon.Memory +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection @@ -101,6 +103,7 @@ fun DesktopSettingsScreen( var showThemePickerDialog by remember { mutableStateOf(false) } var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showContrastPickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -108,6 +111,13 @@ fun DesktopSettingsScreen( ) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + if (showLanguagePickerDialog) { LanguagePickerDialog( onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) }, @@ -166,15 +176,23 @@ fun DesktopSettingsScreen( ExpressiveSection(title = stringResource(Res.string.app_settings)) { ListItem( text = stringResource(Res.string.theme), - leadingIcon = Icons.Rounded.FormatPaint, + leadingIcon = MeshtasticIcons.FormatPaint, trailingIcon = null, ) { showThemePickerDialog = true } + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + showContrastPickerDialog = true + } + ListItem( text = stringResource(Res.string.preferences_language), - leadingIcon = Icons.Rounded.Language, + leadingIcon = MeshtasticIcons.Language, trailingIcon = null, ) { showLanguagePickerDialog = true @@ -201,8 +219,8 @@ fun DesktopSettingsScreen( } ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { - ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) { - onNavigate(WifiProvisionRoutes.WifiProvision()) + ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) { + onNavigate(WifiProvisionRoute.WifiProvision()) } } @@ -219,7 +237,7 @@ fun DesktopSettingsScreen( appVersionName = settingsViewModel.appVersionName, excludedModulesUnlocked = excludedModulesUnlocked, onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() }, - onNavigateToAbout = { onNavigate(SettingsRoutes.About) }, + onNavigateToAbout = { onNavigate(SettingsRoute.About) }, ) } } @@ -237,8 +255,8 @@ private fun DesktopAppInfoSection( ExpressiveSection(title = stringResource(Res.string.info)) { ListItem( text = stringResource(Res.string.acknowledgements), - leadingIcon = Icons.Rounded.Info, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + leadingIcon = MeshtasticIcons.Info, + trailingIcon = MeshtasticIcons.ChevronRight, ) { onNavigateToAbout() } @@ -274,7 +292,7 @@ private fun DesktopAppVersionButton( ListItem( text = stringResource(Res.string.app_version), - leadingIcon = Icons.Rounded.Memory, + leadingIcon = MeshtasticIcons.Memory, supportingText = appVersionName, trailingIcon = null, ) { diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 5b63cc90a..a9a728559 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr if (directory != null && file != null) { val targetFile = File(directory, file) val data = dataPackageProvider() - withContext(Dispatchers.IO) { targetFile.writeBytes(data) } + withContext(ioDispatcher) { targetFile.writeBytes(data) } Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } } } diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt deleted file mode 100644 index 8ffb10fae..000000000 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.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.feature.settings - -import android.content.res.Configuration -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText -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.use_homoglyph_characters_encoding -import org.meshtastic.feature.settings.component.HomoglyphSetting -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.util.Locale - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class HomoglyphSettingTest { - - @get:Rule val composeTestRule = createComposeRule() - - @Test - fun homoglyphSetting_isVisible_forRussianLocale() { - val russianConfig = Configuration().apply { setLocale(Locale.forLanguageTag("ru")) } - - composeTestRule.setContent { - CompositionLocalProvider(LocalConfiguration provides russianConfig) { - HomoglyphSetting(homoglyphEncodingEnabled = false, onToggle = {}) - } - } - - val expectedText = getString(Res.string.use_homoglyph_characters_encoding) - composeTestRule.onNodeWithText(expectedText).assertIsDisplayed() - } - - @Test - fun homoglyphSetting_isNotVisible_forEnglishLocale() { - val englishConfig = Configuration().apply { setLocale(Locale.forLanguageTag("en")) } - - composeTestRule.setContent { - CompositionLocalProvider(LocalConfiguration provides englishConfig) { - HomoglyphSetting(homoglyphEncodingEnabled = false, onToggle = {}) - } - } - - val expectedText = getString(Res.string.use_homoglyph_characters_encoding) - composeTestRule.onNodeWithText(expectedText).assertDoesNotExist() - } -} diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index a11e4ee7d..3054da6df 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -23,6 +23,7 @@ plugins { android { namespace = "org.meshtastic.feature.widget" + resourcePrefix = "widget_" defaultConfig { minSdk = 26 } } @@ -33,7 +34,7 @@ dependencies { implementation(projects.core.resources) implementation(projects.core.repository) - implementation(libs.androidx.compose.ui) // LocalConfiguration, LocalDensity + implementation(libs.compose.multiplatform.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 415e0e11d..c6cef8aa3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt @@ -17,22 +17,48 @@ package org.meshtastic.feature.widget import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater +private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L + @Single -class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater { +class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) : + AppWidgetUpdater { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + // Observe state changes and trigger a widget re-render whenever the data changes. + // Glance compositions are ephemeral — the widget cannot self-update via collectAsState() + // alone, so we must call updateAll() externally to drive re-renders. + @OptIn(FlowPreview::class) + scope.launch { + stateProvider.state + .debounce(WIDGET_UPDATE_DEBOUNCE_MS) + .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) } + .collect { if (hasWidgetInstances()) updateAll() } + } + } + + private suspend fun hasWidgetInstances(): Boolean = + GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty() + override suspend fun updateAll() { - // Kickstart the widget composition. - // The widget internally uses collectAsState() and its own sampled StateFlow - // to drive updates automatically without excessive IPC and recreation. @Suppress("TooGenericExceptionCaught") try { LocalStatsWidget().updateAll(context) } catch (e: Exception) { - co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" } + Logger.e(e) { "Failed to update widgets" } } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 6f988f2db..099b24cc3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -132,11 +132,11 @@ class LocalStatsWidget : Scaffold( titleBar = { TitleBar( - startIcon = ImageProvider(R.drawable.app_icon), + startIcon = ImageProvider(R.drawable.widget_app_icon), title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( - imageProvider = ImageProvider(R.drawable.ic_refresh), + imageProvider = ImageProvider(R.drawable.widget_ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, @@ -297,7 +297,7 @@ class LocalStatsWidget : CircularProgressIndicator(modifier = GlanceModifier.size(24.dp)) } else { Image( - provider = ImageProvider(R.drawable.app_icon), + provider = ImageProvider(R.drawable.widget_app_icon), contentDescription = null, modifier = GlanceModifier.size(32.dp), ) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index 793482ba2..b8aca2664 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -26,14 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats @@ -79,11 +77,7 @@ data class LocalStatsWidgetUiState( ) @Single -class LocalStatsWidgetStateProvider( - nodeRepository: NodeRepository, - serviceRepository: ServiceRepository, - appWidgetUpdater: AppWidgetUpdater, -) { +class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -104,8 +98,6 @@ class LocalStatsWidgetStateProvider( .map { input -> mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } - .distinctUntilChanged() - .onEach { appWidgetUpdater.updateAll() } .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) private data class StateInput( diff --git a/feature/widget/src/main/res/drawable/app_icon.xml b/feature/widget/src/main/res/drawable/widget_app_icon.xml similarity index 100% rename from feature/widget/src/main/res/drawable/app_icon.xml rename to feature/widget/src/main/res/drawable/widget_app_icon.xml diff --git a/feature/widget/src/main/res/drawable/ic_refresh.xml b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml similarity index 100% rename from feature/widget/src/main/res/drawable/ic_refresh.xml rename to feature/widget/src/main/res/drawable/widget_ic_refresh.xml diff --git a/mesh_service_example/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml similarity index 87% rename from mesh_service_example/src/main/res/values/strings.xml rename to feature/widget/src/main/res/values/strings.xml index e194d4b9b..1e47c86ee 100644 --- a/mesh_service_example/src/main/res/values/strings.xml +++ b/feature/widget/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - - MeshServiceExample + Meshtastic diff --git a/feature/widget/src/main/res/xml/local_stats_widget_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml similarity index 95% rename from feature/widget/src/main/res/xml/local_stats_widget_info.xml rename to feature/widget/src/main/res/xml/widget_local_stats_info.xml index da9863cd9..6dde1ea1e 100644 --- a/feature/widget/src/main/res/xml/local_stats_widget_info.xml +++ b/feature/widget/src/main/res/xml/widget_local_stats_info.xml @@ -16,6 +16,7 @@ ~ along with this program. If not, see . --> = runCatching { + suspend fun connect(address: String? = null): Result = safeCatching { Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } val device = - withTimeout(SCAN_TIMEOUT_MS) { - scanner - .scan( - timeout = SCAN_TIMEOUT_MS.milliseconds, - serviceUuid = WIRELESS_SERVICE_UUID, - address = address, - ) - .first() + withTimeout(SCAN_TIMEOUT) { + scanner.scan(timeout = SCAN_TIMEOUT, 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_MS) + val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT) check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" } Logger.i { "$TAG: Connected. Discovering wireless service…" } @@ -130,7 +123,7 @@ class NymeaWifiService( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -145,7 +138,7 @@ class NymeaWifiService( * * Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0). */ - suspend fun scanNetworks(): Result> = runCatching { + suspend fun scanNetworks(): Result> = safeCatching { // Trigger scan sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN))) val scanAck = NymeaJson.decodeFromString(waitForResponse()) @@ -187,7 +180,7 @@ class NymeaWifiService( NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)), ) - return runCatching { + return safeCatching { sendCommand(json) val response = NymeaJson.decodeFromString(waitForResponse()) if (response.responseCode == RESPONSE_SUCCESS) { @@ -235,8 +228,8 @@ class NymeaWifiService( } } - /** 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() } + /** Wait up to [RESPONSE_TIMEOUT] for a complete JSON response from the notification channel. */ + private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT) { 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 472f1effe..a79d32b25 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt @@ -16,24 +16,25 @@ */ 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 -import org.meshtastic.core.navigation.WifiProvisionRoutes +import org.meshtastic.core.navigation.WifiProvisionRoute import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen /** * Registers the WiFi provisioning graph entries into the host navigation provider. * - * Both the graph sentinel ([WifiProvisionRoutes.WifiProvisionGraph]) and the primary screen - * ([WifiProvisionRoutes.WifiProvision]) navigate to the same composable so that the feature can be reached via either a + * Both the graph sentinel ([WifiProvisionRoute.WifiProvisionGraph]) and the primary screen + * ([WifiProvisionRoute.WifiProvision]) navigate to the same composable so that the feature can be reached via either a * top-level push or a deep-link graph push. */ fun EntryProviderScope.wifiProvisionGraph(backStack: NavBackStack) { - entry { - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }) + entry { + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } - entry { key -> - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address) + entry { key -> + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, address = key.address) } } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt index a2ad7cfe9..c2c39b3ca 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt @@ -21,9 +21,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Error import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -41,6 +38,9 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.wifi_provision_sending_credentials import org.meshtastic.core.resources.wifi_provision_status_applied import org.meshtastic.core.resources.wifi_provision_status_failed +import org.meshtastic.core.ui.icon.Error +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Success import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus /** Inline status card matching the web flasher's colored status feedback. */ @@ -86,9 +86,9 @@ private fun StatusIcon(provisionStatus: ProvisionStatus, isProvisioning: Boolean when { isProvisioning -> LoadingIndicator(modifier = Modifier.size(20.dp), color = tint) provisionStatus == ProvisionStatus.Success -> - Icon(Icons.Rounded.CheckCircle, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) + Icon(MeshtasticIcons.Success, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) provisionStatus == ProvisionStatus.Failed -> - Icon(Icons.Rounded.Error, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) + Icon(MeshtasticIcons.Error, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) } } 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 ced6d212c..397710fea 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -43,13 +43,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material.icons.rounded.VisibilityOff -import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -59,6 +52,7 @@ 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 @@ -82,6 +76,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -93,11 +88,13 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_network import org.meshtastic.core.resources.apply import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.img_mpwrd_logo +import org.meshtastic.core.resources.mpwrd_os import org.meshtastic.core.resources.password import org.meshtastic.core.resources.show_password import org.meshtastic.core.resources.wifi_provision_available_networks @@ -117,6 +114,13 @@ import org.meshtastic.core.resources.wifi_provision_ssid_label import org.meshtastic.core.resources.wifi_provision_ssid_placeholder import org.meshtastic.core.resources.wifi_provisioning import org.meshtastic.core.ui.component.AutoLinkText +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.Lock +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff +import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.wifiprovision.WifiProvisionError import org.meshtastic.feature.wifiprovision.WifiProvisionUiState import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase @@ -156,7 +160,7 @@ fun WifiProvisionScreen( title = { Text(stringResource(Res.string.wifi_provisioning)) }, navigationIcon = { IconButton(onClick = onNavigateUp) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + Icon(MeshtasticIcons.ArrowBack, contentDescription = stringResource(Res.string.back)) } }, ) @@ -251,7 +255,7 @@ internal fun ScanningBleContent() { internal fun DeviceFoundContent(deviceName: String?, onProceed: () -> Unit, onCancel: () -> Unit) { CenteredStatusContent { Icon( - Icons.Rounded.Bluetooth, + MeshtasticIcons.Bluetooth, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary, @@ -344,7 +348,7 @@ internal fun ConnectedContent( if (isScanning) { LoadingIndicator(modifier = Modifier.size(18.dp)) } else { - Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(MeshtasticIcons.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) } Spacer(Modifier.width(8.dp)) Text( @@ -414,9 +418,10 @@ internal fun ConnectedContent( singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconToggleButton(checked = passwordVisible, onCheckedChange = { passwordVisible = it }) { Icon( - imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + imageVector = + if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, contentDescription = if (passwordVisible) { stringResource(Res.string.hide_password) @@ -453,7 +458,7 @@ internal fun ConnectedContent( Spacer(Modifier.width(8.dp)) Text(stringResource(Res.string.wifi_provision_sending_credentials)) } else { - Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(MeshtasticIcons.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text(stringResource(Res.string.apply)) } @@ -474,19 +479,24 @@ internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () - headlineContent = { Text(network.ssid) }, supportingContent = { Text(stringResource(Res.string.wifi_provision_signal_strength, network.signalStrength)) }, leadingContent = { - Icon(Icons.Rounded.Wifi, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon(MeshtasticIcons.Wifi, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, trailingContent = { if (network.isProtected) { Icon( - Icons.Rounded.Lock, + MeshtasticIcons.Lock, contentDescription = stringResource(Res.string.password), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, colors = ListItemDefaults.colors(containerColor = containerColor), - modifier = Modifier.clickable(onClick = onClick), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_network), + role = Role.Button, + onClick = onClick, + ), ) } @@ -511,7 +521,7 @@ internal fun MpwrdDisclaimerBanner() { ) { Image( painter = painterResource(Res.drawable.img_mpwrd_logo), - contentDescription = "mPWRD-OS", + contentDescription = stringResource(Res.string.mpwrd_os), modifier = Modifier.size(MPWRD_LOGO_SIZE_DP.dp).clip(RoundedCornerShape(8.dp)), ) AutoLinkText( diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 65798a13b..0ee5bb0ec 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.testing.FakeBleConnection import org.meshtastic.core.testing.FakeBleConnectionFactory import org.meshtastic.core.testing.FakeBleDevice @@ -62,7 +63,15 @@ class WifiProvisionViewModelTest { scanner = FakeBleScanner() connection = FakeBleConnection() viewModel = - WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) + WifiProvisionViewModel( + bleScanner = scanner, + bleConnectionFactory = FakeBleConnectionFactory(connection), + dispatchers = CoroutineDispatchers( + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, + ), + ) } @AfterTest diff --git a/gradle.properties b/gradle.properties index 8e67ce164..2f265135a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,3 +29,4 @@ org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDu org.gradle.parallel=true org.gradle.vfs.watch=true org.gradle.welcome=never +compose.hot.reload=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0278220fa..baf89fb1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,28 +2,26 @@ xmlutil = "0.91.3" # Android -agp = "9.1.0" +agp = "9.2.0-rc01" 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-alpha02" -navigation3 = "1.1.0-beta01" -navigationevent = "1.1.0-alpha01" +jetbrains-lifecycle = "2.11.0-alpha03" +navigation3 = "1.1.0-rc01" paging = "3.4.2" -room = "3.0.0-alpha02" -koin = "4.2.0" -koin-plugin = "0.6.2" +room = "3.0.0-alpha03" +koin = "4.2.1" +koin-plugin = "1.0.0-RC1" # Kotlin -kotlin = "2.3.20" +kotlin = "2.3.21-RC2" kotlinx-coroutines-android = "1.10.2" -kotlinx-datetime = "0.7.1-0.6.x-compat" -kotlinx-serialization = "1.10.0" +kotlinx-datetime = "0.7.1" +kotlinx-serialization = "1.11.0" ktlint = "1.7.1" ktfmt = "0.61" kover = "0.9.8" @@ -35,12 +33,24 @@ testRetry = "1.6.4" turbine = "1.2.1" # Compose Multiplatform -compose-multiplatform = "1.11.0-beta01" -compose-multiplatform-material3 = "1.11.0-alpha05" +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 -maps-compose = "8.2.2" +maps-compose = "8.3.0" # ML Kit mlkit-barcode-scanning = "17.3.0" @@ -52,25 +62,25 @@ camerax = "1.6.0" ktor = "3.4.2" # Other -aboutlibraries = "13.2.1" +aboutlibraries = "14.0.1" jserialcomm = "2.11.4" coil = "3.4.0" -datadog-gradle = "1.24.0" -dd-sdk-android = "3.8.0" +datadog-gradle = "1.25.0" +dd-sdk-android = "3.9.0" detekt = "1.23.8" dokka = "2.2.0" devtools-ksp = "2.3.6" -firebase-crashlytics-gradle = "3.0.6" +firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" -markdownRenderer = "0.39.2" +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.0.3" -dependency-guard = "0.5.0" +vico = "3.2.0-next.1" kable = "0.42.0" -kmqtt = "1.0.0" +mqttastic = "0.2.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" @@ -80,7 +90,7 @@ xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", v # AndroidX androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.13.0" } -androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" } +androidx-annotation = { module = "androidx.annotation:annotation", version = "1.10.0" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } @@ -101,14 +111,12 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi # lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0; # use jetbrains-lifecycle-runtime (JB KMP fork) instead. androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } -androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" } # JetBrains KMP lifecycle (use in commonMain and androidMain) jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } -jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } @@ -119,28 +127,20 @@ 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 -androidx-compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.01" } -androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } -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" } +# 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) # 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" } -compose-multiplatform-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version = "1.7.3" } # last published; deprecated upstream # JetBrains Material 3 Adaptive (multiplatform — Android, Desktop, iOS) jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" } @@ -151,11 +151,10 @@ jetbrains-compose-material3-adaptive-navigation-suite = { module = "org.jetbrain # Google firebase-analytics = { module = "com.google.firebase:firebase-analytics" } -firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.11.0" } +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" } @@ -174,7 +173,6 @@ qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrco kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } dokka-gradlePlugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } - kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version = "0.32.1" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-android" } @@ -199,13 +197,10 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0 androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" } junit = { module = "junit:junit", version = "4.13.2" } -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } -mokkery-library = { module = "dev.mokkery:mokkery-runtime", version.ref = "mokkery" } kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } -kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-runner-junit6 = { module = "io.kotest:kotest-runner-junit6", version.ref = "kotest" } robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } @@ -217,7 +212,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } -dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" } + dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" } dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" } dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" } @@ -232,11 +227,11 @@ markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-rend material = { module = "com.google.android.material:material", version = "1.13.0" } kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" } -kmqtt-client = { module = "io.github.davidepianca98:kmqtt-client", version.ref = "kmqtt" } -kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = "kmqtt" } +meshtastic-mqtt-client = { module = "org.meshtastic:mqtt-client", version.ref = "mqttastic" } jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } @@ -248,12 +243,12 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } -android-tools-common = { module = "com.android.tools:common", version = "32.1.0" } +android-tools-common = { module = "com.android.tools:common", version = "32.1.1" } androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } datadog-gradlePlugin = { module = "com.datadoghq.dd-sdk-android-gradle-plugin:com.datadoghq.dd-sdk-android-gradle-plugin.gradle.plugin", version.ref = "datadog-gradle" } -detekt-compose = { module = "io.nlopez.compose.rules:detekt", version = "0.5.6" } +detekt-compose = { module = "io.nlopez.compose.rules:detekt", version = "0.5.7" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-gradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } firebase-crashlytics-gradlePlugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebase-crashlytics-gradle" } @@ -262,7 +257,6 @@ koin-gradlePlugin = { module = "io.insert-koin.compiler.plugin:io.insert-koin.co mokkery-gradlePlugin = { module = "dev.mokkery:mokkery-gradle", version.ref = "mokkery" } kover-gradlePlugin = { module = "org.jetbrains.kotlinx.kover:org.jetbrains.kotlinx.kover.gradle.plugin", version.ref = "kover" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "devtools-ksp" } -secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"} serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" } spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } @@ -305,14 +299,12 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } # Meshtastic -meshtastic-analytics = { id = "meshtastic.analytics" } meshtastic-android-application = { id = "meshtastic.android.application" } meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" } meshtastic-android-application-flavors = { id = "meshtastic.android.application.flavors" } meshtastic-android-library = { id = "meshtastic.android.library" } meshtastic-android-library-compose = { id = "meshtastic.android.library.compose" } meshtastic-android-library-flavors = { id = "meshtastic.android.library.flavors" } -meshtastic-android-lint = { id = "meshtastic.android.lint" } meshtastic-android-room = { id = "meshtastic.android.room" } meshtastic-detekt = { id = "meshtastic.detekt" } meshtastic-koin = { id = "meshtastic.koin" } diff --git a/mesh_service_example/README.md b/mesh_service_example/README.md deleted file mode 100644 index 3804db328..000000000 --- a/mesh_service_example/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# mesh_service_example - -> **DEPRECATED — scheduled for removal in a future release.** -> -> This module is no longer maintained and will be deleted once the new public API documentation is -> available. Do not add new code here. Do not use it as a template for new integrations. -> -> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module -> README at [`core/api/README.md`](../core/api/README.md). - -## What this was - -`mesh_service_example` was a sample Android application demonstrating how to bind to the -`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in -the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`) -and the JitPack publication that consumers may reference, until those are formally retired. - -## License - -See the root `LICENSE` file. diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts deleted file mode 100644 index 843eeff85..000000000 --- a/mesh_service_example/build.gradle.kts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import com.android.build.api.dsl.ApplicationExtension -import org.meshtastic.buildlogic.FlavorDimension -import org.meshtastic.buildlogic.MeshtasticFlavor - -plugins { - alias(libs.plugins.meshtastic.android.application) - alias(libs.plugins.meshtastic.android.application.compose) - alias(libs.plugins.kotlin.parcelize) - alias(libs.plugins.kotlin.serialization) -} - -configure { - namespace = "com.meshtastic.android.meshserviceexample" - defaultConfig { - // Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin - missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name) - } - - testOptions { unitTests.isReturnDefaultValues = true } -} - -dependencies { - implementation(projects.core.api) - implementation(projects.core.model) - implementation(projects.core.proto) - - implementation(libs.androidx.activity.compose) - implementation(libs.jetbrains.lifecycle.viewmodel.compose) - implementation(libs.jetbrains.lifecycle.runtime) - implementation(libs.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 deleted file mode 100644 index ecf2e0cce..000000000 --- a/mesh_service_example/detekt-baseline.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/mesh_service_example/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml deleted file mode 100644 index b8ffa4cae..000000000 --- a/mesh_service_example/src/main/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt deleted file mode 100644 index d61c6f192..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("DEPRECATION") - -package com.meshtastic.android.meshserviceexample - -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import org.meshtastic.core.api.MeshtasticIntent -import org.meshtastic.core.service.IMeshService - -private const val TAG: String = "MeshServiceExample" - -/** - * MainActivity for the MeshServiceExample application. - * - * **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use - * it as a template for new integrations. See `:core:api` README for the current public API surface. - */ -@Deprecated( - message = - "mesh_service_example is deprecated and will be removed in a future release. " + - "See core/api/README.md for integration guidance.", -) -class MainActivity : ComponentActivity() { - - private var meshService: IMeshService? = null - private var isMeshServiceBound = false - - private val viewModel: MeshServiceViewModel by viewModels() - - private val serviceConnection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - meshService = IMeshService.Stub.asInterface(service) - Log.i(TAG, "Connected to MeshService") - isMeshServiceBound = true - viewModel.onServiceConnected(meshService) - } - - override fun onServiceDisconnected(name: ComponentName?) { - meshService = null - isMeshServiceBound = false - viewModel.onServiceDisconnected() - } - } - - private val meshtasticReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}") - intent?.let { viewModel.handleIncomingIntent(it) } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - bindMeshService() - - val intentFilter = - IntentFilter().apply { - addAction(MeshtasticIntent.ACTION_NODE_CHANGE) - addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED) - addAction(MeshtasticIntent.ACTION_MESH_CONNECTED) - addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED) - addAction(MeshtasticIntent.ACTION_MESSAGE_STATUS) - addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_POSITION_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER) - addAction(MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") - registerReceiver(meshtasticReceiver, intentFilter) - } - - setContent { ExampleTheme { MainScreen(viewModel) } } - } - - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(meshtasticReceiver) - unbindMeshService() - } - - private fun bindMeshService() { - try { - Log.i(TAG, "Attempting to bind to Mesh Service...") - val intent = Intent("com.geeksville.mesh.Service") - - val resolveInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.queryIntentServices(intent, 0) - } - - if (resolveInfo.isNotEmpty()) { - val serviceInfo = resolveInfo[0].serviceInfo - intent.setClassName(serviceInfo.packageName, serviceInfo.name) - Log.i(TAG, "Found service in package: ${serviceInfo.packageName}") - } else { - Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.") - intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") - } - - val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE) - if (!success) { - Log.e(TAG, "bindService returned false") - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException while binding: ${e.message}") - } - } - - private fun unbindMeshService() { - if (isMeshServiceBound) { - try { - unbindService(serviceConnection) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "MeshService not registered or already unbound: ${e.message}") - } - isMeshServiceBound = false - meshService = null - } - } -} - -@Composable -fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colorScheme = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> darkColorScheme() - else -> lightColorScheme() - } - - MaterialTheme(colorScheme = colorScheme, content = content) -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt deleted file mode 100644 index 96024bf0f..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt +++ /dev/null @@ -1,584 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("TooManyFunctions") - -package com.meshtastic.android.meshserviceexample - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.BatteryUnknown -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.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( - Icons.AutoMirrored.Rounded.BatteryUnknown, - "Telemetry", - Modifier.size(20.dp), - MaterialTheme.colorScheme.secondary, - ) - } - IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline) - } - IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Rounded.SignalCellularAlt, - "Conn Status", - Modifier.size(20.dp), - MaterialTheme.colorScheme.outline, - ) - } - if (isOnline) { - Icon( - imageVector = Icons.Rounded.Router, - contentDescription = "Online", - tint = androidx.compose.ui.graphics.Color.Green.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 8.dp).size(20.dp), - ) - } - } -} - -@Composable -private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - TitledCard(title = "Device Controls") { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - GpsButtons(viewModel, snackbarHostState) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - viewModel.rebootLocalDevice() - scope.launch { snackbarHostState.showSnackbar("Reboot Requested") } - }, - shape = MaterialTheme.shapes.medium, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Reboot Radio") - } - } - } -} - -@Composable -private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - val colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.startProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Start GPS", style = MaterialTheme.typography.labelLarge) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.stopProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Stop GPS", style = MaterialTheme.typography.labelLarge) - } - } -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt deleted file mode 100644 index 09fb9fe0f..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ /dev/null @@ -1,361 +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 com.meshtastic.android.meshserviceexample - -import android.content.Intent -import android.os.Build -import android.os.Parcelable -import android.os.RemoteException -import android.util.Log -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.service.IMeshService -import org.meshtastic.proto.PortNum -import java.text.SimpleDateFormat -import java.util.Locale -import kotlin.random.Random - -private const val TAG = "MeshServiceViewModel" - -/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */ -@Suppress("TooManyFunctions") -class MeshServiceViewModel : ViewModel() { - - private var meshService: IMeshService? = null - - private val _myNodeInfo = MutableStateFlow(null) - val myNodeInfo: StateFlow = _myNodeInfo.asStateFlow() - - private val _myId = MutableStateFlow(null) - val myId: StateFlow = _myId.asStateFlow() - - private val _nodes = MutableStateFlow>(emptyList()) - val nodes: StateFlow> = _nodes.asStateFlow() - - private val _serviceConnectionStatus = MutableStateFlow(false) - val serviceConnectionStatus: StateFlow = _serviceConnectionStatus.asStateFlow() - - private val _message = MutableStateFlow("") - val message: StateFlow = _message.asStateFlow() - - private val _connectionState = MutableStateFlow("UNKNOWN") - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val _packetLog = MutableStateFlow>(emptyList()) - val packetLog: StateFlow> = _packetLog.asStateFlow() - - fun onServiceConnected(service: IMeshService?) { - meshService = service - _serviceConnectionStatus.value = true - updateAllData() - addToLog("Service Connected") - } - - fun onServiceDisconnected() { - meshService = null - _serviceConnectionStatus.value = false - addToLog("Service Disconnected") - } - - private fun updateAllData() { - requestMyNodeInfo() - requestNodes() - updateConnectionState() - updateMyId() - } - - fun updateMyId() { - meshService?.let { - try { - _myId.value = it.myId - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyId", e) - } - } - } - - fun updateConnectionState() { - meshService?.let { - try { - val state = it.connectionState() ?: "UNKNOWN" - _connectionState.value = state - addToLog("Connection State: $state") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get connection state", e) - } - } - } - - fun sendMessage(text: String) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}") - addToLog("Sent: $text (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send message", e) - addToLog("Failed to send message: ${e.message}") - } - } ?: Log.w(TAG, "MeshService is not bound, cannot send message") - } - - fun sendSpecialPacket(portNum: PortNum) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(), - dataType = portNum.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send special packet", e) - addToLog("Failed to send ${portNum.name} packet: ${e.message}") - } - } - } - - fun requestMyNodeInfo() { - meshService?.let { - try { - _myNodeInfo.value = it.myNodeInfo - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyNodeInfo", e) - } - } - } - - fun requestNodes() { - meshService?.let { - try { - _nodes.value = it.nodes ?: emptyList() - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get nodes", e) - } - } - } - - fun startProvideLocation() { - try { - meshService?.startProvideLocation() - addToLog("Started GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to start providing location", e) - } - } - - fun stopProvideLocation() { - try { - meshService?.stopProvideLocation() - addToLog("Stopped GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to stop providing location", e) - } - } - - fun requestTraceroute(nodeNum: Int) { - meshService?.let { - try { - it.requestTraceroute(Random.nextInt(), nodeNum) - Log.i(TAG, "Traceroute requested for node $nodeNum") - addToLog("Requested Traceroute for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request traceroute", e) - } - } - } - - fun requestTelemetry(nodeNum: Int) { - meshService?.let { - try { - it.requestTelemetry(Random.nextInt(), nodeNum, 1) - Log.i(TAG, "Telemetry requested for node $nodeNum") - addToLog("Requested Telemetry for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request telemetry", e) - } - } - } - - fun requestNeighborInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestNeighborInfo(Random.nextInt(), nodeNum) - Log.i(TAG, "Neighbor info requested for node $nodeNum") - addToLog("Requested Neighbors for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request neighbor info", e) - } - } - } - - fun requestPosition(nodeNum: Int) { - meshService?.let { - try { - it.requestPosition(nodeNum, Position(0.0, 0.0, 0)) - Log.i(TAG, "Position requested for node $nodeNum") - addToLog("Requested Position for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request position", e) - } - } - } - - fun requestUserInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestUserInfo(nodeNum) - Log.i(TAG, "User info requested for node $nodeNum") - addToLog("Requested User Info for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request user info", e) - } - } - } - - fun requestDeviceConnectionStatus(nodeNum: Int) { - meshService?.let { - try { - it.getDeviceConnectionStatus(Random.nextInt(), nodeNum) - Log.i(TAG, "Device connection status requested for node $nodeNum") - addToLog("Requested Connection Status for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request device connection status", e) - } - } - } - - fun rebootLocalDevice() { - meshService?.let { - try { - it.requestReboot(Random.nextInt(), 0) - Log.w(TAG, "Local reboot requested!") - addToLog("Requested Local Reboot") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request reboot", e) - } - } - } - - fun handleIncomingIntent(intent: Intent) { - val action = intent.action ?: return - Log.d(TAG, "Received broadcast: $action") - - when (action) { - "com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent) - "com.geeksville.mesh.CONNECTION_CHANGED", - "com.geeksville.mesh.MESH_CONNECTED", - "com.geeksville.mesh.MESH_DISCONNECTED", - -> updateConnectionState() - - "com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent) - else -> - if (action.startsWith("com.geeksville.mesh.RECEIVED.")) { - handleReceivedPacket(action, intent) - } - } - } - - private fun handleNodeChange(intent: Intent) { - val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java) - nodeInfo?.let { ni -> - Log.d(TAG, "Node updated: ${ni.num}") - _nodes.value = - _nodes.value.toMutableList().apply { - val index = indexOfFirst { it.num == ni.num } - if (index != -1) set(index, ni) else add(ni) - } - } - } - - private fun handleMessageStatus(intent: Intent) { - val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0) - val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java) - Log.d(TAG, "Message Status for ID $id: $status") - addToLog("Msg Status ID $id: $status") - } - - private fun handleReceivedPacket(action: String, intent: Intent) { - val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) - if (packet == null) { - Log.e(TAG, "Received packet extra was NULL for action: $action") - addToLog("Error: Packet payload was null for $action") - return - } - - Log.d(TAG, "Packet received: $packet") - - if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) { - val receivedText = packet.bytes?.utf8() ?: "" - _message.value = "From ${packet.from}: $receivedText" - addToLog("Received Text from ${packet.from}: $receivedText") - } else { - val type = action.substringAfterLast(".") - addToLog("Received $type from ${packet.from}. Check Logcat for details.") - } - } - - private fun addToLog(entry: String) { - val date = nowMillis.toInstant().toDate() - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date) - val logEntry = "[$timestamp] $entry" - Log.d(TAG, "Log: $logEntry") - @Suppress("MagicNumber") - _packetLog.value = (listOf(logEntry) + _packetLog.value).take(50) - } - - private fun Intent.getParcelableCompat(key: String, clazz: Class): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(key, clazz) - } else { - @Suppress("DEPRECATION") - getParcelableExtra(key) - } -} diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml deleted file mode 100644 index 07d5da9cb..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 6f3b755bf..000000000 --- a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-be-rBY/strings.xml b/mesh_service_example/src/main/res/values-be-rBY/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-be-rBY/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml deleted file mode 100644 index bebf8fbdd..000000000 --- a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Изпратете съобщение за здравей - diff --git a/mesh_service_example/src/main/res/values-ca-rES/strings.xml b/mesh_service_example/src/main/res/values-ca-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ca-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-de-rDE/strings.xml b/mesh_service_example/src/main/res/values-de-rDE/strings.xml deleted file mode 100644 index 968230ec2..000000000 --- a/mesh_service_example/src/main/res/values-de-rDE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Beispiel MeshService - Hallo Nachricht senden - diff --git a/mesh_service_example/src/main/res/values-el-rGR/strings.xml b/mesh_service_example/src/main/res/values-el-rGR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-el-rGR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-es-rES/strings.xml b/mesh_service_example/src/main/res/values-es-rES/strings.xml deleted file mode 100644 index 8abd298f5..000000000 --- a/mesh_service_example/src/main/res/values-es-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Ejemplo de servicio de red - Enviar Mensaje Hola - diff --git a/mesh_service_example/src/main/res/values-et-rEE/strings.xml b/mesh_service_example/src/main/res/values-et-rEE/strings.xml deleted file mode 100644 index dd6ff8304..000000000 --- a/mesh_service_example/src/main/res/values-et-rEE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceNäidis - Saada Tere sõnum - diff --git a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml deleted file mode 100644 index 2da506dda..000000000 --- a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExamplebled - Lähetä tervehdysviesti - diff --git a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml deleted file mode 100644 index 2b9ff6e40..000000000 --- a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exemple de service de maillage - Envoyer un message d’annonce - diff --git a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-gl-rES/strings.xml b/mesh_service_example/src/main/res/values-gl-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-gl-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml deleted file mode 100644 index 1cff8d920..000000000 --- a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Hello Üzenet Küldés - diff --git a/mesh_service_example/src/main/res/values-is-rIS/strings.xml b/mesh_service_example/src/main/res/values-is-rIS/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-is-rIS/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-it-rIT/strings.xml b/mesh_service_example/src/main/res/values-it-rIT/strings.xml deleted file mode 100644 index dd7addd1d..000000000 --- a/mesh_service_example/src/main/res/values-it-rIT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Invia Messaggio di Saluto - diff --git a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-no-rNO/strings.xml b/mesh_service_example/src/main/res/values-no-rNO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-no-rNO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index 4e232be75..000000000 --- a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - ExemploServiçoMesh - Enviar Mensagem de Olá - diff --git a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml deleted file mode 100644 index ba088c7e3..000000000 --- a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Отправить приветственное сообщение - diff --git a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-srp/strings.xml b/mesh_service_example/src/main/res/values-srp/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-srp/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml deleted file mode 100644 index f9271ce44..000000000 --- a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh-service exempel - Skicka Hej-meddelande - diff --git a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml deleted file mode 100644 index 37d7a2bb2..000000000 --- a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Надіслати привітальне повідомлення - diff --git a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index 16c04c5d3..000000000 --- a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh 服務範例 - 發送打招呼訊息 - diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml deleted file mode 100644 index a6b3daec9..000000000 --- a/mesh_service_example/src/main/res/values/colors.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml deleted file mode 100644 index e8f8fe799..000000000 --- a/mesh_service_example/src/main/res/values/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - -