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/calculate-version-code/action.yml b/.github/actions/calculate-version-code/action.yml
deleted file mode 100644
index 3af727e6f..000000000
--- a/.github/actions/calculate-version-code/action.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-name: 'Calculate Version Code'
-description: 'Calculates the Android versionCode based on the Git commit count plus an offset.'
-outputs:
- versionCode:
- description: "The calculated version code"
- value: ${{ steps.calculate_version.outputs.VERSION_CODE }}
-runs:
- using: 'composite'
- steps:
- - name: Calculate Version Code
- id: calculate_version
- shell: bash
- run: |
- # This action assumes that the repo has been checked out with `fetch-depth: 0`
- GIT_COMMIT_COUNT=$(git rev-list --count HEAD)
- OFFSET=30630
- VERSION_CODE=$((GIT_COMMIT_COUNT + OFFSET))
- echo "Calculated versionCode: $VERSION_CODE (from $GIT_COMMIT_COUNT commits + $OFFSET offset)"
- echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_OUTPUT
diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml
new file mode 100644
index 000000000..a42959190
--- /dev/null
+++ b/.github/actions/gradle-setup/action.yml
@@ -0,0 +1,40 @@
+name: Gradle Setup
+description: Setup Java and Gradle for KMP builds
+inputs:
+ cache_read_only:
+ description: 'Whether Gradle cache is read-only'
+ default: 'true'
+ jdk_distribution:
+ description: 'JDK distribution (temurin or jetbrains)'
+ default: 'temurin'
+ gradle_encryption_key:
+ description: 'Encryption key for Gradle remote cache'
+ required: false
+runs:
+ using: composite
+ steps:
+ - name: Copy CI Gradle properties
+ shell: bash
+ run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties
+
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ java-version: '21'
+ distribution: ${{ inputs.jdk_distribution }}
+ token: ${{ github.token }}
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+ with:
+ cache-read-only: ${{ inputs.cache_read_only }}
+ cache-encryption-key: ${{ inputs.gradle_encryption_key }}
+ cache-cleanup: on-success
+ add-job-summary: always
+ gradle-home-cache-includes: |
+ caches
+ notifications
+ ~/.m2/repository/org/robolectric
\ No newline at end of file
diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties
new file mode 100644
index 000000000..e4d203ef7
--- /dev/null
+++ b/.github/ci-gradle.properties
@@ -0,0 +1,52 @@
+#
+# CI-specific Gradle properties.
+#
+# This file is copied to ~/.gradle/gradle.properties by the gradle-setup
+# composite action, overriding the dev-oriented values in the repo-root
+# gradle.properties. Inspired by the nowinandroid & sqldelight patterns.
+#
+
+# ── Daemon ────────────────────────────────────────────────────────────
+# Single-use CI runners never reuse a daemon, so the startup cost is pure
+# overhead. Disabling it also avoids "daemon disappeared" warnings.
+org.gradle.daemon=false
+
+# ── Memory ────────────────────────────────────────────────────────────
+# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon
+# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom).
+org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
+kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC
+
+# ── Parallelism ───────────────────────────────────────────────────────
+org.gradle.parallel=true
+org.gradle.workers.max=4
+
+# ── Caching & Configuration ──────────────────────────────────────────
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+org.gradle.configureondemand=false
+org.gradle.vfs.watch=false
+org.gradle.isolated-projects=true
+
+# ── Kotlin ────────────────────────────────────────────────────────────
+# Incremental compilation is wasted on fresh CI checkouts (no prior build
+# state to diff against). Disabling avoids the overhead of maintaining
+# incremental state that will never be reused.
+kotlin.incremental=false
+kotlin.code.style=official
+kotlin.parallel.tasks.in.project=true
+
+# ── KSP ──────────────────────────────────────────────────────────────
+# In CI, KSP incremental processing adds overhead without benefit (fresh
+# checkouts). Keep intermodule incremental off (no prior state).
+ksp.incremental=false
+ksp.run.in.process=true
+
+# ── Android ──────────────────────────────────────────────────────────
+android.experimental.lint.analysisPerComponent=true
+# Disable unused build features to reduce build time
+android.defaults.buildfeatures.resvalues=false
+android.defaults.buildfeatures.shaders=false
+
+# ── Misc ─────────────────────────────────────────────────────────────
+org.gradle.welcome=never
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 b69f7c826..e856cbe8f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,183 +1,6 @@
-# Copilot Instructions for Meshtastic-Android
+# Meshtastic Android - GitHub Copilot Guide
-## Repository Summary
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
-Meshtastic-Android is a native Android client application for the Meshtastic mesh networking project. It enables users to communicate via off-grid, decentralized mesh networks using LoRa radios. The app is written in Kotlin and follows modern Android development practices.
-
-**Key Repository Details:**
-- **Language:** Kotlin (primary), with some Java and AIDL files
-- **Build System:** Gradle with Kotlin DSL
-- **Size:** ~3MB source code across 3 modules
-- **Target Platform:** Android API 26+ (Android 8.0+), targeting API 36
-- **Architecture:** Modern Android with Jetpack Compose, Hilt DI, Room database
-- **Product Flavors:** `fdroid` (F-Droid) and `google` (Google Play Store)
-- **Build Types:** `debug` and `release`
-
-## Essential Build & Test Commands
-
-**ALWAYS run these commands in the exact order specified to avoid build failures:**
-
-### Prerequisites Setup
-1. **JDK Requirement:** JDK 17 is required (compatible with most developer environments)
-2. **Secrets Configuration:** Copy `secrets.defaults.properties` to `local.properties` and update:
- ```properties
- MAPS_API_KEY=your_google_maps_api_key_here
- datadogApplicationId=your_datadog_app_id
- datadogClientToken=your_datadog_client_token
- ```
-3. **Clean Environment:** Always start with `./gradlew clean` for fresh builds
-
-### Build Commands (Validated Working Order)
-```bash
-# 1. ALWAYS clean first for reliable builds
-./gradlew clean
-
-# 2. Check code formatting (run before making changes)
-./gradlew spotlessCheck
-
-# 3. Apply automatic code formatting fixes
-./gradlew spotlessApply
-
-# 4. Run static code analysis/linting
-./gradlew detekt
-
-# 5. Build debug APKs for both flavors (takes 3-5 minutes)
-./gradlew assembleDebug
-
-# 6. Build specific flavor variants
-./gradlew assembleFdroidDebug # F-Droid debug build
-./gradlew assembleGoogleDebug # Google debug build
-./gradlew assembleFdroidRelease # F-Droid release build
-./gradlew assembleGoogleRelease # Google release build
-
-# 7. Run local unit tests (takes 2-3 minutes)
-./gradlew test
-
-# 8. Run specific flavor unit tests
-./gradlew testFdroidDebug
-./gradlew testGoogleDebug
-
-# 9. Run instrumented tests (requires Android device/emulator, takes 5-10 minutes)
-./gradlew connectedAndroidTest
-
-# 10. Run lint checks for both flavors
-./gradlew lintFdroidDebug lintGoogleDebug
-```
-
-### Time Requirements
-- Clean build: 3-5 minutes
-- Unit tests: 2-3 minutes
-- Instrumented tests: 5-10 minutes
-- Detekt analysis: 1-2 minutes
-- Spotless formatting: 30 seconds
-
-### Environment Setup
-**Required Tools:**
-- Android SDK API 36 (compile target)
-- JDK 17 (Preferred for consistency across project and plugins)
-- Gradle 9.0+ (downloaded automatically by wrapper)
-
-**Optional but Recommended:**
-- Install pre-push Git hook: `./gradlew spotlessInstallGitPrePushHook --no-configuration-cache`
-
-## Project Architecture & Layout
-
-### Module Structure
-```
-├── app/ # Main Android application
-│ ├── src/main/ # Main source code
-│ ├── src/test/ # Unit tests
-│ ├── src/androidTest/ # Instrumented tests
-│ ├── src/fdroid/ # F-Droid specific code
-│ └── src/google/ # Google Play specific code
-├── core/ # Core library modules
-├── network/ # HTTP API networking library
-├── mesh_service_example/ # AIDL service usage example
-├── build-logic/ # Build configuration convention plugins
-└── config/ # Linting and formatting configs
- ├── detekt/ # Detekt static analysis rules
- └── spotless/ # Code formatting configuration
-```
-
-### Key Configuration Files
-- `config.properties` - Version constants and build config
-- `app/build.gradle.kts` - Main app build configuration
-- `config/detekt/detekt.yml` - Static analysis rules
-- `config/spotless/.editorconfig` - Code formatting rules
-- `gradle.properties` - Gradle build settings
-- `secrets.defaults.properties` - Template for secrets (copy to `local.properties`)
-
-### Architecture Components
-- **UI Framework:** Jetpack Compose with Material 3
-- **State Management:** Unidirectional Data Flow with ViewModels
-- **Dependency Injection:** Hilt
-- **Navigation:** Jetpack Navigation Compose
-- **Local Data:** Room database + DataStore preferences
-- **Remote Data:** Custom Bluetooth/WiFi protocol + HTTP API (network module)
-- **Background Work:** WorkManager
-- **Communication:** AIDL service interface (`IMeshService.aidl`)
-
-## Continuous Integration
-
-### GitHub Workflows (.github/workflows/)
-- **pull-request.yml** - Runs on every PR: build, detekt, tests
-- **reusable-android-build.yml** - Shared build logic: spotless, detekt, lint, assemble, test
-- **reusable-android-test.yml** - Instrumented tests on Android emulators (API 26, 35)
-
-### CI Commands (Must Pass)
-```bash
-# Exact commands run in CI that must pass:
-./gradlew :app:spotlessCheck :app:detekt :app:lintFdroidDebug :app:lintGoogleDebug :app:assembleDebug :app:testFdroidDebug :app:testGoogleDebug --configuration-cache --scan
-./gradlew :app:connectedFdroidDebugAndroidTest :app:connectedGoogleDebugAndroidTest --configuration-cache --scan
-```
-
-### Validation Steps
-1. **Code Style:** Spotless check (auto-fixable with `spotlessApply`)
-2. **Static Analysis:** Detekt with custom rules in `config/detekt/detekt.yml`
-3. **Lint Checks:** Android lint for both flavors
-4. **Unit Tests:** JUnit tests in `app/src/test/`
-5. **UI Tests:** Compose UI tests in `app/src/androidTest/`
-
-## Common Issues & Solutions
-
-### Build Failures
-- **Gradle version error:** Ensure JDK 17 (Compatible version)
-- **Missing secrets:** Copy `secrets.defaults.properties` → `local.properties`
-- **Configuration cache:** Add `--no-configuration-cache` flag if issues persist
-- **Clean state:** Always run `./gradlew clean` before debugging build issues
-
-### Testing Issues
-- **Instrumented tests:** Require Android device/emulator with API 26+
-- **UI tests:** Use `ComposeTestRule` for Compose UI testing
-- **Coroutine tests:** Use `kotlinx.coroutines.test` library
-
-### Code Style Issues
-- **Formatting:** Run `./gradlew spotlessApply` to auto-fix
-- **Detekt warnings:** Check `config/detekt/detekt.yml` for rules
-- **Localization:** Use `stringResource(Res.string.key)` instead of hardcoded strings
-
-## File Organization
-
-### Source Code Locations
-- **Main Activity:** `app/src/main/java/com/geeksville/mesh/MainActivity.kt`
-- **Service Interface:** `core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl`
-- **UI Screens:** `feature/*/src/main/kotlin/org/meshtastic/feature/*/`
-- **Data Layer:** `core/data/src/main/kotlin/org/meshtastic/core/data/`
-- **Database:** `core/database/src/main/kotlin/org/meshtastic/core/database/`
-- **Models:** `core/model/src/main/kotlin/org/meshtastic/core/model/`
-
-### Dependencies
-- **Non-obvious deps:** Protobuf for device communication, DataDog for analytics (Google flavor)
-- **Flavor-specific:** Google Services (google flavor), no analytics (fdroid flavor)
-- **Version catalog:** Dependencies defined in `gradle/libs.versions.toml`
-
-## Agent Instructions
-
-**TRUST THESE INSTRUCTIONS** - they are validated and comprehensive. Only search for additional information if:
-1. Commands fail with unexpected errors
-2. Information appears outdated
-3. Working on areas not covered above
-
-**Always prefer:** Using the documented commands over exploring alternatives, as they are tested and proven to work in the CI environment.
-
-**For code changes:** Follow the architecture patterns established in existing code, maintain the modular structure, and ensure all validation steps pass before submitting changes.
\ No newline at end of file
+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 c7ad60add..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,107 +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-latest' }}
- 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: '17'
-
- # 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/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml
index 7b1365186..3c6ddd61a 100644
--- a/.github/workflows/create-or-promote-release.yml
+++ b/.github/workflows/create-or-promote-release.yml
@@ -20,6 +20,11 @@ on:
required: true
type: boolean
default: false
+ build_desktop:
+ description: 'Whether to build the desktop distribution'
+ required: true
+ type: boolean
+ default: false
permissions:
contents: write
@@ -29,7 +34,7 @@ permissions:
jobs:
determine-tags:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
release_name: ${{ steps.calculate_tags.outputs.release_name }}
@@ -106,112 +111,6 @@ jobs:
fi
shell: bash
- - name: Update External Assets (Firmware, Hardware, Protos)
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- run: |
- # Update Submodules (Protobufs)
- echo "Updating core/proto submodule..."
- git submodule update --init --remote core/proto
-
- # Update Firmware List
- firmware_file_path="app/src/main/assets/firmware_releases.json"
- temp_firmware_file="/tmp/new_firmware_releases.json"
-
- echo "Fetching latest firmware releases..."
- curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file"
-
- if ! jq empty "$temp_firmware_file" 2>/dev/null; then
- echo "::error::Firmware API returned invalid JSON data. Aborting."
- exit 1
- else
- if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then
- echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path."
- cp "$temp_firmware_file" "$firmware_file_path"
- else
- echo "No changes detected in firmware list."
- fi
- fi
-
- # Update Hardware List
- hardware_file_path="app/src/main/assets/device_hardware.json"
- temp_hardware_file="/tmp/new_device_hardware.json"
-
- echo "Fetching latest device hardware data..."
- curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file"
-
- if ! jq empty "$temp_hardware_file" 2>/dev/null; then
- echo "::error::Hardware API returned invalid JSON data. Aborting."
- exit 1
- else
- if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then
- echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path."
- cp "$temp_hardware_file" "$hardware_file_path"
- else
- echo "No changes detected in hardware list."
- fi
- fi
-
- - name: Sync with Crowdin
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- uses: crowdin/github-action@v2
- with:
- base_url: 'https://meshtastic.crowdin.com/api/v2'
- config: 'crowdin.yml'
- crowdin_branch_name: 'main'
- upload_sources: true
- upload_sources_args: '--preserve-hierarchy'
- upload_translations: false
- download_translations: true
- download_translations_args: '--preserve-hierarchy'
- create_pull_request: false
- push_translations: false
- push_sources: false
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
- CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-
- - name: Commit Release Assets (Translations, Data, Config)
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- env:
- FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }}
- run: |
- # Calculate Version Code
- OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
- COMMIT_COUNT=$(git rev-list --count HEAD)
- # +1 because we are about to add a commit
- VERSION_CODE=$((COMMIT_COUNT + OFFSET + 1))
-
- echo "Calculated Version Code: $VERSION_CODE"
-
- # Update VERSION_NAME_BASE in config.properties
- sed -i "s/^VERSION_NAME_BASE=.*/VERSION_NAME_BASE=${{ inputs.base_version }}/" config.properties
-
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- # Add updated data files
- git add config.properties
- git add app/src/main/assets/firmware_releases.json || true
- git add app/src/main/assets/device_hardware.json || true
- git add core/proto || true
-
- # Add updated translations (fastlane metadata and strings)
- git add fastlane/metadata/android || true
- git add "**/strings.xml" || true
-
- # Only commit if there are changes
- if ! git diff --cached --quiet; then
- git commit -m "chore(release): prepare $FINAL_TAG [skip ci]
-
- - Bump base version to ${{ inputs.base_version }}
- - Sync translations and assets"
- git push origin HEAD:${{ github.ref_name }}
- else
- echo "No changes to commit."
- fi
- shell: bash
-
- name: Create and Push Release Tag
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
@@ -230,6 +129,7 @@ jobs:
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
+ build_desktop: ${{ inputs.build_desktop }}
secrets: inherit
call-promote-workflow:
@@ -244,3 +144,23 @@ jobs:
base_version: ${{ inputs.base_version }}
from_channel: ${{ needs.determine-tags.outputs.from_channel }}
secrets: inherit
+
+ cleanup-on-failure:
+ needs: [determine-tags, call-release-workflow]
+ if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Delete Failed or Cancelled Tag
+ env:
+ FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
+ run: |
+ if [ -n "$FINAL_TAG" ]; then
+ echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
+ git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
+ else
+ echo "No tag was created to delete."
+ fi
diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml
index 9009becd4..10535d723 100644
--- a/.github/workflows/dependency-submission.yml
+++ b/.github/workflows/dependency-submission.yml
@@ -10,19 +10,20 @@ permissions:
jobs:
dependency-submission:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
- distribution: jetbrains
- java-version: 17
+ distribution: temurin
+ java-version: 21
+ token: ${{ github.token }}
- name: Generate and submit dependency graph
- uses: gradle/actions/dependency-submission@v5
+ uses: gradle/actions/dependency-submission@v6
with:
build-scan-publish: true
- build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use"
+ build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index bf239c5de..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,16 +39,16 @@ 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:
if: github.repository == 'meshtastic/Meshtastic-Android'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -47,20 +57,16 @@ jobs:
submodules: 'recursive'
ref: ${{ inputs.ref || '' }}
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
- uses: actions/upload-pages-artifact@v4
+ uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
@@ -69,9 +75,9 @@ jobs:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
needs: build-docs
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml
new file mode 100644
index 000000000..eaf3f54d3
--- /dev/null
+++ b/.github/workflows/main-check.yml
@@ -0,0 +1,26 @@
+name: Main CI (Verify & Build)
+
+on:
+ push:
+ branches: [ main ]
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: main-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ validate-and-build:
+ if: github.repository == 'meshtastic/Meshtastic-Android'
+ uses: ./.github/workflows/reusable-check.yml
+ with:
+ run_lint: true
+ 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 ff1513535..da161e44e 100644
--- a/.github/workflows/main-push-changelog.yml
+++ b/.github/workflows/main-push-changelog.yml
@@ -5,6 +5,10 @@ on:
branches:
- main
+permissions:
+ contents: write
+ pull-requests: read
+
concurrency:
group: main-push-${{ github.ref }}
cancel-in-progress: true
@@ -12,7 +16,7 @@ concurrency:
jobs:
main-push-changelog:
name: Generate main push changelog
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -35,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 27e532a26..44d31183d 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -4,6 +4,9 @@ on:
merge_group:
types: [checks_requested]
+permissions:
+ contents: read
+
concurrency:
group: build-mq-${{ github.ref }}
cancel-in-progress: true
@@ -13,14 +16,15 @@ jobs:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-check.yml
with:
- api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
- flavors: '["google", "fdroid"]'
+ run_lint: true
+ run_unit_tests: true
upload_artifacts: false
secrets: inherit
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
needs:
- android-check
if: always()
diff --git a/.github/workflows/models_issue_triage.yml b/.github/workflows/models_issue_triage.yml
index f61a15fe6..a02fb8ed8 100644
--- a/.github/workflows/models_issue_triage.yml
+++ b/.github/workflows/models_issue_triage.yml
@@ -14,8 +14,8 @@ concurrency:
jobs:
triage:
- if: ${{ github.repository == 'meshtastic/firmware' && github.event.issue.user.type != 'Bot' }}
- runs-on: ubuntu-latest
+ if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }}
+ runs-on: ubuntu-24.04-arm
steps:
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam
@@ -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 ef303c02a..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' }}
- runs-on: ubuntu-latest
+ 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 b576ad9a6..4b8f94bfa 100644
--- a/.github/workflows/moderate.yml
+++ b/.github/workflows/moderate.yml
@@ -9,7 +9,8 @@ on:
jobs:
spam-detection:
- runs-on: ubuntu-latest
+ if: github.repository == 'meshtastic/Meshtastic-Android'
+ runs-on: ubuntu-24.04-arm
permissions:
issues: write
pull-requests: write
diff --git a/.github/workflows/post-release-cleanup.yml b/.github/workflows/post-release-cleanup.yml
index 925d265fa..d62c36ed9 100644
--- a/.github/workflows/post-release-cleanup.yml
+++ b/.github/workflows/post-release-cleanup.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
cleanup_prereleases:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
environment: Release
steps:
- name: Checkout code
diff --git a/.github/workflows/pr_enforce_labels.yml b/.github/workflows/pr_enforce_labels.yml
index 8669b3c43..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:
- runs-on: ubuntu-latest
+ 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 0345e1a1b..df16866f3 100644
--- a/.github/workflows/promote.yml
+++ b/.github/workflows/promote.yml
@@ -65,9 +65,9 @@ permissions:
jobs:
prepare-build-info:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
- APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
+ APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps:
- name: Checkout code
@@ -77,9 +77,14 @@ jobs:
fetch-depth: 0
submodules: 'recursive'
- - name: Determine Version Name from Tag
- id: get_version_name
- run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
+ - name: Prep APP_VERSION_NAME
+ id: prep_version
+ env:
+ INPUT_TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
+ echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
+ echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@@ -97,7 +102,7 @@ jobs:
shell: bash
promote-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
environment: Release
needs: [ prepare-build-info ]
steps:
@@ -111,7 +116,7 @@ jobs:
user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }}
update-github-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
needs: [ prepare-build-info, promote-release ]
steps:
- name: Checkout code
@@ -134,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/publish-core.yml b/.github/workflows/publish-core.yml
index efe07fdfa..6bbf344f0 100644
--- a/.github/workflows/publish-core.yml
+++ b/.github/workflows/publish-core.yml
@@ -12,7 +12,7 @@ on:
jobs:
publish:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
@@ -23,25 +23,25 @@ jobs:
with:
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'temurin'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Configure Version
id: version
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
- if [[ "${{ github.event_name }}" == "release" ]]; then
- echo "VERSION_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
+ if [[ "$EVENT_NAME" == "release" ]]; then
+ echo "VERSION_NAME=$RELEASE_TAG" >> $GITHUB_ENV
else
# Use a timestamp-based version for manual/branch builds to avoid collisions
# or use the base version + suffix
BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2)
- echo "VERSION_NAME=${BASE_VERSION}${{ inputs.version_suffix }}" >> $GITHUB_ENV
+ echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV
fi
- name: Publish to GitHub Packages
diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml
index cebe7e588..d37cecf43 100644
--- a/.github/workflows/pull-request-target.yml
+++ b/.github/workflows/pull-request-target.yml
@@ -1,15 +1,67 @@
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:
contents: read
pull-requests: write
- runs-on: ubuntu-latest
+ 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 4e215d2dd..d450711ce 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -1,72 +1,134 @@
+name: Pull Request CI
+
on:
pull_request:
- branches:
- - main
- workflow_dispatch:
+ branches: [ main ]
+
+permissions:
+ contents: read
concurrency:
- group: build-pr-${{ github.ref }}
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
+ # 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
- code_changed: ${{ steps.filter.outputs.code }}
+ android: ${{ steps.filter.outputs.android }}
steps:
- uses: actions/checkout@v6
- - uses: dorny/paths-filter@v3
+ - uses: dorny/paths-filter@v4
id: filter
with:
+ token: ''
filters: |
- code:
- - '**/*.kt'
- - '**/*.java'
- - '**/*.xml'
- - '**/*.kts'
- - '**/*.properties'
+ android:
+ # CI/workflow implementation
+ - '.github/workflows/**'
+ - '.github/actions/**'
+ # Product modules validated by reusable-check
+ - 'app/**'
+ - 'baselineprofile/**'
+ - 'desktop/**'
+ - 'core/**'
+ - 'feature/**'
+ # Shared build infrastructure
+ - 'build-logic/**'
+ - 'config/**'
- 'gradle/**'
+ # Root build entrypoints/config that can alter task graph or outputs
+ - 'build.gradle.kts'
+ - 'config.properties'
+ - 'compose_compiler_config.conf'
+ - 'gradle.properties'
- 'gradlew'
- 'gradlew.bat'
- - '**/src/**'
- - '.github/workflows/**'
+ - 'settings.gradle.kts'
+ - 'test.gradle.kts'
- android-check:
+ # 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
+ verify-check-changes-filter:
+ if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - uses: actions/checkout@v6
+ - name: Verify module roots are represented in check-changes filter
+ run: |
+ python3 - <<'PY'
+ import re
+ from pathlib import Path
+
+ settings = Path('settings.gradle.kts').read_text()
+ workflow = Path('.github/workflows/pull-request.yml').read_text()
+
+ module_roots = {
+ module.split(':')[0]
+ for module in re.findall(r'":([^"]+)"', settings)
+ }
+
+ allowed_extra_roots = {'baselineprofile'}
+ expected_roots = module_roots | allowed_extra_roots
+
+ filter_paths = {
+ path.split('/')[0]
+ for path in re.findall(r"-\s*'([^']+/\*\*)'", workflow)
+ }
+
+ actual_module_roots = filter_paths & expected_roots
+
+ missing = sorted(expected_roots - actual_module_roots)
+ unexpected = sorted(actual_module_roots - expected_roots)
+
+ if missing or unexpected:
+ print('check-changes filter drift detected:')
+ if missing:
+ print(' Missing roots:', ', '.join(missing))
+ if unexpected:
+ print(' Unexpected roots:', ', '.join(unexpected))
+ raise SystemExit(1)
+
+ print('check-changes filter is aligned with settings.gradle module roots.')
+ PY
+
+ # 2. VALIDATION & BUILD: Delegate to reusable-check.yml
+ # We disable coverage and desktop builds for PRs to keep feedback fast
+ # (< 10 mins). Desktop compilation is already covered by the :desktop:test
+ # task in the shard-app test shard.
+ validate-and-build:
needs: check-changes
- if: needs.check-changes.outputs.code_changed == 'true'
+ if: needs.check-changes.outputs.android == 'true'
uses: ./.github/workflows/reusable-check.yml
with:
- api_levels: '[35]' # Only test latest API on PRs for speed
- flavors: '["google","fdroid"]'
+ run_lint: true
+ run_unit_tests: true
+ run_coverage: false
+ run_desktop_builds: false
+ upload_artifacts: true
secrets: inherit
- skip-notice:
- needs: check-changes
- if: needs.check-changes.outputs.code_changed != 'true'
- runs-on: ubuntu-latest
- steps:
- - name: Skip CI for non-code changes
- run: echo "Skipping CI - no code changes detected (docs/config only)"
-
+ # 3. WORKFLOW STATUS: Ensures required checks are satisfied
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
- needs:
- - check-changes
- - android-check
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
+ needs: [check-changes, verify-check-changes-filter, validate-and-build]
if: always()
steps:
- name: Check Workflow Status
run: |
- if [[ "${{ needs.check-changes.outputs.code_changed }}" != "true" ]]; then
- echo "No code changes - CI jobs skipped as expected"
- exit 0
- fi
-
- if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
- echo "::error::Android Check failed"
+ if [[ "${{ needs.verify-check-changes-filter.result }}" == "failure" || "${{ needs.verify-check-changes-filter.result }}" == "cancelled" ]]; then
+ echo "::error::check-changes filter verification failed"
exit 1
fi
- echo "All jobs passed successfully"
+ # If changes were detected but build failed, fail the status check
+ if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then
+ echo "::error::Android Check failed"
+ exit 1
+ fi
+
+ # If no changes were detected, this still succeeds to satisfy required status check
+ echo "Workflow status satisfied."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b745d0850..40d8e40f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,6 +19,11 @@ on:
description: 'The channel to create a release for or promote to'
required: true
type: string
+ build_desktop:
+ description: 'Whether to build the desktop distribution'
+ required: false
+ type: boolean
+ default: false
secrets:
GSERVICES:
required: true
@@ -44,6 +49,10 @@ on:
required: false
GRADLE_CACHE_PASSWORD:
required: false
+ INTERNAL_BUILDS_HOST:
+ required: false
+ INTERNAL_BUILDS_HOST_PAT:
+ required: false
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag_name }}
@@ -56,23 +65,12 @@ permissions:
attestations: write
jobs:
- run-lint:
- uses: ./.github/workflows/reusable-check.yml
- with:
- run_lint: true
- run_unit_tests: false
- run_instrumented_tests: false
- flavors: '["google"]'
- upload_artifacts: false
- secrets: inherit
-
prepare-build-info:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
- APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
+ APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@@ -83,22 +81,14 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
- with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
-
- - name: Determine Version Name from Tag
- id: get_version_name
- run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
+ - name: Prep APP_VERSION_NAME
+ id: prep_version
+ env:
+ INPUT_TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
+ echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
+ echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@@ -116,11 +106,10 @@ jobs:
shell: bash
release-google:
- runs-on: ubuntu-latest
- needs: [prepare-build-info, run-lint]
+ runs-on: ubuntu-24.04
+ needs: [prepare-build-info]
environment: Release
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@@ -131,18 +120,12 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Load secrets
env:
@@ -167,7 +150,7 @@ jobs:
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
- ruby-version: '3.4.8'
+ ruby-version: '3.4.9'
bundler-cache: true
- name: Build and Deploy Google Play to Internal Track with Fastlane
@@ -192,27 +175,26 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: google-apk
- path: app/build/outputs/apk/**/*.apk
+ path: app/build/outputs/apk/google/release/*.apk
retention-days: 1
- name: Attest Google AAB provenance
- if: always()
+ if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab
- name: Attest Google APK provenance
- if: always()
+ if: success()
uses: actions/attest-build-provenance@v4
with:
- subject-path: app/build/outputs/apk/**/*.apk
+ subject-path: app/build/outputs/apk/google/release/*.apk
release-fdroid:
- runs-on: ubuntu-latest
- needs: [prepare-build-info, run-lint]
+ runs-on: ubuntu-24.04
+ needs: [prepare-build-info]
environment: Release
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@@ -223,18 +205,12 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Load secrets
env:
@@ -248,7 +224,7 @@ jobs:
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
- ruby-version: '3.4.8'
+ ruby-version: '3.4.9'
bundler-cache: true
- name: Build F-Droid with Fastlane
@@ -265,24 +241,86 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
- path: app/build/outputs/apk/**/*.apk
+ path: app/build/outputs/apk/fdroid/release/*.apk
retention-days: 1
- name: Attest F-Droid APK provenance
- if: always()
+ if: success()
uses: actions/attest-build-provenance@v4
with:
- subject-path: app/build/outputs/apk/**/*.apk
+ subject-path: app/build/outputs/apk/fdroid/release/*.apk
- github-release:
- runs-on: ubuntu-latest
- needs: [prepare-build-info, release-google, release-fdroid]
+ release-desktop:
+ if: ${{ inputs.build_desktop }}
+ runs-on: ${{ matrix.os }}
+ needs: [prepare-build-info]
+ environment: Release
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
+ env:
+ GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
+ GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
+ GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
+ submodules: 'recursive'
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
+
+ - name: Install dependencies for AppImage
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y libfuse2
+
+ - name: Package Native Distributions
+ env:
+ ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
+ APPIMAGE_EXTRACT_AND_RUN: 1
+ run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon
+
+ - name: List Desktop Binaries
+ if: runner.os == 'Linux'
+ run: ls -R desktop/build/compose/binaries/main-release
+
+ - name: Upload Desktop Artifacts
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: desktop-${{ runner.os }}-${{ runner.arch }}
+ path: |
+ desktop/build/compose/binaries/main-release/*/*.dmg
+ desktop/build/compose/binaries/main-release/*/*.msi
+ desktop/build/compose/binaries/main-release/*/*.exe
+ desktop/build/compose/binaries/main-release/*/*.deb
+ desktop/build/compose/binaries/main-release/*/*.rpm
+ desktop/build/compose/binaries/main-release/*/*.AppImage
+ retention-days: 1
+ if-no-files-found: ignore
+
+ github-release:
+ if: ${{ !cancelled() && !failure() }}
+ runs-on: ubuntu-24.04-arm
+ needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
+ env:
+ INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag_name }}
- name: Download all artifacts
uses: actions/download-artifact@v8
@@ -290,23 +328,26 @@ 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 }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: true
- files: ./artifacts/*/*
+ files: ./artifacts/**/*
draft: true
prerelease: true
- name: Create or Update internal GitHub Release
- uses: softprops/action-gh-release@v2
+ continue-on-error: true
+ if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
+ uses: softprops/action-gh-release@v3
with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
tag_name: ${{ inputs.tag_name }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
- generate_release_notes: true
- files: ./artifacts/*/*
+ generate_release_notes: false
+ files: ./artifacts/**/*
draft: false
- prerelease: true
+ prerelease: true
\ No newline at end of file
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 85b9d46ba..632bf1ea4 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -9,18 +9,12 @@ on:
run_unit_tests:
type: boolean
default: true
- run_instrumented_tests:
+ run_coverage:
+ type: boolean
+ default: true
+ run_desktop_builds:
type: boolean
default: true
- flavors:
- type: string
- default: '["google"]'
- api_levels:
- type: string
- default: '[35]'
- num_shards:
- type: number
- default: 1
upload_artifacts:
type: boolean
default: true
@@ -42,164 +36,280 @@ on:
GRADLE_CACHE_PASSWORD:
required: false
+env:
+ DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
+ DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
+ MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
+ GITHUB_TOKEN: ${{ github.token }}
+ GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
+ GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
+ GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
+ # Fallback VERSION_CODE for the lint-check job itself (which computes the real
+ # value from git). Downstream jobs override this with the git-derived value.
+ VERSION_CODE: ${{ github.run_number }}
+
jobs:
- check:
- runs-on: ubuntu-latest
- timeout-minutes: 60
- strategy:
- fail-fast: true
- matrix:
- api_level: ${{ fromJson(inputs.api_levels) }}
- flavor: ${{ fromJson(inputs.flavors) }}
- env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
- DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
- DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
- MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
- GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
- GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
- GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
-
+ # ── Lint & Static Analysis ──────────────────────────────────────────
+ lint-check:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 30
+ outputs:
+ cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
+ version_code: ${{ steps.version_code.outputs.version_code }}
+
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- submodules: 'recursive'
+ filter: 'blob:none'
+ submodules: true
- - name: Set up JDK 17
- uses: actions/setup-java@v5
- with:
- java-version: '17'
- distribution: 'jetbrains'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
- add-job-summary: always
-
- - name: Calculate Version Code
- id: calculate_version_code
- uses: ./.github/actions/calculate-version-code
-
- - name: Determine Tasks
- id: tasks
+ - name: Determine cache read-only setting
+ id: cache_config
+ shell: bash
run: |
- TASKS=""
- # Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources
- IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
- IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
-
- if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
- [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest "
+ if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.event_name }}" == "merge_group" ]] || [[ "${{ github.ref }}" == gh-readonly-queue/* ]]; then
+ echo "cache_read_only=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "cache_read_only=true" >> "$GITHUB_OUTPUT"
fi
-
- FLAVOR="${{ matrix.flavor }}"
- if [ "$IS_FIRST_API" = "true" ]; then
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS assembleGoogleDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS assembleFdroidDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest "
- fi
- fi
-
- # Instrumented Test Tasks
- if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
- [ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest "
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS connectedGoogleDebugAndroidTest "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS connectedFdroidDebugAndroidTest "
- fi
- fi
-
- # Run coverage report if unit tests were executed
- if [ "${{ inputs.run_unit_tests }}" = "true" ] && [ "$IS_FIRST_API" = "true" ]; then
- if [ "$IS_FIRST_FLAVOR" = "true" ]; then
- TASKS="$TASKS koverXmlReportDebug "
- fi
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS koverXmlReportGoogleDebug "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS koverXmlReportFdroidDebug "
- fi
- fi
-
- echo "tasks=$TASKS" >> $GITHUB_OUTPUT
- echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT
- - name: Enable KVM group perms
- if: inputs.run_instrumented_tests == true
+ - name: Calculate version code from git commit count
+ id: version_code
+ shell: bash
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
+ COMMIT_COUNT=$(git rev-list --count HEAD)
+ OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2 || echo 0)
+ VERSION_CODE=$((COMMIT_COUNT + OFFSET))
+ echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
- - name: Run Check (with Emulator)
- if: inputs.run_instrumented_tests == true
- uses: reactivecircus/android-emulator-runner@v2
- env:
- VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
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 -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
- - name: Run Check (no Emulator)
- if: inputs.run_instrumented_tests == false
- env:
- VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
- run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
+ - name: Lint, Analysis & KMP Smoke Compile
+ if: inputs.run_lint == true
+ run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
- - name: Upload coverage results to Codecov
- if: ${{ !cancelled() }}
- uses: codecov/codecov-action@v5
+ - name: KMP Smoke Compile (lint skipped)
+ if: inputs.run_lint == false
+ run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan
+
+ # ── Sharded Unit Tests ──────────────────────────────────────────────
+ # Tests are split into 3 shards that run in parallel:
+ # shard-core: core:* KMP module tests (allTests)
+ # shard-feature: feature:* KMP module tests (allTests)
+ # shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
+ test-shards:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 45
+ needs: lint-check
+ if: inputs.run_unit_tests == true
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
+ strategy:
+ fail-fast: false
+ matrix:
+ shard:
+ - name: shard-core
+ tasks: >-
+ :core:ble:allTests
+ :core:common:allTests
+ :core:data:allTests
+ :core:database:allTests
+ :core:domain:allTests
+ :core:model:allTests
+ :core:navigation:allTests
+ :core:network:allTests
+ :core:prefs:allTests
+ :core:repository:allTests
+ :core:service:allTests
+ :core:takserver:allTests
+ :core:testing:allTests
+ :core:ui:allTests
+ kover: >-
+ :core:ble:koverXmlReport
+ :core:common:koverXmlReport
+ :core:data:koverXmlReport
+ :core:database:koverXmlReport
+ :core:domain:koverXmlReport
+ :core:model:koverXmlReport
+ :core:navigation:koverXmlReport
+ :core:network:koverXmlReport
+ :core:prefs:koverXmlReport
+ :core:repository:koverXmlReport
+ :core:service:koverXmlReport
+ :core:takserver:koverXmlReport
+ :core:testing:koverXmlReport
+ :core:ui:koverXmlReport
+ - name: shard-feature
+ tasks: >-
+ :feature:connections:allTests
+ :feature:firmware:allTests
+ :feature:intro:allTests
+ :feature:map:allTests
+ :feature:messaging:allTests
+ :feature:node:allTests
+ :feature:settings:allTests
+ kover: >-
+ :feature:connections:koverXmlReport
+ :feature:firmware:koverXmlReport
+ :feature:intro:koverXmlReport
+ :feature:map:koverXmlReport
+ :feature:messaging:koverXmlReport
+ :feature:node:koverXmlReport
+ :feature:settings:koverXmlReport
+ - name: shard-app
+ tasks: >-
+ :app:testFdroidDebugUnitTest
+ :app:testGoogleDebugUnitTest
+ :desktop:test
+ :core:barcode:testFdroidDebugUnitTest
+ :core:barcode:testGoogleDebugUnitTest
+ kover: >-
+ :app:koverXmlReportFdroidDebug
+ :app:koverXmlReportGoogleDebug
+ :core:barcode:koverXmlReportFdroidDebug
+ :core:barcode:koverXmlReportGoogleDebug
+ :desktop:koverXmlReport
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
with:
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: meshtastic/Meshtastic-Android
- files: "**/build/reports/kover/report*.xml"
+ fetch-depth: 1
+ submodules: true
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
+
+ - name: Run Tests & Coverage (${{ matrix.shard.name }})
+ run: |
+ kover_tasks=""
+ if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
+ kover_tasks="${{ matrix.shard.kover }}"
+ fi
+ ./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ slug: meshtastic/Meshtastic-Android
+ flags: ${{ matrix.shard.name }}
+ fail_ci_if_error: false
report_type: test_results
- files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml"
+ files: "**/build/test-results/**/*.xml"
- - name: Upload debug artifact
- if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
+ - name: Upload coverage to Codecov
+ if: ${{ !cancelled() && inputs.run_coverage }}
+ uses: codecov/codecov-action@v6
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: meshtastic/Meshtastic-Android
+ flags: ${{ matrix.shard.name }}
+ fail_ci_if_error: false
+ files: "**/build/reports/kover/report*.xml"
+
+ - name: Upload shard reports
+ if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: ${{ matrix.flavor }}Debug
- path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk
- retention-days: 14
+ name: reports-${{ matrix.shard.name }}
+ path: |
+ **/build/reports
+ **/build/test-results
+ retention-days: 7
+
+ # ── Android Build ────────────────────────────────────────────────────
+ android-check:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ needs: lint-check
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ submodules: true
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
+
+ - name: Build Android APKs
+ run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
+
+ - name: Upload debug artifact
+ if: ${{ inputs.upload_artifacts }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-debug-apks
+ path: app/build/outputs/apk/*/debug/*.apk
+ retention-days: 7
- name: Report App Size
- if: always() && steps.tasks.outputs.is_first_api == 'true'
+ if: always()
run: |
- echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY
+ 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 reports
- if: ${{ always() && inputs.upload_artifacts }}
+ # ── Desktop Build ───────────────────────────────────────────────────
+ build-desktop:
+ name: Build Desktop Debug (${{ matrix.os }})
+ if: inputs.run_desktop_builds == true
+ runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ needs: lint-check
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ submodules: true
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
+
+ - name: Build Desktop
+ run: ./gradlew :desktop:createDistributable -Pci=true --scan
+
+ - name: Upload Desktop artifact
+ if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: reports-${{ matrix.flavor }}-api-${{ matrix.api_level }}
- path: |
- **/build/reports
- **/build/test-results
- **/build/outputs/androidTest-results
+ 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 a965f7f04..2399d1f88 100644
--- a/.github/workflows/scheduled-updates.yml
+++ b/.github/workflows/scheduled-updates.yml
@@ -2,12 +2,12 @@ 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:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
contents: write # To commit files and push branches
@@ -81,22 +81,11 @@ jobs:
- name: Fix file permissions
run: sudo chown -R $USER:$USER .
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
- add-job-summary: always
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Update Graphs
run: ./gradlew graphUpdate
@@ -143,7 +132,8 @@ jobs:
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
needs:
- update_assets
if: always()
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index e0647e27e..f1ae45660 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -12,7 +12,7 @@ permissions:
jobs:
stale_issues:
name: Close Stale Issues
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
@@ -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 633b732fb..447d8a28e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,9 @@ keystore.properties
/fastlane/play-store-credentials.json
**/google-services.json
+# Generated library definitions
+**/src/main/resources/aboutlibraries.json
+
/fastlane/report.xml
/build-logic/convention/build/*
@@ -48,3 +51,8 @@ wireless-install.sh
# Git worktrees
.worktrees/
+/firebase-debug.log.jdk/
+firebase-debug.log
+.agent_plans/
+.agent_refs/
+.agent_artifacts/
diff --git a/.jdk b/.jdk
new file mode 120000
index 000000000..096e1a9e3
--- /dev/null
+++ b/.jdk
@@ -0,0 +1 @@
+/home/james/.jdks/ms-17.0.18
\ No newline at end of file
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/.ruby-version b/.ruby-version
index 7921bd0c8..7bcbb3808 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.4.8
+3.4.9
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 882c6c1f7..c1bafdd96 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,140 +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 workflows.
+
+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.
+
-## 1. Project Overview
+
+- **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.
+
-- **Type:** Native Android Application (Kotlin).
-- **Purpose:** Client interface for Meshtastic mesh radios.
-- **Architecture:** Modern Android Development (MAD) principles.
- - **UI:** Jetpack Compose (Material 3).
- - **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- - **Dependency Injection:** Hilt.
- - **Navigation:** Type-Safe Navigation (Jetpack Navigation).
- - **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
+
+- **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).
+
-## 2. Codebase Map
+
+- **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.
+
-| Directory | Description |
-| :--- | :--- |
-| `app/` | Main application module. Contains `MainActivity`, `AppNavigation`, and Hilt entry points. Uses package `com.geeksville.mesh`. |
-| `core/` | Shared library modules. Most code here uses package `org.meshtastic.core.*`. |
-| `core/ble/` | **New:** Bluetooth Low Energy stack using Nordic libraries. |
-| `core/strings/` | **Crucial:** Centralized string resources using Compose Multiplatform Resources. |
-| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). Each is a standalone Gradle module. Uses package `org.meshtastic.feature.*`. |
-| `build-logic/` | Custom Gradle convention plugins. Defines build logic for the entire project. |
-| `gradle/libs.versions.toml` | **Version Catalog.** All dependencies and versions are defined here. |
-| `core/proto/` | Protobuf definitions for communicating with the mesh radio. |
+
+`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.
-## 3. Development Guidelines
+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.
+
-### A. UI Development (Jetpack Compose)
-- **Material 3:** The app uses Material 3. Look for ways to use **Material 3 Expressive** components where appropriate.
-- **Strings:**
- - Do **not** use `app/src/main/res/values/strings.xml` for UI strings.
- - Use the **Compose Multiplatform Resource** library in `core:resources`.
- - **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- - **Usage:**
- ```kotlin
- import org.jetbrains.compose.resources.stringResource
- import org.meshtastic.core.resources.Res
- import org.meshtastic.core.resources.your_string_key
+
+- **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.
+
- Text(text = stringResource(Res.string.your_string_key))
- ```
-- **Dialogs:**
- - Use the centralized `MeshtasticDialog` for all alerts and confirmation boxes.
- - **Specialized Overloads:** Use `MeshtasticResourceDialog` (for resource-only content) or `MeshtasticTextDialog` (for mixed resource/text content) to reduce boilerplate.
- - **Location:** Defined in `core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt`.
-- **Previews:** Create `@Preview` functions for your Composables to ensure they render correctly.
+
+These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
+section.
-### B. Architecture & State
-- **ViewModels:** Must be annotated with `@HiltViewModel`.
-- **Injection:** Use `@Inject constructor(...)`.
-- **Scopes:** Use `viewModelScope` for coroutines. Avoid `GlobalScope`.
-- **Data Flow:** Expose UI state as `StateFlow` or `Flow`.
+- **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. Navigation
-- The project uses **Type-Safe Navigation** (Kotlin Serialization).
-- Routes are defined in `core/navigation` (e.g., `ContactsRoutes`, `SettingsRoutes`).
-- The main `NavHost` is located in `app/src/main/java/com/geeksville/mesh/ui/Main.kt`.
-
-### D. Bluetooth (BLE)
-- **Library:** Uses **Nordic Semiconductor's Kotlin BLE Library** and **Android Common Libraries**.
-- **Location:** Core logic resides in `core/ble`.
-- **Key Classes:** `BluetoothRepository`, `NordicBleInterface`, `BleConnection`.
-- **Usage:** Use `BluetoothRepository` for scanning and bonding. Use `BleConnection` for managing connections. Avoid legacy `BluetoothAdapter` APIs directly.
-- **Environment Mocking:** Use `LocalEnvironmentOwner` and `MockAndroidEnvironment` to test UI hardware reactions without a real device.
-
-### E. Dependency Management
-- **Never** hardcode versions in `build.gradle.kts` files.
-- **Action:** Add the library and version to `gradle/libs.versions.toml`.
-- **Action:** Apply plugins using the alias from the catalog (e.g., `alias(libs.plugins.meshtastic.android.library)`).
-- **Alpha Libraries:** Do not be shy about using alpha libraries from Google if they provide necessary features.
-
-### F. Build Variants (Flavors)
-- **`google`**: Includes Google Play Services (Maps, Firebase, Crashlytics).
-- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
-- **Task Example:** `./gradlew assembleFdroidDebug`
-
-### G. Kotlin Multiplatform (KMP) & Decoupling
-- **Goal:** We are actively moving logic and models from Android-specific modules to KMP modules (`core:common`, `core:model`, `core:proto`) to support future cross-platform expansion.
-- **Domain Models:** Always place domain models (Data Classes, Enums) in `commonMain` of the respective module.
-- **Parceling:**
- - Use the platform-agnostic `CommonParcelable` and `CommonParcelize` from `core:common`.
- - Avoid direct imports of `android.os.Parcelable` or `kotlinx.parcelize.Parcelize` in `commonMain`.
-- **Platform Abstractions:** Use `expect`/`actual` for platform-specific logic (e.g., `DateFormatter`, `RandomUtils`, `BuildUtils`).
-- **AIDL Compatibility:** AIDL parcelable declarations for models moved to `commonMain` should be relocated to `:core:api` to ensure proper export to consumer modules.
-
-## 4. Quality Assurance
-
-### A. Code Style (Spotless)
-- The project uses **Spotless** to enforce formatting.
-- **Command:** `./gradlew spotlessApply`
-- **Rule:** You **must** run this before submitting any code.
-
-### B. Linting (Detekt)
-- The project uses **Detekt** for static analysis.
-- **Command:** `./gradlew detekt`
-- **Rule:** Ensure zero regressions.
-
-### C. Testing
-- **Unit Tests:** JUnit 4/5 in `src/test/java`. Run with `./gradlew test`.
-- **Compose UI Tests (JVM):** Preferred for component testing. Use **Robolectric** in `src/test/java`.
- - **Important:** Annotate with `@Config(sdk = [34])` if using Java 17 to avoid SDK 35 compatibility issues.
- - **Best Practice:** Pass mocked ViewModels to Composables instead of using Hilt in Robolectric tests.
-- **Instrumented Tests:** For full E2E or Hilt integration tests, use `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
-- **Feature Test:** `./gradlew feature:settings:testGoogleDebug`
-
-## 5. Agent Workflow
-
-1. **Explore First:** Before making changes, read `gradle/libs.versions.toml` and the relevant `build.gradle.kts` to understand the environment.
-2. **Plan:** Identify which modules (`core` or `feature`) need modification.
-3. **Implement:**
- - If adding a string, modify `core:resources`.
- - If adding a dependency, modify `libs.versions.toml` first.
-4. **Verify:**
- - Run `./gradlew spotlessApply` (Essential!).
- - Run `./gradlew detekt`.
- - Run relevant tests (e.g., `./gradlew :feature:settings:testDebugUnitTest`).
-
-## 6. Important Context
-
-- **Protobuf:** Communication with the device uses Protobufs. The definitions are in `core/proto`. This is a Git submodule, but the build system handles it.
-- **Legacy:** Some code in `app/` uses the `com.geeksville.mesh` package. Newer code in `core/` and `feature/` uses `org.meshtastic.*`. Respect the existing package structure of the file you are editing.
-- **Versioning:** Do not manually edit `versionCode` or `versionName`. These are managed by the build system and CI/CD.
-- **Database Safety:** When modifying critical database logic (e.g., `NodeInfoDao`), always ensure you have explicit test coverage for security edge cases (like PKC conflicts or key wiping). Refer to `core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt` for examples.
-
-## 7. Troubleshooting
-
-- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.resources.Res` and the specific string property, and that you have run a build to generate the resources.
-- **Build Errors:** Check `gradle/libs.versions.toml` for version conflicts. Use `build-logic` conventions to ensure plugins are applied correctly.
-
----
-*Refer to `CONTRIBUTING.md` for human-centric processes like Code of Conduct and Pull Request etiquette.*
-
-### E. Resources and Assets
-- **Centralization:** All global app resources (Strings, Drawables, Fonts, raw files) should be placed in `:core:resources`.
-- **Module Path:** `core/resources/src/commonMain/composeResources/`
-- **Decentralization:** Feature-specific strings and assets can (and should) be housed in their respective feature module's `composeResources` directory to maintain modular boundaries and clean architectural dependency graphs. Crowdin localization handles globbing `/**/composeResources/values/strings.xml` perfectly.
-- **Drawables:** Use `painterResource(Res.drawable.your_icon)` to access cross-platform drawables. Name them consistently (`ic_` for icons, `img_` for artwork). Avoid putting standard Drawables or Vectors in legacy Android `res/drawable` folders unless strictly required by a legacy library (like `OsmDroid` map markers) or the OS layer (like `app_icon.xml`).
+
+- **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/CONTRIBUTING.md b/CONTRIBUTING.md
index d64fe9976..d4fe0b740 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,7 +48,7 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
- **Unit tests** are located in the `src/test/` directory of each module.
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
- - Note: If using Java 17, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
+ - Note: If using Java 21, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
#### Guidelines for Testing
diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 000000000..72a350afb
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,6 @@
+# Meshtastic Android - Google Gemini Guide
+
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
+
+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/README.md b/README.md
index 9eed8d9ae..2cc1ffe1c 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
[](https://opencollective.com/meshtastic/)
[](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
-This is a tool for using Android with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware).
+This is a tool for using Android (and Compose Desktop) with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware).
This project is currently beta testing across various providers. If you have questions or feedback please [Join our discussion forum](https://github.com/orgs/meshtastic/discussions) or the [Discord Group](https://discord.gg/meshtastic) . We would love to hear from you!
@@ -51,23 +51,24 @@ You can generate the documentation locally to preview your changes.
1. **Run the Dokka task:**
```bash
- ./gradlew :app:dokkaHtml
+ ./gradlew dokkaGeneratePublicationHtml
```
2. **View the output:**
- The generated HTML files will be located in the `app/build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
+ The generated HTML files will be located in the `build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
## Architecture
### Modern Android Development (MAD)
-The app follows modern Android development practices:
-- **UI:** Jetpack Compose (Material 3).
+The app follows modern Android development practices, built on top of a shared Kotlin Multiplatform (KMP) Core:
+- **KMP Modules:** Business logic (`core:domain`), data sources (`core:data`, `core:database`, `core:datastore`), and communications (`core:network`, `core:ble`) are entirely platform-agnostic, targeting Android and Compose Desktop.
+- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
-- **Dependency Injection:** Hilt.
-- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
-- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
+- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
+- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
+- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
-The BLE stack has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication. See [core/ble/README.md](core/ble/README.md) for details.
+The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
## Translations
@@ -79,6 +80,8 @@ Developers can integrate with the Meshtastic Android app using our published API
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
+Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
+
## Building the Android App
> [!WARNING]
> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..dc4df33df
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,12 @@
+# Security Policy
+
+## Supported Versions
+
+| App Version | Supported |
+| ---------------- | ------------------ |
+| 2.7.x | :white_check_mark: |
+| <= 2.6.x | :x: |
+
+## Reporting a Vulnerability
+
+We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
diff --git a/app/README.md b/app/README.md
index d61f3a418..ff6f5542f 100644
--- a/app/README.md
+++ b/app/README.md
@@ -6,13 +6,13 @@ The `:app` module is the entry point for the Meshtastic Android application. It
## Key Components
### 1. `MainActivity` & `Main.kt`
-The single Activity of the application. It hosts the `NavHost` and manages the root UI structure (Navigation Bar, Rail, etc.).
+The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService`
-The core background service that manages long-running communication with the mesh radio. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
+The core background service that manages long-running communication with the mesh radio. While it is declared in the `:app` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
-### 3. Hilt Application
-`MeshUtilApplication` is the Hilt entry point, providing the global dependency injection container.
+### 3. Koin Application
+`MeshUtilApplication` is the Koin entry point, providing the global dependency injection container.
## Architecture
The module primarily serves as a "glue" layer, connecting:
@@ -25,13 +25,13 @@ The module primarily serves as a "glue" layer, connecting:
```mermaid
graph TB
:app[app]:::android-application
- :app -.-> :core:analytics
:app -.-> :core:ble
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:database
:app -.-> :core:datastore
:app -.-> :core:di
+ :app -.-> :core:domain
:app -.-> :core:model
:app -.-> :core:navigation
:app -.-> :core:network
@@ -42,20 +42,27 @@ graph TB
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
+ :app -.-> :core:takserver
:app -.-> :feature:intro
:app -.-> :feature:messaging
+ :app -.-> :feature:connections
:app -.-> :feature:map
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
+ :app -.-> :feature:wifi-provision
+ :app -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
+classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2a740864b..d239d0530 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -29,11 +29,11 @@ plugins {
alias(libs.plugins.meshtastic.android.application)
alias(libs.plugins.meshtastic.android.application.flavors)
alias(libs.plugins.meshtastic.android.application.compose)
- alias(libs.plugins.meshtastic.hilt)
+ id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
- alias(libs.plugins.devtools.ksp)
alias(libs.plugins.secrets)
alias(libs.plugins.aboutlibraries)
+ id("dev.mokkery")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -44,7 +44,7 @@ if (keystorePropertiesFile.exists()) {
}
configure {
- namespace = configProperties.getProperty("APPLICATION_ID")
+ namespace = "org.meshtastic.app"
signingConfigs {
create("release") {
@@ -150,7 +150,7 @@ configure {
includeInBundle = false
}
- testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Configure existing product flavors (defined by convention plugin)
@@ -171,8 +171,6 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
- isMinifyEnabled = true
- isShrinkResources = true
isDebuggable = false
}
}
@@ -210,15 +208,16 @@ project.afterEvaluate {
}
dependencies {
- implementation(projects.core.analytics)
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)
+ implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
+ implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network)
implementation(projects.core.nfc)
implementation(projects.core.prefs)
@@ -227,83 +226,113 @@ dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
+ implementation(projects.core.takserver)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
+ implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
+ implementation(projects.feature.wifiProvision)
+ implementation(projects.feature.widget)
- implementation(libs.androidx.compose.material3.adaptive)
- implementation(libs.androidx.compose.material3.adaptive.layout)
- implementation(libs.androidx.compose.material3.adaptive.navigation)
- implementation(libs.androidx.compose.material3.navigationSuite)
+ implementation(libs.jetbrains.compose.material3.adaptive)
+ 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)
implementation(libs.androidx.lifecycle.process)
- implementation(libs.androidx.lifecycle.viewmodel.compose)
- implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.androidx.navigation.compose)
- implementation(libs.androidx.paging.compose)
- implementation(libs.coil.network.okhttp)
+ implementation(libs.jetbrains.lifecycle.viewmodel.compose)
+ implementation(libs.jetbrains.lifecycle.runtime.compose)
+ implementation(libs.jetbrains.navigation3.ui)
+ implementation(libs.ktor.client.android)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.coil)
+ implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
- implementation(libs.org.eclipse.paho.client.mqttv3)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
- implementation(libs.androidx.hilt.work)
- ksp(libs.androidx.hilt.compiler)
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose.viewmodel)
+ implementation(libs.koin.androidx.workmanager)
+ implementation(libs.koin.annotations)
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
-
- implementation(libs.nordic.client.android)
- implementation(libs.nordic.common.core)
- implementation(libs.nordic.common.permissions.ble)
- implementation(libs.nordic.common.permissions.notification)
- implementation(libs.nordic.common.scanner.ble)
- implementation(libs.nordic.common.ui)
+ implementation(libs.kotlinx.datetime)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
+ googleImplementation(libs.maps.compose)
+ googleImplementation(libs.maps.compose.utils)
+ googleImplementation(libs.maps.compose.widgets)
+ googleImplementation(libs.dd.sdk.android.logs)
+ googleImplementation(libs.dd.sdk.android.rum)
+ googleImplementation(libs.dd.sdk.android.session.replay)
+ googleImplementation(libs.dd.sdk.android.session.replay.material)
+ googleImplementation(libs.dd.sdk.android.timber)
+ googleImplementation(libs.dd.sdk.android.trace)
+ googleImplementation(libs.dd.sdk.android.trace.otel)
+ googleImplementation(platform(libs.firebase.bom))
+ googleImplementation(libs.firebase.analytics)
+ googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
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.hilt.android.testing)
- androidTestImplementation(libs.kotlinx.coroutines.test)
- androidTestImplementation(libs.androidx.compose.ui.test.junit4)
- androidTestImplementation(libs.nordic.client.android.mock)
- androidTestImplementation(libs.nordic.core.mock)
-
- testImplementation(libs.junit)
- testImplementation(libs.mockk)
+ testImplementation(kotlin("test-junit"))
+ testImplementation(libs.androidx.work.testing)
+ testImplementation(libs.koin.test)
+ testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.kotlinx.coroutines.test)
- testImplementation(libs.nordic.client.android.mock)
- testImplementation(libs.nordic.client.core.mock)
- testImplementation(libs.nordic.core.mock)
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 {
- export { excludeFields = listOf("generated") }
+ // 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 = isReleaseBuild && ghToken.isPresent
+ fetchRemoteFunding = isReleaseBuild && ghToken.isPresent
+ if (ghToken.isPresent) {
+ gitHubApiToken = ghToken.get()
+ }
+ }
+ export {
+ excludeFields = listOf("generated")
+ outputFile = file("src/main/resources/aboutlibraries.json")
+ }
library {
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.SIMPLE
}
}
+
+// Ensure aboutlibraries.json is always up-to-date during the build.
+// This is required since AboutLibraries v11+ no longer auto-exports.
+tasks
+ .matching { it.name.startsWith("process") && it.name.endsWith("Resources") }
+ .configureEach { dependsOn("exportLibraryDefinitions") }
diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt
deleted file mode 100644
index c6b9b1427..000000000
--- a/app/dependencies/googleReleaseRuntimeClasspath.txt
+++ /dev/null
@@ -1,415 +0,0 @@
-androidx.activity:activity-compose:1.12.3
-androidx.activity:activity-ktx:1.12.3
-androidx.activity:activity:1.12.3
-androidx.annotation:annotation-experimental:1.5.1
-androidx.annotation:annotation-jvm:1.9.1
-androidx.annotation:annotation:1.9.1
-androidx.appcompat:appcompat-resources:1.7.1
-androidx.appcompat:appcompat:1.7.1
-androidx.arch.core:core-common:2.2.0
-androidx.arch.core:core-runtime:2.2.0
-androidx.autofill:autofill:1.0.0
-androidx.cardview:cardview:1.0.0
-androidx.collection:collection-jvm:1.5.0
-androidx.collection:collection-ktx:1.5.0
-androidx.collection:collection:1.5.0
-androidx.compose.animation:animation-android:1.11.0-alpha04
-androidx.compose.animation:animation-core-android:1.11.0-alpha04
-androidx.compose.animation:animation-core:1.11.0-alpha04
-androidx.compose.animation:animation:1.11.0-alpha04
-androidx.compose.foundation:foundation-android:1.11.0-alpha04
-androidx.compose.foundation:foundation-layout-android:1.11.0-alpha04
-androidx.compose.foundation:foundation-layout:1.11.0-alpha04
-androidx.compose.foundation:foundation:1.11.0-alpha04
-androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive:1.3.0-alpha07
-androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha13
-androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha13
-androidx.compose.material3:material3-android:1.5.0-alpha13
-androidx.compose.material3:material3:1.5.0-alpha13
-androidx.compose.material:material-android:1.11.0-alpha04
-androidx.compose.material:material-icons-core-android:1.7.8
-androidx.compose.material:material-icons-core:1.7.8
-androidx.compose.material:material-icons-extended-android:1.7.8
-androidx.compose.material:material-icons-extended:1.7.8
-androidx.compose.material:material-ripple-android:1.11.0-alpha04
-androidx.compose.material:material-ripple:1.11.0-alpha04
-androidx.compose.material:material:1.11.0-alpha04
-androidx.compose.runtime:runtime-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-annotation-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-annotation:1.11.0-alpha04
-androidx.compose.runtime:runtime-livedata:1.11.0-alpha04
-androidx.compose.runtime:runtime-retain-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-retain:1.11.0-alpha04
-androidx.compose.runtime:runtime-saveable-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-saveable:1.11.0-alpha04
-androidx.compose.runtime:runtime-tracing:1.11.0-alpha04
-androidx.compose.runtime:runtime:1.11.0-alpha04
-androidx.compose.ui:ui-android:1.11.0-alpha04
-androidx.compose.ui:ui-geometry-android:1.11.0-alpha04
-androidx.compose.ui:ui-geometry:1.11.0-alpha04
-androidx.compose.ui:ui-graphics-android:1.11.0-alpha04
-androidx.compose.ui:ui-graphics:1.11.0-alpha04
-androidx.compose.ui:ui-text-android:1.11.0-alpha04
-androidx.compose.ui:ui-text:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-data-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-data:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-preview-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-preview:1.11.0-alpha04
-androidx.compose.ui:ui-tooling:1.11.0-alpha04
-androidx.compose.ui:ui-unit-android:1.11.0-alpha04
-androidx.compose.ui:ui-unit:1.11.0-alpha04
-androidx.compose.ui:ui-util-android:1.11.0-alpha04
-androidx.compose.ui:ui-util:1.11.0-alpha04
-androidx.compose.ui:ui:1.11.0-alpha04
-androidx.compose:compose-bom-alpha:2026.01.01
-androidx.compose:compose-bom:2026.01.00
-androidx.concurrent:concurrent-futures-ktx:1.1.0
-androidx.concurrent:concurrent-futures:1.1.0
-androidx.constraintlayout:constraintlayout-core:1.0.0
-androidx.constraintlayout:constraintlayout:2.1.0
-androidx.coordinatorlayout:coordinatorlayout:1.1.0
-androidx.core:core-ktx:1.17.0
-androidx.core:core-location-altitude-external-protobuf:1.0.0-beta01
-androidx.core:core-location-altitude-proto:1.0.0-beta01
-androidx.core:core-location-altitude:1.0.0-beta01
-androidx.core:core-splashscreen:1.2.0
-androidx.core:core-viewtree:1.0.0
-androidx.core:core:1.17.0
-androidx.cursoradapter:cursoradapter:1.0.0
-androidx.customview:customview-poolingcontainer:1.0.0
-androidx.customview:customview:1.1.0
-androidx.databinding:viewbinding:8.13.2
-androidx.datastore:datastore-android:1.2.0
-androidx.datastore:datastore-core-android:1.2.0
-androidx.datastore:datastore-core-okio-jvm:1.2.0
-androidx.datastore:datastore-core-okio:1.2.0
-androidx.datastore:datastore-core:1.2.0
-androidx.datastore:datastore-preferences-android:1.2.0
-androidx.datastore:datastore-preferences-core-android:1.2.0
-androidx.datastore:datastore-preferences-core:1.2.0
-androidx.datastore:datastore-preferences-external-protobuf:1.2.0
-androidx.datastore:datastore-preferences-proto:1.2.0
-androidx.datastore:datastore-preferences:1.2.0
-androidx.datastore:datastore:1.2.0
-androidx.documentfile:documentfile:1.0.0
-androidx.drawerlayout:drawerlayout:1.1.1
-androidx.dynamicanimation:dynamicanimation:1.1.0
-androidx.emoji2:emoji2-emojipicker:1.6.0
-androidx.emoji2:emoji2-views-helper:1.6.0
-androidx.emoji2:emoji2:1.6.0
-androidx.exifinterface:exifinterface:1.4.1
-androidx.fragment:fragment-ktx:1.6.2
-androidx.fragment:fragment:1.6.2
-androidx.graphics:graphics-path:1.0.1
-androidx.graphics:graphics-shapes-android:1.0.1
-androidx.graphics:graphics-shapes:1.0.1
-androidx.hilt:hilt-common:1.3.0
-androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0
-androidx.hilt:hilt-lifecycle-viewmodel:1.3.0
-androidx.hilt:hilt-work:1.3.0
-androidx.interpolator:interpolator:1.0.0
-androidx.legacy:legacy-support-core-utils:1.0.0
-androidx.lifecycle:lifecycle-common-java8:2.10.0
-androidx.lifecycle:lifecycle-common-jvm:2.10.0
-androidx.lifecycle:lifecycle-common:2.10.0
-androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
-androidx.lifecycle:lifecycle-livedata-core:2.10.0
-androidx.lifecycle:lifecycle-livedata-ktx:2.10.0
-androidx.lifecycle:lifecycle-livedata:2.10.0
-androidx.lifecycle:lifecycle-process:2.10.0
-androidx.lifecycle:lifecycle-runtime-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-compose:2.10.0
-androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
-androidx.lifecycle:lifecycle-runtime:2.10.0
-androidx.lifecycle:lifecycle-service:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
-androidx.lifecycle:lifecycle-viewmodel:2.10.0
-androidx.loader:loader:1.0.0
-androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
-androidx.metrics:metrics-performance:1.0.0-beta03
-androidx.navigation3:navigation3-runtime-android:1.0.0
-androidx.navigation3:navigation3-runtime:1.0.0
-androidx.navigation3:navigation3-ui-android:1.0.0
-androidx.navigation3:navigation3-ui:1.0.0
-androidx.navigation:navigation-common-android:2.9.7
-androidx.navigation:navigation-common:2.9.7
-androidx.navigation:navigation-compose-android:2.9.7
-androidx.navigation:navigation-compose:2.9.7
-androidx.navigation:navigation-fragment:2.9.7
-androidx.navigation:navigation-runtime-android:2.9.7
-androidx.navigation:navigation-runtime:2.9.7
-androidx.navigationevent:navigationevent-android:1.0.2
-androidx.navigationevent:navigationevent-compose-android:1.0.2
-androidx.navigationevent:navigationevent-compose:1.0.2
-androidx.navigationevent:navigationevent:1.0.2
-androidx.paging:paging-common-android:3.4.0
-androidx.paging:paging-common:3.4.0
-androidx.paging:paging-compose-android:3.4.0
-androidx.paging:paging-compose:3.4.0
-androidx.print:print:1.0.0
-androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11
-androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11
-androidx.profileinstaller:profileinstaller:1.4.1
-androidx.recyclerview:recyclerview:1.3.2
-androidx.resourceinspection:resourceinspection-annotation:1.0.1
-androidx.room:room-common-jvm:2.8.4
-androidx.room:room-common:2.8.4
-androidx.room:room-paging-android:2.8.4
-androidx.room:room-paging:2.8.4
-androidx.room:room-runtime-android:2.8.4
-androidx.room:room-runtime:2.8.4
-androidx.savedstate:savedstate-android:1.4.0
-androidx.savedstate:savedstate-compose-android:1.4.0
-androidx.savedstate:savedstate-compose:1.4.0
-androidx.savedstate:savedstate-ktx:1.4.0
-androidx.savedstate:savedstate:1.4.0
-androidx.slidingpanelayout:slidingpanelayout:1.2.0
-androidx.sqlite:sqlite-android:2.6.2
-androidx.sqlite:sqlite-framework-android:2.6.2
-androidx.sqlite:sqlite-framework:2.6.2
-androidx.sqlite:sqlite:2.6.2
-androidx.startup:startup-runtime:1.2.0
-androidx.tracing:tracing-ktx:1.2.0
-androidx.tracing:tracing-perfetto:1.0.1
-androidx.tracing:tracing:1.2.0
-androidx.transition:transition:1.6.0
-androidx.vectordrawable:vectordrawable-animated:1.1.0
-androidx.vectordrawable:vectordrawable:1.1.0
-androidx.versionedparcelable:versionedparcelable:1.1.1
-androidx.viewpager2:viewpager2:1.1.0-beta02
-androidx.viewpager:viewpager:1.0.0
-androidx.window:window-core-android:1.5.0
-androidx.window:window-core:1.5.0
-androidx.window:window:1.5.0
-androidx.work:work-runtime-ktx:2.11.1
-androidx.work:work-runtime:2.11.1
-co.touchlab:kermit-android:2.0.8
-co.touchlab:kermit-core-android:2.0.8
-co.touchlab:kermit-core:2.0.8
-co.touchlab:kermit:2.0.8
-com.caverock:androidsvg-aar:1.4
-com.datadoghq:dd-sdk-android-compose:3.6.0
-com.datadoghq:dd-sdk-android-core:3.6.0
-com.datadoghq:dd-sdk-android-internal:3.6.0
-com.datadoghq:dd-sdk-android-logs:3.6.0
-com.datadoghq:dd-sdk-android-okhttp:3.6.0
-com.datadoghq:dd-sdk-android-rum:3.6.0
-com.datadoghq:dd-sdk-android-session-replay-compose:3.6.0
-com.datadoghq:dd-sdk-android-session-replay:3.6.0
-com.datadoghq:dd-sdk-android-timber:3.6.0
-com.datadoghq:dd-sdk-android-trace-api:3.6.0
-com.datadoghq:dd-sdk-android-trace-internal:3.6.0
-com.datadoghq:dd-sdk-android-trace-otel:3.6.0
-com.datadoghq:dd-sdk-android-trace:3.6.0
-com.github.mik3y:usb-serial-for-android:3.10.0
-com.google.accompanist:accompanist-drawablepainter:0.37.3
-com.google.accompanist:accompanist-permissions:0.37.3
-com.google.android.datatransport:transport-api:3.2.0
-com.google.android.datatransport:transport-backend-cct:3.3.0
-com.google.android.datatransport:transport-runtime:3.3.0
-com.google.android.gms:play-services-ads-identifier:18.0.0
-com.google.android.gms:play-services-base:18.5.0
-com.google.android.gms:play-services-basement:18.9.0
-com.google.android.gms:play-services-location:21.3.0
-com.google.android.gms:play-services-maps:20.0.0
-com.google.android.gms:play-services-measurement-api:23.0.0
-com.google.android.gms:play-services-measurement-base:23.0.0
-com.google.android.gms:play-services-measurement-impl:23.0.0
-com.google.android.gms:play-services-measurement-sdk-api:23.0.0
-com.google.android.gms:play-services-measurement-sdk:23.0.0
-com.google.android.gms:play-services-measurement:23.0.0
-com.google.android.gms:play-services-stats:17.0.2
-com.google.android.gms:play-services-tasks:18.4.0
-com.google.android.material:material:1.13.0
-com.google.auto.value:auto-value-annotations:1.6.3
-com.google.code.findbugs:jsr305:3.0.2
-com.google.code.gson:gson:2.13.2
-com.google.dagger:dagger-lint-aar:2.59
-com.google.dagger:dagger:2.59
-com.google.dagger:hilt-android:2.59
-com.google.dagger:hilt-core:2.59
-com.google.errorprone:error_prone_annotations:2.41.0
-com.google.firebase:firebase-analytics:23.0.0
-com.google.firebase:firebase-annotations:17.0.0
-com.google.firebase:firebase-bom:34.8.0
-com.google.firebase:firebase-common:22.0.1
-com.google.firebase:firebase-components:19.0.0
-com.google.firebase:firebase-config-interop:16.0.1
-com.google.firebase:firebase-crashlytics:20.0.4
-com.google.firebase:firebase-datatransport:19.0.0
-com.google.firebase:firebase-encoders-json:18.0.1
-com.google.firebase:firebase-encoders-proto:16.0.0
-com.google.firebase:firebase-encoders:17.0.0
-com.google.firebase:firebase-installations-interop:17.2.0
-com.google.firebase:firebase-installations:19.0.1
-com.google.firebase:firebase-measurement-connector:20.0.1
-com.google.firebase:firebase-sessions:3.0.4
-com.google.guava:failureaccess:1.0.3
-com.google.guava:guava:33.5.0-android
-com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
-com.google.j2objc:j2objc-annotations:3.1
-com.google.maps.android:android-maps-utils:4.0.0
-com.google.maps.android:maps-compose-utils:8.0.0
-com.google.maps.android:maps-compose-widgets:8.0.0
-com.google.maps.android:maps-compose:8.0.0
-com.google.maps.android:maps-ktx:6.0.0
-com.google.maps.android:maps-utils-ktx:6.0.0
-com.google.re2j:re2j:1.7
-com.google.zxing:core:3.5.4
-com.jakewharton.timber:timber:5.0.1
-com.journeyapps:zxing-android-embedded:4.3.0
-com.lyft.kronos:kronos-android:0.0.1-alpha11
-com.lyft.kronos:kronos-java:0.0.1-alpha11
-com.mikepenz:aboutlibraries-compose-core-android:13.2.1
-com.mikepenz:aboutlibraries-compose-core:13.2.1
-com.mikepenz:aboutlibraries-compose-m3-android:13.2.1
-com.mikepenz:aboutlibraries-compose-m3:13.2.1
-com.mikepenz:aboutlibraries-core-android:13.2.1
-com.mikepenz:aboutlibraries-core:13.2.1
-com.mikepenz:multiplatform-markdown-renderer-android:0.39.2
-com.mikepenz:multiplatform-markdown-renderer-m3-android:0.39.2
-com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2
-com.mikepenz:multiplatform-markdown-renderer:0.39.2
-com.patrykandpatrick.vico:compose-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m2-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m2:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m3-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m3:3.0.0-beta.3
-com.patrykandpatrick.vico:compose:3.0.0-beta.3
-com.squareup.okhttp3:logging-interceptor:5.3.2
-com.squareup.okhttp3:okhttp-android:5.3.2
-com.squareup.okhttp3:okhttp:5.3.2
-com.squareup.okio:okio-jvm:3.16.4
-com.squareup.okio:okio:3.16.4
-com.squareup.wire:wire-runtime-jvm:5.2.1
-com.squareup.wire:wire-runtime:5.2.1
-io.coil-kt.coil3:coil-android:3.3.0
-io.coil-kt.coil3:coil-compose-android:3.3.0
-io.coil-kt.coil3:coil-compose-core-android:3.3.0
-io.coil-kt.coil3:coil-compose-core:3.3.0
-io.coil-kt.coil3:coil-compose:3.3.0
-io.coil-kt.coil3:coil-core-android:3.3.0
-io.coil-kt.coil3:coil-core:3.3.0
-io.coil-kt.coil3:coil-network-core-android:3.3.0
-io.coil-kt.coil3:coil-network-core:3.3.0
-io.coil-kt.coil3:coil-network-okhttp-jvm:3.3.0
-io.coil-kt.coil3:coil-network-okhttp:3.3.0
-io.coil-kt.coil3:coil-svg-android:3.3.0
-io.coil-kt.coil3:coil-svg:3.3.0
-io.coil-kt.coil3:coil:3.3.0
-io.ktor:ktor-client-content-negotiation-jvm:3.4.0
-io.ktor:ktor-client-content-negotiation:3.4.0
-io.ktor:ktor-client-core-jvm:3.4.0
-io.ktor:ktor-client-core:3.4.0
-io.ktor:ktor-client-okhttp-jvm:3.4.0
-io.ktor:ktor-client-okhttp:3.4.0
-io.ktor:ktor-events-jvm:3.4.0
-io.ktor:ktor-events:3.4.0
-io.ktor:ktor-http-cio-jvm:3.4.0
-io.ktor:ktor-http-cio:3.4.0
-io.ktor:ktor-http-jvm:3.4.0
-io.ktor:ktor-http:3.4.0
-io.ktor:ktor-io-jvm:3.4.0
-io.ktor:ktor-io:3.4.0
-io.ktor:ktor-network-jvm:3.4.0
-io.ktor:ktor-network:3.4.0
-io.ktor:ktor-serialization-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx-json-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx-json:3.4.0
-io.ktor:ktor-serialization-kotlinx-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx:3.4.0
-io.ktor:ktor-serialization:3.4.0
-io.ktor:ktor-sse-jvm:3.4.0
-io.ktor:ktor-sse:3.4.0
-io.ktor:ktor-utils-jvm:3.4.0
-io.ktor:ktor-utils:3.4.0
-io.ktor:ktor-websocket-serialization-jvm:3.4.0
-io.ktor:ktor-websocket-serialization:3.4.0
-io.ktor:ktor-websockets-jvm:3.4.0
-io.ktor:ktor-websockets:3.4.0
-io.opentelemetry:opentelemetry-api:1.40.0
-io.opentelemetry:opentelemetry-context:1.40.0
-jakarta.inject:jakarta.inject-api:2.0.1
-javax.inject:javax.inject:1
-no.nordicsemi.android:dfu:2.10.1
-no.nordicsemi.kotlin.ble:client-android:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:client-core-android:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:client-core:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:core:2.0.0-alpha12
-org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5
-org.jctools:jctools-core:3.3.0
-org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6
-org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6
-org.jetbrains.androidx.savedstate:savedstate:1.3.6
-org.jetbrains.compose.animation:animation-core:1.10.0
-org.jetbrains.compose.animation:animation:1.10.0
-org.jetbrains.compose.annotation-internal:annotation:1.10.0
-org.jetbrains.compose.collection-internal:collection:1.10.0
-org.jetbrains.compose.components:components-resources-android:1.10.0
-org.jetbrains.compose.components:components-resources:1.10.0
-org.jetbrains.compose.foundation:foundation-layout:1.10.0
-org.jetbrains.compose.foundation:foundation:1.10.0
-org.jetbrains.compose.material3:material3:1.9.0
-org.jetbrains.compose.material:material-ripple:1.10.0
-org.jetbrains.compose.material:material:1.10.0
-org.jetbrains.compose.runtime:runtime-saveable:1.10.0
-org.jetbrains.compose.runtime:runtime:1.10.0
-org.jetbrains.compose.ui:ui-backhandler-android:1.9.1
-org.jetbrains.compose.ui:ui-backhandler:1.9.1
-org.jetbrains.compose.ui:ui-geometry:1.10.0
-org.jetbrains.compose.ui:ui-graphics:1.10.0
-org.jetbrains.compose.ui:ui-text:1.10.0
-org.jetbrains.compose.ui:ui-tooling-preview:1.10.0-rc02
-org.jetbrains.compose.ui:ui-unit:1.10.0
-org.jetbrains.compose.ui:ui-util:1.10.0
-org.jetbrains.compose.ui:ui:1.10.0
-org.jetbrains.kotlin:kotlin-bom:1.8.22
-org.jetbrains.kotlin:kotlin-parcelize-runtime:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21
-org.jetbrains.kotlin:kotlin-stdlib:2.3.0
-org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0
-org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0
-org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2
-org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat
-org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat
-org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-core:0.8.2
-org.jetbrains.kotlinx:kotlinx-serialization-bom:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-core:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0
-org.jetbrains:annotations:23.0.0
-org.jetbrains:markdown-jvm:0.7.3
-org.jetbrains:markdown:0.7.3
-org.jspecify:jspecify:1.0.0
-org.slf4j:slf4j-api:2.0.17
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 801f6c2f2..c373eea43 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -1,92 +1,5 @@
-
- CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib
- CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError
- CyclomaticComplexMethod:MeshMessageProcessor.kt$MeshMessageProcessor$private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?)
- CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController)
- EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }
- EmptyFunctionBlock:NopInterface.kt$NopInterface${ }
- EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}
- FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
- FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
- FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
- FinalNewline:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt
- FinalNewline:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt
- FinalNewline:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt
- FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt
- FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt
- FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt
- FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt
- FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt
- FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt
- FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt
- FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
- LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()
- MagicNumber:Contacts.kt$7
- MagicNumber:Contacts.kt$8
- MagicNumber:MQTTRepository.kt$MQTTRepository$512
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
- MagicNumber:ServiceClient.kt$ServiceClient$500
- MagicNumber:StreamInterface.kt$StreamInterface$0xff
- MagicNumber:StreamInterface.kt$StreamInterface$3
- MagicNumber:StreamInterface.kt$StreamInterface$4
- MagicNumber:StreamInterface.kt$StreamInterface$8
- MagicNumber:TCPInterface.kt$TCPInterface$1000
- MagicNumber:UIState.kt$4
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"
- NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
- NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
- NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
- NewLineAtEndOfFile:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt
- NewLineAtEndOfFile:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt
- NewLineAtEndOfFile:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt
- NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt
- NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt
- NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt
- NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt
- NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt
- NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt
- NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt
- NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
- NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$
- NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$
- NoConsecutiveBlankLines:DebugLogFile.kt$
- NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ }
- NoSemicolons:DateUtils.kt$DateUtils$;
- OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract
- RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex
- ReturnCount:MeshDataHandler.kt$MeshDataHandler$@Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket)
- ReturnCount:MeshDataHandler.kt$MeshDataHandler$private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean
- SwallowedException:Exceptions.kt$ex: Throwable
- SwallowedException:NsdManager.kt$ex: IllegalArgumentException
- SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException
- SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
- TooGenericExceptionCaught:Exceptions.kt$ex: Throwable
- TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception
- TooGenericExceptionCaught:MeshDataHandler.kt$MeshDataHandler$e: Exception
- TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception
- TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception
- TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable
- TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable
- TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable
- TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable
- TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Haven't called connect")
- TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound")
- TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout")
- TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")
- TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface
- TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService
- TooManyFunctions:UIState.kt$UIViewModel : ViewModel
- UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule
-
+
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index cc6a76518..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,50 +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
-# Needed for protobufs
--keep class com.google.protobuf.** { *; }
--keep class org.meshtastic.proto.** { *; }
+# 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
-# eclipse.paho.client
--keep class org.eclipse.paho.client.mqttv3.logging.JSR47Logger { *; }
+# ---- Networking (transitive references from Ktor on Android) ----------------
-# OkHttp
--dontwarn okhttp3.internal.platform.**
-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/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
deleted file mode 100644
index 6a701aa8c..000000000
--- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package com.geeksville.mesh.filter
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import dagger.hilt.android.testing.HiltAndroidRule
-import dagger.hilt.android.testing.HiltAndroidTest
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.prefs.filter.FilterPrefs
-import org.meshtastic.core.service.filter.MessageFilterService
-import javax.inject.Inject
-
-@HiltAndroidTest
-@RunWith(AndroidJUnit4::class)
-class MessageFilterIntegrationTest {
-
- @get:Rule var hiltRule = HiltAndroidRule(this)
-
- @Inject lateinit var filterPrefs: FilterPrefs
-
- @Inject lateinit var filterService: MessageFilterService
-
- @Before
- fun setup() {
- hiltRule.inject()
- }
-
- @Test
- fun filterPrefsIntegration() = runTest {
- filterPrefs.filterEnabled = true
- filterPrefs.filterWords = setOf("test", "spam")
- filterService.rebuildPatterns()
-
- assertTrue(filterService.shouldFilter("this is a test message"))
- assertTrue(filterService.shouldFilter("spam content"))
- }
-}
diff --git a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/MarkerClusterer.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java
similarity index 98%
rename from feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/MarkerClusterer.java
rename to app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java
index b6c5601c6..38e51da52 100644
--- a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/MarkerClusterer.java
+++ b/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java
@@ -15,14 +15,14 @@
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.cluster;
+package org.meshtastic.app.map.cluster;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
-import org.meshtastic.feature.map.model.MarkerWithLabel;
+import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.views.MapView;
diff --git a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java
similarity index 98%
rename from feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java
rename to app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java
index 655e9d7b9..e2710352a 100644
--- a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/RadiusMarkerClusterer.java
+++ b/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.cluster;
+package org.meshtastic.app.map.cluster;
import android.content.Context;
import android.graphics.Bitmap;
@@ -27,7 +27,7 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
-import org.meshtastic.feature.map.model.MarkerWithLabel;
+import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.bonuspack.R;
import org.osmdroid.util.BoundingBox;
diff --git a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/StaticCluster.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java
similarity index 95%
rename from feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/StaticCluster.java
rename to app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java
index b49a33f11..324a34b52 100644
--- a/feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/StaticCluster.java
+++ b/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java
@@ -15,9 +15,9 @@
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.cluster;
+package org.meshtastic.app.map.cluster;
-import org.meshtastic.feature.map.model.MarkerWithLabel;
+import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
diff --git a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt
similarity index 64%
rename from core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt
index a8b4532d1..7d0daab08 100644
--- a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.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,29 +14,28 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.analytics
-package org.meshtastic.core.analytics.platform
-
-import androidx.compose.runtime.Composable
-import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import org.meshtastic.core.analytics.BuildConfig
-import org.meshtastic.core.analytics.DataPair
-import javax.inject.Inject
+import org.koin.core.annotation.Single
+import org.meshtastic.app.BuildConfig
+import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.PlatformAnalytics
/**
- * F-Droid specific implementation of [org.meshtastic.analytics.platform.PlatformAnalytics]. This provides no-op
- * implementations for analytics and other platform services.
+ * F-Droid specific implementation of [PlatformAnalytics]. This provides no-op implementations for analytics and other
+ * platform services.
*/
-class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
+@Single
+class FdroidPlatformAnalytics : PlatformAnalytics {
init {
// For F-Droid builds we don't initialize external analytics services.
// In debug builds we attach a DebugTree for convenient local logging, but
// release builds rely on system logging only.
if (BuildConfig.DEBUG) {
Logger.setMinSeverity(Severity.Debug)
- Logger.i { "F-Droid platform no-op analytics initialized (Debug mode }." }
+ Logger.i { "F-Droid platform no-op analytics initialized (Debug mode)." }
} else {
Logger.setMinSeverity(Severity.Info)
Logger.i { "F-Droid platform no-op analytics initialized." }
@@ -48,16 +47,6 @@ class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
Logger.d { "Set device attributes called: firmwareVersion=$firmwareVersion, deviceHardware=$model" }
}
- @Composable
- override fun AddNavigationTrackingEffect(navController: NavHostController) {
- // No-op for F-Droid, but we can log navigation if needed for debugging
- if (BuildConfig.DEBUG) {
- navController.addOnDestinationChangedListener { _, destination, _ ->
- Logger.d { "Navigation changed to: ${destination.route}" }
- }
- }
- }
-
override val isPlatformServicesAvailable: Boolean
get() = false
diff --git a/core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
similarity index 62%
rename from core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
index 538400edc..fba7a417f 100644
--- a/core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.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,40 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.di
-package org.meshtastic.core.network.di
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Single
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
-import org.meshtastic.core.network.BuildConfig
import org.meshtastic.core.network.service.ApiService
-import javax.inject.Singleton
-@InstallIn(SingletonComponent::class)
@Module
class FDroidNetworkModule {
- @Provides
- @Singleton
- fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
- .addInterceptor(
- interceptor =
- HttpLoggingInterceptor().apply {
- if (BuildConfig.DEBUG) {
- setLevel(HttpLoggingInterceptor.Level.BODY)
- }
- },
- )
- .build()
-
- @Provides
- @Singleton
+ @Single
fun provideApiService(): ApiService = object : ApiService {
override suspend fun getDeviceHardware(): List =
throw NotImplementedError("API calls to getDeviceHardware are not supported on Fdroid builds.")
diff --git a/core/di/src/main/kotlin/org/meshtastic/core/di/ProcessLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
similarity index 78%
rename from core/di/src/main/kotlin/org/meshtastic/core/di/ProcessLifecycle.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 76311b20a..5a192d437 100644
--- a/core/di/src/main/kotlin/org/meshtastic/core/di/ProcessLifecycle.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.di
-package org.meshtastic.core.di
+import org.koin.core.annotation.Module
-import javax.inject.Qualifier
-
-@Qualifier
-@Retention(AnnotationRetention.BINARY)
-annotation class ProcessLifecycle
+@Module(includes = [FDroidNetworkModule::class])
+class FlavorModule
diff --git a/feature/intro/src/fdroid/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt b/app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt
similarity index 91%
rename from feature/intro/src/fdroid/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt
index def21ab01..a9065a24a 100644
--- a/feature/intro/src/fdroid/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.intro
+package org.meshtastic.app.intro
import androidx.compose.runtime.Composable
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
new file mode 100644
index 000000000..21c2d4fde
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.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.app.map
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+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, 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,
+ )
+ }
+}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
new file mode 100644
index 000000000..48b1aa7fc
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
@@ -0,0 +1,21 @@
+/*
+ * 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
+
+import org.meshtastic.core.ui.util.MapViewProvider
+
+fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapUtils.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
similarity index 97%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapUtils.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
index 6bad64d44..1243fdc8a 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapUtils.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.content.Context
import android.util.TypedValue
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
similarity index 60%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 4130e57f3..b4d0e1bbd 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -14,13 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+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
@@ -28,29 +26,21 @@ import androidx.compose.foundation.layout.Row
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.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
@@ -59,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
@@ -66,15 +57,12 @@ 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
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -83,14 +71,20 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.R
+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.model.CustomTileSource
+import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.database.entity.Packet
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.util.toString
+import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -100,10 +94,8 @@ 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.heading
-import org.meshtastic.core.resources.latitude
+import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
-import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
@@ -112,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
@@ -121,27 +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.cluster.RadiusMarkerClusterer
-import org.meshtastic.feature.map.component.CacheLayout
-import org.meshtastic.feature.map.component.DownloadButton
-import org.meshtastic.feature.map.component.EditWaypointDialog
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
+import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton
-import org.meshtastic.feature.map.model.CustomTileSource
-import org.meshtastic.feature.map.model.MarkerWithLabel
-import org.meshtastic.feature.map.model.TracerouteOverlay
+import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
-import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@@ -160,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)
@@ -229,16 +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(
- mapViewModel: MapViewModel = hiltViewModel(),
+ 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) }
@@ -337,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()
@@ -344,7 +324,7 @@ fun MapView(
LaunchedEffect(selectedWaypointId, waypoints) {
if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) {
- waypoints[selectedWaypointId]?.data?.waypoint?.let { pt ->
+ waypoints[selectedWaypointId]?.waypoint?.let { pt ->
val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
map.controller.setCenter(geoPoint)
map.controller.setZoom(WAYPOINT_ZOOM)
@@ -352,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
}
@@ -441,7 +365,9 @@ fun MapView(
if (node.batteryStr != "") node.batteryStr else "?",
)
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
- subDescription = getString(Res.string.map_subDescription, ourNode.bearing(node).toString(), dist)
+ ourNode.bearing(node)?.let { bearing ->
+ subDescription = getString(Res.string.map_subDescription, bearing, dist)
+ }
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
@@ -450,7 +376,7 @@ fun MapView(
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
- setPrecisionBits(p.precision_bits ?: 0)
+ setPrecisionBits(p.precision_bits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
@@ -470,7 +396,7 @@ fun MapView(
Logger.d { "User deleted waypoint ${waypoint.id} for me" }
mapViewModel.deleteWaypoint(waypoint.id)
}
- if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
+ if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ ->
Logger.d { "User deleted waypoint ${waypoint.id} for everyone" }
mapViewModel.sendWaypoint(waypoint.copy(expire = 1))
@@ -496,9 +422,9 @@ fun MapView(
fun showMarkerLongPressDialog(id: Int) {
performHapticFeedback()
Logger.d { "marker long pressed id=$id" }
- val waypoint = waypoints[id]?.data?.waypoint ?: return
+ val waypoint = waypoints[id]?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
- if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
+ if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
@@ -512,25 +438,25 @@ fun MapView(
}
@Suppress("MagicNumber")
- fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List {
+ fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List {
return waypoints.mapNotNull { waypoint ->
- val pt = waypoint.data.waypoint ?: return@mapNotNull null
+ val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
- val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
- val time = DateFormatter.formatDateTime(waypoint.received_time)
- val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
- val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
+ val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
+ val time = DateFormatter.formatDateTime(waypoint.time)
+ val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt())
+ val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val now = nowMillis
- val expireTimeMillis = (pt.expire ?: 0) * 1000L
+ val expireTimeMillis = pt.expire * 1000L
val expireTimeStr =
when {
- (pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never"
+ pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
- title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
+ title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
if (selectedWaypointId == pt.id) {
@@ -581,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
@@ -690,52 +569,8 @@ 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 = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
},
@@ -750,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,
)
}
@@ -776,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()
}
- }
- }
+ },
+ )
}
}
}
@@ -941,12 +684,11 @@ fun MapView(
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
- val newId =
- if (waypoint.id == 0) mapViewModel.generatePacketId() ?: return@EditWaypointDialog else waypoint.id
+ val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
- val newExpire = if ((waypoint.expire ?: 0) == 0) Int.MAX_VALUE else (waypoint.expire ?: Int.MAX_VALUE)
- val newLockedTo = if ((waypoint.locked_to ?: 0) != 0) mapViewModel.myNodeNum ?: 0 else 0
- val newIcon = if ((waypoint.icon ?: 0) == 0) 128205 else waypoint.icon
+ val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
+ val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0
+ val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
mapViewModel.sendWaypoint(
waypoint.copy(
@@ -971,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) }
@@ -979,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)
@@ -1022,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))
}
}
@@ -1126,57 +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/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
similarity index 93%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
index 52ae76c25..3cc0dbaf0 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.graphics.Color
import android.graphics.DashPathEffect
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
+import org.meshtastic.app.R
import org.meshtastic.proto.Position
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
@@ -123,17 +124,17 @@ 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 {
+ positions.map { pos ->
Marker(this).apply {
icon = navIcon
- rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
+ rotation = ((pos.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)
+ position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
setOnMarkerClickListener { _, _ ->
- onClick()
+ onClick(pos.time)
true
}
}
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
similarity index 65%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 2029e058d..1ffe68aa1 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -14,47 +14,48 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import androidx.lifecycle.SavedStateHandle
-import androidx.navigation.toRoute
-import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.navigation.MapRoutes
-import org.meshtastic.core.prefs.map.MapPrefs
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.LocalConfig
-import javax.inject.Inject
@Suppress("LongParameterList")
-@HiltViewModel
-class MapViewModel
-@Inject
-constructor(
+@KoinViewModel
+class MapViewModel(
mapPrefs: MapPrefs,
packetRepository: PacketRepository,
- private val nodeRepository: NodeRepository,
- serviceRepository: ServiceRepository,
+ nodeRepository: NodeRepository,
+ radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
-) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) {
+) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
- private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute().waypointId)
+ private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId"))
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
+ fun setWaypointId(id: Int?) {
+ if (_selectedWaypointId.value != id) {
+ _selectedWaypointId.value = id
+ }
+ }
+
var mapStyleId: Int
- get() = mapPrefs.mapStyle
+ get() = mapPrefs.mapStyle.value
set(value) {
- mapPrefs.mapStyle = value
+ mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
@@ -63,6 +64,4 @@ constructor(
get() = localConfig.value
val applicationId = buildConfigProvider.applicationId
-
- override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
}
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
similarity index 78%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewWithLifecycle.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
index a32e49a0a..c16d87163 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewWithLifecycle.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.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,12 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.map
-package org.meshtastic.feature.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
@@ -33,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
@@ -42,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
@@ -137,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()
}
@@ -167,10 +130,7 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer)
- onDispose {
- lifecycle.removeObserver(observer)
- wakeLock.safeRelease()
- }
+ onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
similarity index 99%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
index 7038177d6..112449d1f 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.database.Cursor
import org.meshtastic.core.common.util.nowMillis
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CacheLayout.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
similarity index 98%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CacheLayout.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
index ac8219d81..986918e06 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CacheLayout.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
similarity index 91%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/DownloadButton.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
index 671626241..7568d695a 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/DownloadButton.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
@@ -14,15 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.animation.AnimatedVisibility
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/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
similarity index 91%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
index 0dc57bd4c..c41798bf0 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import android.app.DatePickerDialog
import android.widget.DatePicker
@@ -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
@@ -60,16 +57,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
-import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
@@ -84,9 +78,13 @@ 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
+import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@@ -102,7 +100,7 @@ fun EditWaypointDialog(
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@Suppress("MagicNumber")
- val emoji = if ((waypointInput.icon ?: 0) == 0) 128205 else waypointInput.icon!!
+ val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
@@ -117,11 +115,11 @@ fun EditWaypointDialog(
val currentInstant =
remember(waypointInput.expire) {
- val expire = waypointInput.expire ?: 0
+ val expire = waypointInput.expire
if (expire != 0 && expire != Int.MAX_VALUE) {
- Instant.fromEpochSeconds(expire.toLong())
+ kotlin.time.Instant.fromEpochSeconds(expire.toLong())
} else {
- nowInstant + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
@@ -129,8 +127,8 @@ fun EditWaypointDialog(
var selectedDate by
remember(currentInstant) {
mutableStateOf(
- if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
- dateFormat.format(currentInstant.toDate())
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
@@ -139,8 +137,8 @@ fun EditWaypointDialog(
var selectedTime by
remember(currentInstant) {
mutableStateOf(
- if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
- timeFormat.format(currentInstant.toDate())
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
@@ -164,7 +162,7 @@ fun EditWaypointDialog(
)
EditTextPreference(
title = stringResource(Res.string.name),
- value = waypointInput.name ?: "",
+ value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
@@ -187,7 +185,7 @@ fun EditWaypointDialog(
)
EditTextPreference(
title = stringResource(Res.string.description),
- value = waypointInput.description ?: "",
+ value = waypointInput.description,
maxSize = 99,
enabled = true,
isError = false,
@@ -200,11 +198,14 @@ 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),
- checked = (waypointInput.locked_to ?: 0) != 0,
+ checked = waypointInput.locked_to != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
@@ -227,7 +228,7 @@ fun EditWaypointDialog(
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
- ldt.monthNumber - 1,
+ ldt.month.ordinal,
ldt.day,
)
@@ -257,13 +258,13 @@ fun EditWaypointDialog(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
- imageVector = Icons.Rounded.CalendarMonth,
+ imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Text(stringResource(Res.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
- checked = waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0,
+ checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
@@ -274,7 +275,7 @@ fun EditWaypointDialog(
)
}
- if (waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0) {
+ if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
similarity index 99%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
index 6225471fb..de0f8c6c2 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.map.model
+package org.meshtastic.app.map.model
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/MarkerWithLabel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
similarity index 96%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/MarkerWithLabel.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
index 32ff692a2..da94a7725 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/MarkerWithLabel.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.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,16 +14,15 @@
* 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.app.map.model
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.MotionEvent
-import org.meshtastic.feature.map.dpToPx
-import org.meshtastic.feature.map.spToPx
+import org.meshtastic.app.map.dpToPx
+import org.meshtastic.app.map.spToPx
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
similarity index 88%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
index 16391721e..ac438397a 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.model
+package org.meshtastic.app.map.model
import android.content.res.Resources
import co.touchlab.kermit.Logger
@@ -86,22 +86,6 @@ open class NOAAWmsTileSource(
if (time != null) this.time = time
}
- // fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
- // var srs: String? = "EPSG:900913"
- // if (layer.srs.isNotEmpty()) {
- // srs = layer.srs[0]
- // }
- // return if (layer.styles.isEmpty()) {
- // WMSTileSource(
- // layer.name, arrayOf(endpoint.baseurl), layer.name,
- // endpoint.wmsVersion, srs, null, layer.pixelSize
- // )
- // } else WMSTileSource(
- // layer.name, arrayOf(endpoint.baseurl), layer.name,
- // endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
- // )
- // }
-
private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180
private fun tile2lat(y: Int, z: Int): Double {
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/OnlineTileSourceAuth.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
similarity index 96%
rename from feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/OnlineTileSourceAuth.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
index 4ed0f43dc..3d51133bd 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/OnlineTileSourceAuth.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.map.model
+package org.meshtastic.app.map.model
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
similarity index 80%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
index 430e2c91d..b7795180f 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -14,9 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.node
+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
@@ -24,13 +24,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.feature.map.MapView
+import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- val destNum = node?.num
Scaffold(
topBar = {
@@ -45,8 +44,11 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
- Box(modifier = Modifier.padding(paddingValues)) {
- MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
- }
+ 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/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
new file mode 100644
index 000000000..447765522
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.node.component
+
+import android.view.ViewGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import org.meshtastic.core.model.Node
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.MapView
+import org.osmdroid.views.overlay.Marker
+
+@Composable
+fun InlineMap(node: Node, modifier: Modifier = Modifier) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ val map = remember {
+ MapView(context).apply {
+ layoutParams =
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+
+ // Default osmdroid tile source.
+ setTileSource(TileSourceFactory.MAPNIK)
+ setMultiTouchControls(false)
+
+ controller.setZoom(15.0)
+ }
+ }
+
+ LaunchedEffect(node.num) {
+ val point = GeoPoint(node.latitude, node.longitude)
+
+ map.overlays.clear()
+
+ val marker =
+ Marker(map).apply {
+ position = point
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ }
+ map.overlays.add(marker)
+
+ map.controller.animateTo(point)
+ }
+
+ AndroidView(factory = { map }, modifier = modifier)
+}
diff --git a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
similarity index 66%
rename from feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
index 2a35798f3..d6515eeb7 100644
--- a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.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,15 +14,15 @@
* 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
+package org.meshtastic.app.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
-internal object TracerouteMapOverlayInsets {
- val overlayAlignment: Alignment = Alignment.BottomEnd
- val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
- val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
-}
+fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets(
+ overlayAlignment = Alignment.BottomEnd,
+ overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp),
+ contentHorizontalAlignment = Alignment.End,
+)
diff --git a/feature/map/src/fdroid/res/drawable/ic_location_on.xml b/app/src/fdroid/res/drawable/ic_location_on.xml
similarity index 100%
rename from feature/map/src/fdroid/res/drawable/ic_location_on.xml
rename to app/src/fdroid/res/drawable/ic_location_on.xml
diff --git a/feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml b/app/src/fdroid/res/drawable/ic_map_location_dot.xml
similarity index 100%
rename from feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml
rename to app/src/fdroid/res/drawable/ic_map_location_dot.xml
diff --git a/feature/map/src/fdroid/res/drawable/ic_map_navigation.xml b/app/src/fdroid/res/drawable/ic_map_navigation.xml
similarity index 100%
rename from feature/map/src/fdroid/res/drawable/ic_map_navigation.xml
rename to app/src/fdroid/res/drawable/ic_map_navigation.xml
diff --git a/feature/map/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml
similarity index 100%
rename from feature/map/src/google/AndroidManifest.xml
rename to app/src/google/AndroidManifest.xml
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
similarity index 80%
rename from core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
rename to app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
index 7bd13f840..0583dd78e 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -14,22 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.analytics.platform
+package org.meshtastic.app.analytics
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.provider.Settings
-import androidx.compose.runtime.Composable
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
-import androidx.navigation.NavHostController
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.ExperimentalTrackingApi
-import com.datadog.android.compose.NavigationViewTrackingEffect
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -37,8 +33,11 @@ import com.datadog.android.log.LogsConfiguration
import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum
+import com.datadog.android.rum.RumActionType
import com.datadog.android.rum.RumConfiguration
-import com.datadog.android.rum.tracking.AcceptAllNavDestinations
+import com.datadog.android.sessionreplay.SessionReplay
+import com.datadog.android.sessionreplay.SessionReplayConfiguration
+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
@@ -51,15 +50,15 @@ import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.initialize
-import dagger.hilt.android.qualifiers.ApplicationContext
import io.opentelemetry.api.GlobalOpenTelemetry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.analytics.BuildConfig
-import org.meshtastic.core.analytics.DataPair
-import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
-import javax.inject.Inject
+import org.koin.core.annotation.Single
+import org.meshtastic.app.BuildConfig
+import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.PlatformAnalytics
import co.touchlab.kermit.Logger as KermitLogger
/**
@@ -69,14 +68,11 @@ import co.touchlab.kermit.Logger as KermitLogger
* This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and
* respect privacy-focused environments.
*/
-class GooglePlatformAnalytics
-@Inject
-constructor(
- @ApplicationContext private val context: Context,
- private val analyticsPrefs: AnalyticsPrefs,
-) : PlatformAnalytics {
+@Single
+class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) :
+ PlatformAnalytics {
- private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
+ private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
@@ -109,11 +105,10 @@ constructor(
KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info)
// Initial consent state
- updateAnalyticsConsent(analyticsPrefs.analyticsAllowed)
+ updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value)
// Subscribe to analytics preference changes
- analyticsPrefs
- .getAnalyticsAllowedChangesFlow()
+ analyticsPrefs.analyticsAllowed
.onEach { allowed -> updateAnalyticsConsent(allowed) }
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
@@ -122,7 +117,7 @@ constructor(
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
- if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
+ if (!analyticsPrefs.analyticsAllowed.value || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)
@@ -146,7 +141,7 @@ constructor(
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
- env = if (BuildConfig.DEBUG) "debug" else "release",
+ env = if (BuildConfig.DEBUG) "Local" else "Production",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
@@ -160,7 +155,7 @@ constructor(
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
- .trackBackgroundEvents(false) // Disable background noise
+ .trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
@@ -171,12 +166,20 @@ constructor(
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
- val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
+ val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build()
Trace.enable(traceConfig)
- GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
+ // Session Replay for debug builds only, matching Apple's TestFlight-only gating.
+ // Masks all text inputs to protect message content.
+ if (BuildConfig.DEBUG) {
+ val sessionReplayConfig =
+ SessionReplayConfiguration.Builder(sampleRate)
+ .setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL_INPUTS)
+ .build()
+ SessionReplay.enable(sessionReplayConfig)
+ }
- // Session Replay disabled to reduce PII collection
+ GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
}
private fun initCrashlytics(application: Application) {
@@ -244,16 +247,22 @@ constructor(
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
- @OptIn(ExperimentalTrackingApi::class)
- @Composable
- override fun AddNavigationTrackingEffect(navController: NavHostController) {
- if (Datadog.isInitialized()) {
- NavigationViewTrackingEffect(
- navController = navController,
- trackArguments = true,
- destinationPredicate = AcceptAllNavDestinations(),
- )
+ override fun trackConnect(
+ firmwareVersion: String?,
+ transportType: String?,
+ hardwareModel: String?,
+ nodes: Int,
+ connectionRestored: Boolean,
+ ) {
+ if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
+ val attributes = buildMap {
+ firmwareVersion?.let { put("firmwareVersion", it) }
+ transportType?.let { put("transportType", it) }
+ hardwareModel?.let { put("hardwareModel", it) }
+ put("nodes", nodes)
+ if (connectionRestored) put("connectionRestored", true)
}
+ GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes)
}
private val isGooglePlayAvailable: Boolean
@@ -309,7 +318,7 @@ constructor(
}
private fun String.extractSemanticVersion(): String {
- val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?".toRegex()
+ val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$".toRegex()
val matchResult = regex.find(this)
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this
}
@@ -318,16 +327,16 @@ constructor(
if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
- when (it.value) {
- is Double -> bundle.putDouble(it.name, it.value)
- is Int ->
- bundle.putLong(it.name, it.value.toLong()) // Firebase expects Long for integer values in bundles
- is Long -> bundle.putLong(it.name, it.value)
- is Float -> bundle.putDouble(it.name, it.value.toDouble())
- is String -> bundle.putString(it.name, it.value as String?) // Explicitly handle String
- else -> bundle.putString(it.name, it.value.toString()) // Fallback for other types
+ val value = it.value
+ when (value) {
+ is Double -> bundle.putDouble(it.name, value)
+ is Int -> bundle.putLong(it.name, value.toLong()) // Firebase expects Long for integer values in bundles
+ is Long -> bundle.putLong(it.name, value)
+ is Float -> bundle.putDouble(it.name, value.toDouble())
+ is String -> bundle.putString(it.name, value) // Explicitly handle String
+ else -> bundle.putString(it.name, value.toString()) // Fallback for other types
}
- KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : ${it.value})" }
+ KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : $value)" }
}
Firebase.analytics.logEvent(event, bundle)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
new file mode 100644
index 000000000..802f3b150
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.di
+
+import org.koin.core.annotation.Module
+import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
+
+@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class])
+class FlavorModule
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
similarity index 62%
rename from app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt
rename to app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
index 21bd9a319..eede9d6e3 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.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,18 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.di
-package com.geeksville.mesh.repository.radio
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Single
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.network.service.ApiServiceImpl
-import javax.inject.Inject
+@Module
+class GoogleNetworkModule {
-/**
- * No-op interface backend implementation.
- */
-class NopInterfaceSpec @Inject constructor(
- private val factory: NopInterfaceFactory
-) : InterfaceSpec {
- override fun createInterface(rest: String): NopInterface {
- return factory.create(rest)
- }
+ @Single fun bindApiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl
}
diff --git a/feature/intro/src/google/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt b/app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt
similarity index 99%
rename from feature/intro/src/google/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt
rename to app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt
index 459ca9d82..fdad2c363 100644
--- a/feature/intro/src/google/kotlin/org/meshtastic/feature/intro/AnalyticsIntro.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.intro
+package org.meshtastic.app.intro
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
new file mode 100644
index 000000000..8a441fa70
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
@@ -0,0 +1,21 @@
+/*
+ * 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
+
+import org.meshtastic.core.ui.util.MapViewProvider
+
+fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
new file mode 100644
index 000000000..940c4ab5a
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.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.app.map
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+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, 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,
+ )
+ }
+}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/LocationHandler.kt b/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
similarity index 98%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/LocationHandler.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
index ac4d632ed..1aa4a7bab 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/LocationHandler.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.Manifest
import android.app.Activity
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
similarity index 98%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
index 848779ccf..6ac756f6b 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.database.sqlite.SQLiteDatabase
import com.google.android.gms.maps.model.Tile
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
new file mode 100644
index 000000000..c8f2f3fee
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -0,0 +1,1125 @@
+/*
+ * 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.app.map
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.view.WindowManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+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.setValue
+import androidx.compose.ui.Alignment
+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.lifecycle.compose.collectAsStateWithLifecycle
+import co.touchlab.kermit.Logger
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+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.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
+import com.google.maps.android.compose.MapProperties
+import com.google.maps.android.compose.MapType
+import com.google.maps.android.compose.MapUiSettings
+import com.google.maps.android.compose.MapsComposeExperimentalApi
+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
+import org.koin.compose.viewmodel.koinViewModel
+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.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
+import org.meshtastic.core.model.util.toString
+import org.meshtastic.core.resources.Res
+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.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
+import org.meshtastic.proto.Waypoint
+import kotlin.math.abs
+import kotlin.math.max
+
+// 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
+
+@Suppress("CyclomaticComplexMethod", "LongMethod")
+@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
+@Composable
+fun MapView(
+ modifier: Modifier = Modifier,
+ mapViewModel: MapViewModel = koinViewModel(),
+ navigateToNodeDetails: (Int) -> Unit = {},
+ mode: GoogleMapMode = GoogleMapMode.Main,
+) {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
+
+ // --- Location permissions ---
+ val locationPermissionsState =
+ rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
+
+ // --- Location tracking ---
+ var isLocationTrackingEnabled by remember { mutableStateOf(false) }
+ var followPhoneBearing by remember { mutableStateOf(false) }
+
+ LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
+ if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
+ isLocationTrackingEnabled = true
+ triggerLocationToggleAfterPermission = false
+ }
+ }
+
+ // --- File picker for map layers (Main mode) ---
+ val filePickerLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ result.data?.data?.let { uri ->
+ val fileName = uri.getFileName(context)
+ mapViewModel.addMapLayer(uri, fileName)
+ }
+ }
+ }
+
+ // --- UI state ---
+ var mapFilterMenuExpanded by remember { mutableStateOf(false) }
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
+ var editingWaypoint by remember { mutableStateOf(null) }
+
+ val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
+ val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
+
+ var mapTypeMenuExpanded by remember { mutableStateOf(false) }
+ var showCustomTileManagerSheet by remember { mutableStateOf(false) }
+
+ // --- Camera ---
+ // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
+ val cameraPositionState =
+ if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
+
+ if (mode is GoogleMapMode.Main) {
+ LaunchedEffect(cameraPositionState.isMoving) {
+ if (!cameraPositionState.isMoving) {
+ mapViewModel.saveCameraPosition(cameraPositionState.position)
+ }
+ }
+ }
+
+ // --- FusedLocation ---
+ val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
+ val locationCallback = remember {
+ object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult) {
+ if (isLocationTrackingEnabled) {
+ locationResult.lastLocation?.let { location ->
+ val latLng = LatLng(location.latitude, location.longitude)
+ val cameraUpdate =
+ if (followPhoneBearing) {
+ val bearing =
+ if (location.hasBearing()) {
+ location.bearing
+ } else {
+ cameraPositionState.position.bearing
+ }
+ CameraUpdateFactory.newCameraPosition(
+ CameraPosition.Builder()
+ .target(latLng)
+ .zoom(cameraPositionState.position.zoom)
+ .bearing(bearing)
+ .build(),
+ )
+ } else {
+ CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
+ }
+ coroutineScope.launch {
+ try {
+ cameraPositionState.animate(cameraUpdate)
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error animating camera to location: ${e.message}" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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)
+ Logger.d { "Started location tracking" }
+ } catch (e: SecurityException) {
+ Logger.d { "Location permission not available: ${e.message}" }
+ isLocationTrackingEnabled = false
+ }
+ } else {
+ fusedLocationClient.removeLocationUpdates(locationCallback)
+ Logger.d { "Stopped location tracking" }
+ }
+ }
+
+ 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 filteredNodes =
+ allNodes
+ .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
+ .filter { node ->
+ mapFilterState.lastHeardFilter.seconds == 0L ||
+ (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
+ node.num == ourNodeInfo?.num
+ }
+
+ val myNodeNum = mapViewModel.myNodeNum
+ val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
+ val theme by mapViewModel.theme.collectAsStateWithLifecycle()
+ val dark =
+ when (theme) {
+ AppCompatDelegate.MODE_NIGHT_YES -> true
+ AppCompatDelegate.MODE_NIGHT_NO -> false
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
+ else -> isSystemInDarkTheme()
+ }
+ 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()
+ }
+
+ // 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 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) {
+ when {
+ tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
+ tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
+ else -> emptyList()
+ }
+ }
+ val tracerouteForwardOffsetPoints =
+ remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
+ offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0)
+ }
+ val tracerouteReturnOffsetPoints =
+ remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
+ offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0)
+ }
+
+ // 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 = {
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ val mimeTypes =
+ arrayOf(
+ "application/vnd.google-earth.kml+xml",
+ "application/vnd.google-earth.kmz",
+ "application/vnd.geo+json",
+ "application/geo+json",
+ "application/json",
+ )
+ putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
+ }
+ filePickerLauncher.launch(intent)
+ }
+ val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
+ val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
+
+ 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)
+ }
+ }
+
+ // --- Main UI ---
+ val isMainMode = mode is GoogleMapMode.Main
+
+ Box(modifier = modifier) {
+ GoogleMap(
+ mapColorScheme = mapColorScheme,
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ uiSettings =
+ MapUiSettings(
+ zoomControlsEnabled = true,
+ mapToolbarEnabled = isMainMode,
+ compassEnabled = false,
+ myLocationButtonEnabled = false,
+ rotationGesturesEnabled = true,
+ scrollGesturesEnabled = true,
+ tiltGesturesEnabled = isMainMode,
+ zoomGesturesEnabled = true,
+ ),
+ properties =
+ MapProperties(
+ mapType = effectiveGoogleMapType,
+ isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
+ ),
+ onMapLongClick = { latLng ->
+ if (isMainMode && isConnected) {
+ editingWaypoint =
+ Waypoint(
+ latitude_i = (latLng.latitude / DEG_D).toInt(),
+ longitude_i = (latLng.longitude / DEG_D).toInt(),
+ )
+ }
+ },
+ ) {
+ // Custom tile overlay (all modes)
+ key(currentCustomTileProviderUrl) {
+ currentCustomTileProviderUrl?.let { url ->
+ val config =
+ mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
+ it.urlTemplate == url || it.localUri == url
+ }
+ mapViewModel.getTileProvider(config)?.let { tileProvider ->
+ TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
+ }
+ }
+ }
+
+ 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,
+ )
+ }
+ }
+
+ is GoogleMapMode.Traceroute ->
+ TracerouteMapContent(
+ forwardOffsetPoints = tracerouteForwardOffsetPoints,
+ returnOffsetPoints = tracerouteReturnOffsetPoints,
+ forwardPointCount = tracerouteForwardPoints.size,
+ returnPointCount = tracerouteReturnPoints.size,
+ displayNodes = tracerouteDisplayNodes,
+ )
+ }
+ }
+
+ // Scale bar
+ ScaleBar(
+ cameraPositionState = cameraPositionState,
+ modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp),
+ )
+
+ // 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),
+ 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 },
+ )
+ },
+ isLocationTrackingEnabled = isLocationTrackingEnabled,
+ onToggleLocationTracking = {
+ if (locationPermissionsState.allPermissionsGranted) {
+ isLocationTrackingEnabled = !isLocationTrackingEnabled
+ if (!isLocationTrackingEnabled) {
+ followPhoneBearing = false
+ }
+ } else {
+ triggerLocationToggleAfterPermission = true
+ locationPermissionsState.launchMultiplePermissionRequest()
+ }
+ },
+ bearing = cameraPositionState.position.bearing,
+ onCompassClick = {
+ if (isLocationTrackingEnabled) {
+ followPhoneBearing = !followPhoneBearing
+ } else {
+ coroutineScope.launch {
+ try {
+ val currentPosition = cameraPositionState.position
+ val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
+ cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
+ Logger.d { "Oriented map to north" }
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error orienting map to north: ${e.message}" }
+ }
+ }
+ }
+ },
+ followPhoneBearing = followPhoneBearing,
+ showRefresh = showRefresh,
+ isRefreshing = isRefreshingLayers,
+ onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
+ )
+ }
+
+ // --- Bottom sheets & dialogs ---
+ if (showLayersBottomSheet) {
+ ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
+ CustomMapLayersSheet(
+ mapLayers = mapLayers,
+ onToggleVisibility = onToggleVisibility,
+ onRemoveLayer = onRemoveLayer,
+ onAddLayerClicked = onAddLayerClicked,
+ onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
+ onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
+ )
+ }
+ }
+ showClusterItemsDialog?.let {
+ ClusterItemsListDialog(
+ items = it,
+ onDismiss = { showClusterItemsDialog = null },
+ onItemClick = { item ->
+ navigateToNodeDetails(item.node.num)
+ showClusterItemsDialog = null
+ },
+ )
+ }
+ if (showCustomTileManagerSheet) {
+ ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
+ CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
+ }
+ }
+}
+
+// region --- Main Map Content ---
+
+@Suppress("LongParameterList")
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+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(),
+ ),
+ )
+ }
+ Logger.d { "Cluster clicked! $cluster" }
+ }
+ true
+ },
+ )
+
+ WaypointMarkers(
+ displayableWaypoints = displayableWaypoints,
+ mapFilterState = mapFilterState,
+ myNodeNum = myNodeNum ?: 0,
+ isConnected = isConnected,
+ onEditWaypointRequest = onEditWaypointRequest,
+ selectedWaypointId = selectedWaypointId,
+ )
+
+ mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
+}
+
+// endregion
+
+// region --- Node Track Overlay ---
+
+/**
+ * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
+ * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
+ * [TripOrigin] dot with an info-window on tap.
+ *
+ * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
+ * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
+ */
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+@Suppress("LongMethod")
+private fun NodeTrackOverlay(
+ focusedNode: Node,
+ sortedPositions: List,
+ displayUnits: DisplayUnits,
+ myNodeNum: Int?,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+) {
+ val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
+ val activeNodeZIndex = if (isHighPriority) 5f else 4f
+ val selectedColor = MaterialTheme.colorScheme.primary
+
+ sortedPositions.forEachIndexed { index, position ->
+ key(position.time) {
+ 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)
+ }
+
+ 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,
+ )
+ }
+ }
+ }
+ }
+
+ // 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
+@Suppress("LongMethod")
+private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
+ @Composable
+ fun PositionRow(label: String, value: String) {
+ Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.labelMedium)
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(value, style = MaterialTheme.typography.labelMedium)
+ }
+ }
+
+ Card {
+ Column(modifier = Modifier.padding(8.dp)) {
+ PositionRow(
+ 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.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())
+ }
+ }
+}
+
+@Composable
+private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
+ val speedInMps = position.ground_speed ?: 0
+ val mpsText = "%d m/s".format(speedInMps)
+ return if (speedInMps > 10) {
+ when (displayUnits) {
+ DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
+ DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
+ else -> mpsText
+ }
+ } else {
+ mpsText
+ }
+}
+
+// endregion
+
+// region --- Traceroute Map Content ---
+
+@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,
+ 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 -> 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])
+ }
+ }
+
+ return points.mapIndexed { index, point ->
+ val heading = headings[index.coerceIn(0, headings.lastIndex)]
+ val perpendicularHeading = heading + (90.0 * sideMultiplier)
+ 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/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
similarity index 84%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 03a4cc8c5..e4eabbb76 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -14,14 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map
+package org.meshtastic.app.map
import android.app.Application
import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
@@ -29,8 +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 dagger.hilt.android.lifecycle.HiltViewModel
-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
@@ -43,17 +45,19 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
-import org.meshtastic.core.data.model.CustomTileProviderConfig
-import org.meshtastic.core.data.repository.CustomTileProviderRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.datastore.UiPreferencesDataSource
-import org.meshtastic.core.navigation.MapRoutes
-import org.meshtastic.core.prefs.map.GoogleMapsPrefs
-import org.meshtastic.core.prefs.map.MapPrefs
-import org.meshtastic.core.service.ServiceRepository
+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
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
import java.io.File
import java.io.FileOutputStream
@@ -61,7 +65,6 @@ import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URL
-import javax.inject.Inject
import kotlin.uuid.Uuid
private const val TILE_SIZE = 256
@@ -76,29 +79,45 @@ data class MapCameraPosition(
)
@Suppress("TooManyFunctions", "LongParameterList")
-@HiltViewModel
-class MapViewModel
-@Inject
-constructor(
+@KoinViewModel
+class MapViewModel(
private val application: Application,
+ private val dispatchers: CoroutineDispatchers,
+ private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
- serviceRepository: ServiceRepository,
+ radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
- uiPreferencesDataSource: UiPreferencesDataSource,
+ uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
-) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) {
+) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
- private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute().waypointId)
+ private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId"))
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
+ fun setWaypointId(id: Int?) {
+ if (_selectedWaypointId.value != id) {
+ _selectedWaypointId.value = id
+ if (id != null) {
+ viewModelScope.launch {
+ val wpMap = waypoints.first { it.containsKey(id) }
+ wpMap[id]?.let { packet ->
+ val waypoint = packet.waypoint!!
+ val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
+ cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
+ }
+ }
+ }
+ }
+ }
+
private val targetLatLng =
- googleMapsPrefs.cameraTargetLat
+ googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }
- ?.let { lat -> googleMapsPrefs.cameraTargetLng.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
+ ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?: ourNodeInfo.value?.position?.toLatLng()
?: LatLng(0.0, 0.0)
@@ -107,13 +126,13 @@ constructor(
position =
CameraPosition(
targetLatLng,
- googleMapsPrefs.cameraZoom,
- googleMapsPrefs.cameraTilt,
- googleMapsPrefs.cameraBearing,
+ googleMapsPrefs.cameraZoom.value,
+ googleMapsPrefs.cameraTilt.value,
+ googleMapsPrefs.cameraBearing.value,
),
)
- val theme: StateFlow = uiPreferencesDataSource.theme
+ val theme: StateFlow = uiPrefs.theme
private val _errorFlow = MutableSharedFlow()
val errorFlow: SharedFlow = _errorFlow.asSharedFlow()
@@ -222,7 +241,7 @@ constructor(
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
}
if (configToRemove.localUri != null) {
@@ -238,28 +257,28 @@ constructor(
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
return
}
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
- googleMapsPrefs.selectedCustomTileUrl = selectedUrl
- googleMapsPrefs.selectedGoogleMapType = null
+ googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
+ googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
- googleMapsPrefs.selectedCustomTileUrl = null
- googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
+ googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
- googleMapsPrefs.selectedGoogleMapType = mapType.name
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
@@ -344,7 +363,7 @@ constructor(
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
wpMap[wpId]?.let { packet ->
- val waypoint = packet.data.waypoint!!
+ val waypoint = packet.waypoint!!
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
}
@@ -354,16 +373,16 @@ constructor(
fun saveCameraPosition(cameraPosition: CameraPosition) {
viewModelScope.launch {
- googleMapsPrefs.cameraTargetLat = cameraPosition.target.latitude
- googleMapsPrefs.cameraTargetLng = cameraPosition.target.longitude
- googleMapsPrefs.cameraZoom = cameraPosition.zoom
- googleMapsPrefs.cameraTilt = cameraPosition.tilt
- googleMapsPrefs.cameraBearing = cameraPosition.bearing
+ googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
+ googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
+ googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
+ googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
+ googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
}
}
private fun loadPersistedMapType() {
- val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl
+ val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
@@ -375,31 +394,31 @@ constructor(
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
- val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType
+ val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
- googleMapsPrefs.selectedGoogleMapType = null
+ googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
}
private fun loadPersistedLayers() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
- val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls
+ val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
@@ -429,7 +448,7 @@ constructor(
}
val networkItems =
- googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
+ googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
@@ -532,14 +551,14 @@ constructor(
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
- googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
+ googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
}
}
- 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")
@@ -572,9 +591,9 @@ constructor(
toggledLayer?.let {
if (it.isVisible) {
- googleMapsPrefs.hiddenLayerUrls -= it.uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
- googleMapsPrefs.hiddenLayerUrls += it.uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
@@ -584,12 +603,13 @@ constructor(
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
- googleMapsPrefs.networkMapLayers =
- googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
+ googleMapsPrefs.setNetworkMapLayers(
+ googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
+ )
} else {
deleteFileToInternalStorage(uri)
}
- googleMapsPrefs.hiddenLayerUrls -= uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
@@ -609,7 +629,7 @@ constructor(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(Dispatchers.IO) {
+ withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -624,11 +644,15 @@ constructor(
@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)
}
@@ -643,6 +667,9 @@ constructor(
super.onCleared()
(currentTileProvider as? MBTilesProvider)?.close()
}
+
+ override fun getUser(userId: String?) =
+ nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
}
enum class LayerType {
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/ClusterItemsListDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
similarity index 96%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/ClusterItemsListDialog.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
index 6a03e663d..5c5e325ac 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/ClusterItemsListDialog.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
@@ -30,11 +30,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes_at_this_location
import org.meshtastic.core.resources.okay
import org.meshtastic.core.ui.component.NodeChip
-import org.meshtastic.feature.map.model.NodeClusterItem
@Composable
fun ClusterItemsListDialog(
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
similarity index 90%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
index 51c655f32..fd9272579 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -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
@@ -49,6 +45,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.app.map.MapLayerItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
@@ -65,7 +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.feature.map.MapLayerItem
+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/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
similarity index 96%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
index e65f5968d..8082e40d1 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -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
@@ -51,7 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.data.model.CustomTileProviderConfig
+import org.meshtastic.app.map.MapViewModel
+import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
import org.meshtastic.core.resources.add_local_mbtiles_file
@@ -70,8 +68,10 @@ 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
-import org.meshtastic.feature.map.MapViewModel
@Suppress("LongMethod")
@Composable
@@ -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/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
similarity index 93%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
index 8e423dea6..18eb0ac83 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import android.app.DatePickerDialog
import android.app.TimePickerDialog
@@ -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
@@ -68,9 +64,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.systemTimeZone
-import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
@@ -84,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
@@ -122,13 +119,13 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0
if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
- val instant = Instant.fromEpochSeconds(expireValue.toLong())
- val date = instant.toDate()
+ 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 = nowInstant + 8.hours
- val date = futureInstant.toDate()
+ val futureInstant = kotlin.time.Clock.System.now() + 8.hours
+ val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
@@ -192,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))
@@ -211,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))
@@ -225,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 = nowInstant + 8.hours
+ val futureInstant = kotlin.time.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
@@ -239,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 {
- nowInstant + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
val ldt = currentInstant.toLocalDateTime(tz)
@@ -254,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 {
- nowInstant + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
@@ -289,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 {
- nowInstant + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
similarity index 90%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
index 6314823bd..d8e29120e 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
@@ -14,14 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.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
@@ -39,13 +35,17 @@ 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.app.map.MapViewModel
import org.meshtastic.core.resources.Res
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 org.meshtastic.feature.map.MapViewModel
import kotlin.math.roundToInt
@Composable
@@ -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/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
similarity index 89%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapTypeDropdown.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
index e3722ac29..ad4bd58bb 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapTypeDropdown.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
@@ -14,10 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
-import androidx.compose.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
@@ -28,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.maps.android.compose.MapType
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.map_type_hybrid
@@ -35,7 +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.feature.map.MapViewModel
+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/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
similarity index 97%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
index 41c895c84..32e250475 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,8 +32,8 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
+import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
-import org.meshtastic.feature.map.model.NodeClusterItem
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
similarity index 96%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
index f42d978af..5403b8c11 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
@@ -30,7 +30,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
@Composable
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
similarity index 75%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index 7072a6ae2..61cdab9f1 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -14,32 +14,38 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
+import androidx.compose.foundation.layout.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/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
similarity index 96%
rename from core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
index 434aa834e..a28b3b6c1 100644
--- a/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.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.core.data.model
+package org.meshtastic.app.map.model
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
similarity index 90%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
index d9dcc910b..4adb7d97d 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/CustomTileSource.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.feature.map.model
+package org.meshtastic.app.map.model
class CustomTileSource {
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
similarity index 95%
rename from feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
index 1930438fc..943d2c826 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
@@ -14,11 +14,11 @@
* 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.app.map.model
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Node
data class NodeClusterItem(
val node: Node,
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
new file mode 100644
index 000000000..fa17fedbf
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+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.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
+
+@Composable
+fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
+ val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
+ val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = node?.user?.long_name ?: "",
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ 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
new file mode 100644
index 000000000..668dedbaa
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.prefs.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.SharedPreferencesMigration
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+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.app.map")
+class GoogleMapsKoinModule {
+
+ @Single
+ @Named("GoogleMapsDataStore")
+ fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = CoroutineScope(dispatchers.io + SupervisorJob()),
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
+}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
new file mode 100644
index 000000000..6cf6091b1
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
@@ -0,0 +1,196 @@
+/*
+ * 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.prefs.map
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.doublePreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import com.google.maps.android.compose.MapType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+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
+
+/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
+interface GoogleMapsPrefs {
+ val selectedGoogleMapType: StateFlow
+
+ fun setSelectedGoogleMapType(value: String?)
+
+ val selectedCustomTileUrl: StateFlow
+
+ fun setSelectedCustomTileUrl(value: String?)
+
+ val hiddenLayerUrls: StateFlow>
+
+ fun setHiddenLayerUrls(value: Set)
+
+ val cameraTargetLat: StateFlow
+
+ fun setCameraTargetLat(value: Double)
+
+ val cameraTargetLng: StateFlow
+
+ fun setCameraTargetLng(value: Double)
+
+ val cameraZoom: StateFlow
+
+ fun setCameraZoom(value: Float)
+
+ val cameraTilt: StateFlow
+
+ fun setCameraTilt(value: Float)
+
+ val cameraBearing: StateFlow
+
+ fun setCameraBearing(value: Float)
+
+ val networkMapLayers: StateFlow>
+
+ fun setNetworkMapLayers(value: Set)
+}
+
+@Single
+class GoogleMapsPrefsImpl(
+ @Named("GoogleMapsDataStore") private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : GoogleMapsPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val selectedGoogleMapType: StateFlow =
+ dataStore.data
+ .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
+ .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
+
+ override fun setSelectedGoogleMapType(value: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (value == null) {
+ prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
+ } else {
+ prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
+ }
+ }
+ }
+ }
+
+ override val selectedCustomTileUrl: StateFlow =
+ dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override fun setSelectedCustomTileUrl(value: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (value == null) {
+ prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
+ } else {
+ prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
+ }
+ }
+ }
+ }
+
+ override val hiddenLayerUrls: StateFlow> =
+ dataStore.data
+ .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
+ }
+
+ override val cameraTargetLat: StateFlow =
+ dataStore.data
+ .map {
+ try {
+ it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0
+ } catch (_: ClassCastException) {
+ it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0
+ }
+ }
+ .stateIn(scope, SharingStarted.Eagerly, 0.0)
+
+ override fun setCameraTargetLat(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
+ }
+
+ override val cameraTargetLng: StateFlow =
+ dataStore.data
+ .map {
+ try {
+ it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0
+ } catch (_: ClassCastException) {
+ it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0
+ }
+ }
+ .stateIn(scope, SharingStarted.Eagerly, 0.0)
+
+ override fun setCameraTargetLng(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
+ }
+
+ override val cameraZoom: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
+
+ override fun setCameraZoom(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
+ }
+
+ override val cameraTilt: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
+
+ override fun setCameraTilt(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
+ }
+
+ override val cameraBearing: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
+
+ override fun setCameraBearing(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
+ }
+
+ override val networkMapLayers: StateFlow> =
+ dataStore.data
+ .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
+ }
+
+ companion object {
+ val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
+ val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
+ val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
+ val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
+ val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
+ val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
+ val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
+ val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
+ val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
+ }
+}
diff --git a/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt b/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
similarity index 90%
rename from core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt
rename to app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
index 9ce615f53..6840cb17d 100644
--- a/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.core.data.repository
+package org.meshtastic.app.map.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
@@ -24,11 +23,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
-import org.meshtastic.core.data.model.CustomTileProviderConfig
+import org.koin.core.annotation.Single
+import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.prefs.map.MapTileProviderPrefs
-import javax.inject.Inject
-import javax.inject.Singleton
+import org.meshtastic.core.repository.MapTileProviderPrefs
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow>
@@ -42,10 +40,8 @@ interface CustomTileProviderRepository {
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}
-@Singleton
-class CustomTileProviderRepositoryImpl
-@Inject
-constructor(
+@Single
+class CustomTileProviderRepositoryImpl(
private val json: Json,
private val dispatchers: CoroutineDispatchers,
private val mapTileProviderPrefs: MapTileProviderPrefs,
@@ -82,7 +78,7 @@ constructor(
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
- val jsonString = mapTileProviderPrefs.customTileProviders
+ val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString>(jsonString)
@@ -99,7 +95,7 @@ constructor(
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
- mapTileProviderPrefs.customTileProviders = jsonString
+ mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
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/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
similarity index 95%
rename from feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt
rename to app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
index bb4c0fbe8..c86e7a78c 100644
--- a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.component
+package org.meshtastic.app.node.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
@@ -31,7 +31,7 @@ import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
@@ -39,7 +39,7 @@ private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
-internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
+fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val mapColorScheme =
when (dark) {
diff --git a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
new file mode 100644
index 000000000..992edf588
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.node.metrics
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
+
+fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets(
+ overlayAlignment = Alignment.BottomCenter,
+ overlayPadding = PaddingValues(bottom = 16.dp),
+ contentHorizontalAlignment = Alignment.CenterHorizontally,
+)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3c0e623aa..f7d2ce900 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,11 +44,14 @@
-
-
+
+
+
+
+
@@ -171,7 +174,7 @@
@@ -198,7 +201,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -228,7 +252,7 @@
android:resource="@xml/device_filter" />
-
@@ -252,19 +276,19 @@
android:path="com.geeksville.mesh" /> -->
-
-
-
+
+
+
+ android:resource="@xml/widget_local_stats_info" />
@@ -277,6 +301,17 @@
+
+
+
+
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 0699ff16b..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"
@@ -1236,12 +1236,28 @@
"rak_3312.svg"
]
},
+ {
+ "hwModel": 112,
+ "hwModelSlug": "M5STACK_CARDPUTER_ADV",
+ "platformioTarget": "m5stack-cardputer-adv",
+ "architecture": "esp32-s3",
+ "activelySupported": false,
+ "supportLevel": 1,
+ "displayName": "Cardputer Mesh Kit",
+ "tags": [
+ "M5Stack"
+ ],
+ "images": [
+ "m5stack_cardputer.svg"
+ ],
+ "partitionScheme": "8MB"
+ },
{
"hwModel": 113,
"hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2",
"platformioTarget": "heltec-wireless-tracker-v2",
- "architecture": "esp32s3",
- "activelySupported": false,
+ "architecture": "esp32-s3",
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "Heltec Wireless Tracker V2",
"tags": [
@@ -1273,7 +1289,7 @@
"hwModelSlug": "WISMESH_TAP_V2",
"platformioTarget": "rak_wismesh_tap_v2",
"architecture": "esp32-s3",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "RAK WisMesh Tap V2",
"tags": [
@@ -1306,7 +1322,7 @@
"hwModelSlug": "THINKNODE_M4",
"platformioTarget": "thinknode_m4",
"architecture": "nrf52840",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "ThinkNode M4",
"tags": [
@@ -1322,7 +1338,7 @@
"hwModelSlug": "THINKNODE_M6",
"platformioTarget": "thinknode_m6",
"architecture": "nrf52840",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "ThinkNode M6",
"tags": [
@@ -1349,5 +1365,38 @@
"images": [
"tbeam-1w.svg"
]
+ },
+ {
+ "hwModel": 123,
+ "hwModelSlug": "T5_S3_EPAPER_PRO",
+ "platformioTarget": "t5-epaper-s3",
+ "architecture": "esp32-s3",
+ "activelySupported": false,
+ "supportLevel": 1,
+ "displayName": "LilyGo T5 E-paper S3 Pro",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": false,
+ "partitionScheme": "8MB",
+ "images": [
+ "t5s3_epaper.svg"
+ ]
+ },
+ {
+ "hwModel": 125,
+ "hwModelSlug": "MINI_EPAPER_S3",
+ "platformioTarget": "mini-epaper-s3",
+ "architecture": "esp32-s3",
+ "activelySupported": false,
+ "supportLevel": 1,
+ "displayName": "LilyGo Mini E-paper S3",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": false,
+ "images": [
+ "mini-epaper-s3.svg"
+ ]
}
]
\ No newline at end of file
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 01aeacbf8..ffdb465d6 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,6 +24,27 @@
}
],
"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.20.6658ec2...v2.7.20.6658ec2"
+ },
{
"id": "v2.7.19.bb3d6d5",
"title": "Meshtastic Firmware 2.7.19.bb3d6d5 Alpha",
@@ -163,42 +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"
- },
- {
- "id": "v2.6.5.fc3d9f2",
- "title": "Meshtastic Firmware 2.6.5.fc3d9f2 Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.5.fc3d9f2",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.5.fc3d9f2/firmware-esp32-2.6.5.fc3d9f2.zip",
- "release_notes": "> [!CAUTION] \r\n> Updating from a previous version of firmware to 2.6, **will wipe** your device. Please remember to [backup your keys](https://meshtastic.org/docs/configuration/radio/security/#security-keys---backup-and-restore) and important [configurations](https://meshtastic.org/docs/software/python/cli/usage/#export-device-config-with---export-config) before proceeding!\r\n\r\n## 🚀 Enhancements\r\n* Update library deps and nrf Toolchain by @caveman99 in https://github.com/meshtastic/firmware/pull/6450\r\n* Update to handle ws80 serial data as well by @tavdog in https://github.com/meshtastic/firmware/pull/6440\r\n* Add a static_assert to verify assumption about NodeInfoLite size by @jasonbcox in https://github.com/meshtastic/firmware/pull/6428\r\n* meshtasticd: CH341 / HAT+ Auto Configuration by @vidplace7 in https://github.com/meshtastic/firmware/pull/6446\r\n* More toggles for InkHUD menu by @todd-herbert in https://github.com/meshtastic/firmware/pull/6469\r\n* Add InkHUD driver for WeAct Studio 4.2\" display module by @todd-herbert in https://github.com/meshtastic/firmware/pull/6384\r\n* Added initial support for Texas Instruments LP5562 by @CypressXt in https://github.com/meshtastic/firmware/pull/6381\r\n* meshtasticd: Set available.d dir in yaml by @vidplace7 in https://github.com/meshtastic/firmware/pull/6481\r\n* Disable bluetooth config on rp2040, portduino (for now), and stm32 by @thebentern in https://github.com/meshtastic/firmware/pull/6465\r\n* meshtasticd: Add FrequencyLabs MeshAdv-Mini Hat by @vidplace7 in https://github.com/meshtastic/firmware/pull/6458\r\n* Initial InkHUD support for Elecrow ThinkNode M1 by @todd-herbert in https://github.com/meshtastic/firmware/pull/6473\r\n* Add support for Quectel-L96, a MT3333 module by @ke6zfi in https://github.com/meshtastic/firmware/pull/6498\r\n* Update OLED library, fix nRF build of SH1107 by @caveman99 in https://github.com/meshtastic/firmware/pull/6489\r\n* Disable network config for non-eth_gateway nrf52 and non-W RP2040 targets by @thebentern in https://github.com/meshtastic/firmware/pull/6462\r\n* Honor user button remapping within InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6400\r\n* Improve PKC unit test coverage by @jasonbcox in https://github.com/meshtastic/firmware/pull/6485\r\n* TCA8418 initial config + basic 3x4 keypad config by @Nasimovy in https://github.com/meshtastic/firmware/pull/6422\r\n* MUI: update device-ui commit reference by @mverch67 in https://github.com/meshtastic/firmware/pull/6526\r\n\r\n## 🐛 Bug fixes & maintenance\r\n* Fix: Update xiao_ble E22-900M30S regulatory gain to 7 dB by @ndoo in https://github.com/meshtastic/firmware/pull/6466\r\n* Update ScreenFonts.h fix CrowPanel 5.79 Font by @markbirss in https://github.com/meshtastic/firmware/pull/6412\r\n* Added 'bluetooth' as a connectivity option for the LilyGo T-Watch-S3.… by @PlantDaddy in https://github.com/meshtastic/firmware/pull/6470* Try-fix some import of configuration inconsistencies by @thebentern in https://github.com/meshtastic/firmware/pull/6364\r\n* Fix: T-Echo frontlight on at boot when using OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6474\r\n* MUI unPhone-tft: fix defaults (BT, power save, and MUI cache size) by @mverch67 in https://github.com/meshtastic/firmware/pull/6477\r\n* Fixes #6315 by @RCGV1 in https://github.com/meshtastic/firmware/pull/6475\r\n* Reinstate M1 Backlight by @caveman99 in https://github.com/meshtastic/firmware/pull/6484\r\n* Remove Very_Long_Slow by @rcarteraz in https://github.com/meshtastic/firmware/pull/6486\r\n* Revert \"Try-fix ESP32 wifi disconnects\" by @thebentern in https://github.com/meshtastic/firmware/pull/6493\r\n* InkHUD: ad-hoc ping using the menu by @todd-herbert in https://github.com/meshtastic/firmware/pull/6492\r\n* Remove duplicate HAS_LP5562 introduced in #6422 by @Nasimovy in https://github.com/meshtastic/firmware/pull/6494\r\n* Fix for PSRAM detection on ESP32-S3R8 and t-beam by @Nasimovy in https://github.com/meshtastic/firmware/pull/6504\r\n* Fix several features of M1 and M2 (i know what the 7 is now ...) by @caveman99 in https://github.com/meshtastic/firmware/pull/6507\r\n* Update platformio.ini fix build-flags ${esp32s3_base.build_flags} by @markbirss in https://github.com/meshtastic/firmware/pull/6512\r\n* inkhud doesn't have a button thread by @caveman99 in https://github.com/meshtastic/firmware/pull/6513\r\n* Fix device-specific logic in install script by @epall in https://github.com/meshtastic/firmware/pull/6508\r\n* Update web, use centrally defined version by @vidplace7 in https://github.com/meshtastic/firmware/pull/6500\r\n* Minor adjustment of blink codes and 'unstick' the M2 button. by @caveman99 in https://github.com/meshtastic/firmware/pull/6521\r\n* chore: update ubx.h by @eltociear in https://github.com/meshtastic/firmware/pull/6522\r\n* meshtasticd docker: Support webui by @vidplace7 in https://github.com/meshtastic/firmware/pull/6482\r\n* remove checkov from trunk config by @fifieldt in https://github.com/meshtastic/firmware/pull/6532\r\n* Send UDP packet even if it's encrypted by @GUVWAF in https://github.com/meshtastic/firmware/pull/6524\r\n\r\n## New Contributors\r\n* @jasonbcox made their first contribution in https://github.com/meshtastic/firmware/pull/6428\r\n* @PlantDaddy made their first contribution in https://github.com/meshtastic/firmware/pull/6470\r\n* @CypressXt made their first contribution in https://github.com/meshtastic/firmware/pull/6381\r\n* @ke6zfi made their first contribution in https://github.com/meshtastic/firmware/pull/6498\r\n* @epall made their first contribution in https://github.com/meshtastic/firmware/pull/6508\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.4.b89355f...v2.6.5.fc3d9f2"
}
]
},
- "pullRequests": [
- {
- "id": "9749",
- "title": "Add AEAD (AES-CCM) authenticated encryption for PSK channels",
- "page_url": "https://github.com/meshtastic/firmware/pull/9749",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9706",
- "title": "Add VL53L0 distance sensor.",
- "page_url": "https://github.com/meshtastic/firmware/pull/9706",
- "zip_url": "https://discord.com/invite/meshtastic"
- }
- ]
+ "pullRequests": []
}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
deleted file mode 100644
index 5c546f476..000000000
--- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
+++ /dev/null
@@ -1,60 +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 com.geeksville.mesh
-
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ProcessLifecycleOwner
-import com.geeksville.mesh.service.MeshServiceNotificationsImpl
-import dagger.Binds
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.di.ProcessLifecycle
-import org.meshtastic.core.service.MeshServiceNotifications
-import javax.inject.Singleton
-
-@InstallIn(SingletonComponent::class)
-@Module
-interface ApplicationModule {
-
- @Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
-
- companion object {
- @Provides @ProcessLifecycle
- fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
-
- @Provides
- @ProcessLifecycle
- fun provideProcessLifecycle(@ProcessLifecycle processLifecycleOwner: LifecycleOwner): Lifecycle =
- processLifecycleOwner.lifecycle
-
- @Singleton
- @Provides
- fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
- override val isDebug: Boolean = BuildConfig.DEBUG
- override val applicationId: String = BuildConfig.APPLICATION_ID
- override val versionCode: Int = BuildConfig.VERSION_CODE
- override val versionName: String = BuildConfig.VERSION_NAME
- override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
- override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
deleted file mode 100644
index 3b5dffc1e..000000000
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ /dev/null
@@ -1,217 +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.geeksville.mesh
-
-import android.app.PendingIntent
-import android.app.TaskStackBuilder
-import android.content.Intent
-import android.graphics.Color
-import android.hardware.usb.UsbManager
-import android.net.Uri
-import android.nfc.NdefMessage
-import android.nfc.NfcAdapter
-import android.os.Build
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.SystemBarStyle
-import androidx.activity.compose.ReportDrawnWhen
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.getValue
-import androidx.core.content.IntentCompat
-import androidx.core.net.toUri
-import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.model.UIViewModel
-import com.geeksville.mesh.ui.MainScreen
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.launch
-import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
-import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
-import org.meshtastic.core.model.util.dispatchMeshtasticUri
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.channel_invalid
-import org.meshtastic.core.ui.theme.AppTheme
-import org.meshtastic.core.ui.theme.MODE_DYNAMIC
-import org.meshtastic.core.ui.util.showToast
-import org.meshtastic.feature.intro.AppIntroductionScreen
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class MainActivity : ComponentActivity() {
- private val model: UIViewModel by viewModels()
-
- /**
- * 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.
- */
- @Inject internal lateinit var meshServiceClient: MeshServiceClient
-
- @Inject internal lateinit var androidEnvironment: AndroidEnvironment
-
- override fun onCreate(savedInstanceState: Bundle?) {
- installSplashScreen()
-
- super.onCreate(savedInstanceState)
-
- setContent {
- val theme by model.theme.collectAsStateWithLifecycle()
- val dynamic = theme == MODE_DYNAMIC
- val dark =
- when (theme) {
- AppCompatDelegate.MODE_NIGHT_YES -> true
- AppCompatDelegate.MODE_NIGHT_NO -> false
- else -> isSystemInDarkTheme()
- }
-
- // Apply modern edge-to-edge drawing with theme-aware system bars
- enableEdgeToEdge(
- statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- )
-
- // Ensure the navigation bar remains seamless on modern Android versions
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.isNavigationBarContrastEnforced = false
- }
-
- @Suppress("SpreadOperator")
- CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
- AppTheme(dynamicColor = dynamic, darkTheme = dark) {
- val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
-
- // Signal to the system that the initial UI is "fully drawn"
- // once we've decided whether to show the intro or the main screen.
- ReportDrawnWhen { true }
-
- if (appIntroCompleted) {
- MainScreen(uIViewModel = model)
- } else {
- AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
- }
- }
- }
- }
-
- // Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
- addOnNewIntentListener { intent -> handleIntent(intent) }
-
- handleIntent(intent)
- }
-
- @Suppress("NestedBlockDepth")
- private fun handleIntent(intent: Intent) {
- val appLinkAction = intent.action
- val appLinkData: Uri? = intent.data
-
- when (appLinkAction) {
- Intent.ACTION_VIEW -> {
- appLinkData?.let { handleMeshtasticUri(it) }
- }
-
- NfcAdapter.ACTION_NDEF_DISCOVERED -> {
- val rawMessages =
- IntentCompat.getParcelableArrayExtra(
- intent,
- NfcAdapter.EXTRA_NDEF_MESSAGES,
- NdefMessage::class.java,
- )
- if (rawMessages != null) {
- for (rawMsg in rawMessages) {
- val msg = rawMsg as NdefMessage
- for (record in msg.records) {
- record.toUri()?.let { handleMeshtasticUri(it) }
- }
- }
- }
- }
-
- UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
- Logger.d { "USB device attached" }
- showSettingsPage()
- }
-
- Intent.ACTION_MAIN -> {}
-
- Intent.ACTION_SEND -> {
- val text = intent.getStringExtra(Intent.EXTRA_TEXT)
- if (text != null) {
- createShareIntent(text).send()
- }
- }
-
- else -> {
- Logger.w { "Unexpected action $appLinkAction" }
- }
- }
- }
-
- private fun handleMeshtasticUri(uri: Uri) {
- Logger.d { "Handling Meshtastic URI: $uri" }
- if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
- model.handleNavigationDeepLink(uri)
- return
- }
-
- uri.dispatchMeshtasticUri(
- onChannel = { model.setRequestChannelSet(it) },
- onContact = { model.setSharedContactRequested(it) },
- onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
- )
- }
-
- private fun createShareIntent(message: String): PendingIntent {
- val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
- val startActivityIntent =
- Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
-
- val resultPendingIntent: PendingIntent? =
- TaskStackBuilder.create(this).run {
- addNextIntentWithParentStack(startActivityIntent)
- getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
- }
- return resultPendingIntent!!
- }
-
- private fun createSettingsIntent(): PendingIntent {
- val deepLink = "$DEEP_LINK_BASE_URI/connections"
- val startActivityIntent =
- Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
-
- val resultPendingIntent: PendingIntent? =
- TaskStackBuilder.create(this).run {
- addNextIntentWithParentStack(startActivityIntent)
- getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
- }
- return resultPendingIntent!!
- }
-
- private fun showSettingsPage() {
- createSettingsIntent().send()
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
deleted file mode 100644
index a6759dae6..000000000
--- a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
+++ /dev/null
@@ -1,202 +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.geeksville.mesh.domain.usecase
-
-import android.hardware.usb.UsbManager
-import android.net.nsd.NsdServiceInfo
-import com.geeksville.mesh.model.DeviceListEntry
-import com.geeksville.mesh.model.getMeshtasticShortName
-import com.geeksville.mesh.repository.network.NetworkRepository
-import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.geeksville.mesh.repository.usb.UsbRepository
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.map
-import org.jetbrains.compose.resources.getString
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.datastore.RecentAddressesDataSource
-import org.meshtastic.core.datastore.model.RecentAddress
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.meshtastic
-import java.util.Locale
-import javax.inject.Inject
-
-data class DiscoveredDevices(
- val bleDevices: List,
- val usbDevices: List,
- val discoveredTcpDevices: List,
- val recentTcpDevices: List,
-)
-
-@Suppress("LongParameterList")
-class GetDiscoveredDevicesUseCase
-@Inject
-constructor(
- private val bluetoothRepository: BluetoothRepository,
- private val networkRepository: NetworkRepository,
- private val recentAddressesDataSource: RecentAddressesDataSource,
- private val nodeRepository: NodeRepository,
- private val databaseManager: DatabaseManager,
- private val usbRepository: UsbRepository,
- private val radioInterfaceService: RadioInterfaceService,
- private val usbManagerLazy: dagger.Lazy,
-) {
- private val suffixLength = 4
-
- @Suppress("LongMethod", "CyclomaticComplexMethod")
- fun invoke(showMock: Boolean): Flow {
- val nodeDb = nodeRepository.nodeDBbyNum
-
- val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
-
- val processedTcpFlow =
- combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
- tcpServices,
- recentList,
- ->
- val recentMap = recentList.associateBy({ it.address }) { it.name }
- tcpServices
- .map { service ->
- val address = "t${service.toAddressString()}"
- val txtRecords = service.attributes
- val shortNameBytes = txtRecords["shortname"]
- val idBytes = txtRecords["id"]
-
- val shortName =
- shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
- val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
- var displayName = recentMap[address] ?: shortName
- if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
- displayName += "_$deviceId"
- }
- DeviceListEntry.Tcp(displayName, address)
- }
- .sortedBy { it.name }
- }
-
- val usbDevicesFlow =
- usbRepository.serialDevices.map { usb ->
- usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) }
- }
-
- return combine(
- nodeDb,
- bondedBleFlow,
- processedTcpFlow,
- usbDevicesFlow,
- networkRepository.resolvedList,
- recentAddressesDataSource.recentAddresses,
- ) { args: Array ->
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val db = args[0] as Map
-
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val bondedBle = args[1] as List
-
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val processedTcp = args[2] as List
-
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val usbDevices = args[3] as List
-
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val resolved = args[4] as List
-
- @Suppress("UNCHECKED_CAST", "MagicNumber")
- val recentList = args[5] as List
-
- val bleForUi =
- bondedBle
- .map { entry ->
- val matchingNode =
- if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
- db.values.find { node ->
- val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
- suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
- }
- } else {
- null
- }
- entry.copy(node = matchingNode)
- }
- .sortedBy { it.name }
-
- val usbForUi =
- (usbDevices + if (showMock) listOf(DeviceListEntry.Mock("Demo Mode")) else emptyList()).map { entry ->
- val matchingNode =
- if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
- db.values.find { node ->
- val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
- suffix != null &&
- suffix.length >= suffixLength &&
- node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
- }
- } else {
- null
- }
- entry.copy(node = matchingNode)
- }
-
- val discoveredTcpForUi =
- processedTcp.map { entry ->
- val matchingNode =
- if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
- val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
- val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
- db.values.find { node ->
- node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
- }
- } else {
- null
- }
- entry.copy(node = matchingNode)
- }
-
- val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
- val recentTcpForUi =
- recentList
- .filterNot { discoveredTcpAddresses.contains(it.address) }
- .map { DeviceListEntry.Tcp(it.name, it.address) }
- .map { entry ->
- val matchingNode =
- if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
- val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
- db.values.find { node ->
- suffix != null &&
- suffix.length >= suffixLength &&
- node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
- }
- } else {
- null
- }
- entry.copy(node = matchingNode)
- }
- .sortedBy { it.name }
-
- DiscoveredDevices(
- bleDevices = bleForUi,
- usbDevices = usbForUi,
- discoveredTcpDevices = discoveredTcpForUi,
- recentTcpDevices = recentTcpForUi,
- )
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
deleted file mode 100644
index 6d2e4c448..000000000
--- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.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 com.geeksville.mesh.model
-
-import android.hardware.usb.UsbManager
-import com.geeksville.mesh.repository.radio.InterfaceId
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.hoho.android.usbserial.driver.UsbSerialDriver
-import no.nordicsemi.kotlin.ble.client.android.Peripheral
-import no.nordicsemi.kotlin.ble.core.BondState
-import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.util.anonymize
-
-/**
- * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is
- * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for
- * exhaustive `when` expressions in the code, making it more robust and readable.
- *
- * @param name The display name of the device.
- * @param fullAddress The unique address of the device, prefixed with a type identifier.
- * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB).
- * @param node The [Node] associated with this device, if found in the database.
- */
-sealed class DeviceListEntry(
- open val name: String,
- open val fullAddress: String,
- open val bonded: Boolean,
- open val node: Node? = null,
-) {
- val address: String
- get() = fullAddress.substring(1)
-
- abstract fun copy(node: Node?): DeviceListEntry
-
- override fun toString(): String =
- "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
-
- @Suppress("MissingPermission")
- data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
- DeviceListEntry(
- name = peripheral.name ?: "unnamed-${peripheral.address}",
- fullAddress = "x${peripheral.address}",
- bonded = peripheral.bondState.value == BondState.BONDED,
- node = node,
- ) {
- override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
- }
-
- data class Usb(
- private val radioInterfaceService: RadioInterfaceService,
- private val usbManager: UsbManager,
- val driver: UsbSerialDriver,
- override val node: Node? = null,
- ) : DeviceListEntry(
- name = driver.device.deviceName,
- fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName),
- bonded = usbManager.hasPermission(driver.device),
- node = node,
- ) {
- override fun copy(node: Node?): Usb =
- copy(radioInterfaceService = radioInterfaceService, usbManager = usbManager, driver = driver, node = node)
- }
-
- data class Tcp(override val name: String, override val fullAddress: String, override val node: Node? = null) :
- DeviceListEntry(name, fullAddress, true, node) {
- override fun copy(node: Node?): Tcp = copy(name = name, fullAddress = fullAddress, node = node)
- }
-
- data class Mock(override val name: String, override val node: Node? = null) :
- DeviceListEntry(name, "m", true, node) {
- override fun copy(node: Node?): Mock = copy(name = name, node = node)
- }
-}
-
-/** Matches names like Meshtastic_1234. */
-private val bleNameRegex = Regex(BLE_NAME_PATTERN)
-
-/**
- * Returns the short name of the device if it's a Meshtastic device, otherwise null.
- *
- * @return The short name (e.g., 1234) or null.
- */
-fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt
deleted file mode 100644
index ccf513922..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt
+++ /dev/null
@@ -1,56 +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 com.geeksville.mesh.navigation
-
-import androidx.compose.runtime.remember
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navDeepLink
-import androidx.navigation.navigation
-import com.geeksville.mesh.ui.sharing.ChannelScreen
-import org.meshtastic.core.navigation.ChannelsRoutes
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.SettingsRoutes
-import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
-import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
-
-/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
-fun NavGraphBuilder.channelsGraph(navController: NavHostController) {
- navigation(startDestination = ChannelsRoutes.Channels) {
- composable(
- deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/channels")),
- ) { backStackEntry ->
- val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) }
- ChannelScreen(
- radioConfigViewModel = hiltViewModel(parentEntry),
- onNavigate = { route -> navController.navigate(route) },
- onNavigateUp = { navController.navigateUp() },
- )
- }
-
- navController.configComposable {
- ChannelConfigScreen(viewModel = it, onBack = navController::popBackStack)
- }
-
- navController.configComposable {
- LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt
deleted file mode 100644
index 8c94d688e..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt
+++ /dev/null
@@ -1,62 +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 com.geeksville.mesh.navigation
-
-import androidx.compose.runtime.remember
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navDeepLink
-import androidx.navigation.navigation
-import com.geeksville.mesh.ui.connections.ConnectionsScreen
-import org.meshtastic.core.navigation.ConnectionsRoutes
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.NodesRoutes
-import org.meshtastic.core.navigation.SettingsRoutes
-import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
-
-/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
-fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {
- @Suppress("ktlint:standard:max-line-length")
- navigation(startDestination = ConnectionsRoutes.Connections) {
- composable(
- deepLinks = listOf(
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/connections"),
- ),
- ) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) { navController.getBackStackEntry(ConnectionsRoutes.ConnectionsGraph) }
- ConnectionsScreen(
- radioConfigViewModel = hiltViewModel(parentEntry),
- onClickNodeChip = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
- launchSingleTop = true
- restoreState = true
- }
- },
- onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
- onConfigNavigate = { route -> navController.navigate(route) },
- )
- }
-
- navController.configComposable {
- LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
deleted file mode 100644
index aaf47dde6..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
+++ /dev/null
@@ -1,107 +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.geeksville.mesh.navigation
-
-import androidx.compose.runtime.getValue
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navDeepLink
-import androidx.navigation.navigation
-import androidx.navigation.toRoute
-import com.geeksville.mesh.model.UIViewModel
-import kotlinx.coroutines.flow.Flow
-import org.meshtastic.core.navigation.ContactsRoutes
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.feature.messaging.QuickChatScreen
-import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
-import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
-
-@Suppress("LongMethod")
-fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow) {
- navigation(startDestination = ContactsRoutes.Contacts) {
- composable(
- deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/contacts")),
- ) {
- val uiViewModel: UIViewModel = hiltViewModel()
- val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
-
- AdaptiveContactsScreen(
- navController = navController,
- scrollToTopEvents = scrollToTopEvents,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onHandleScannedUri = uiViewModel::handleScannedUri,
- onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
- onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
- )
- }
- composable(
- deepLinks =
- listOf(
- navDeepLink(
- basePath =
- "$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended
- ),
- ),
- ) { backStackEntry ->
- val args = backStackEntry.toRoute()
- val uiViewModel: UIViewModel = hiltViewModel()
- val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
-
- AdaptiveContactsScreen(
- navController = navController,
- scrollToTopEvents = scrollToTopEvents,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onHandleScannedUri = uiViewModel::handleScannedUri,
- onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
- onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
- initialContactKey = args.contactKey,
- initialMessage = args.message,
- )
- }
- }
- composable(
- deepLinks =
- listOf(
- navDeepLink(
- basePath = "$DEEP_LINK_BASE_URI/share", // ?message={message} is auto-appended
- ),
- ),
- ) { backStackEntry ->
- val message = backStackEntry.toRoute().message
- ShareScreen(
- onConfirm = {
- navController.navigate(ContactsRoutes.Messages(it, message)) {
- popUpTo { inclusive = true }
- }
- },
- onNavigateUp = navController::navigateUp,
- )
- }
- composable(
- deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
- ) {
- QuickChatScreen(onNavigateUp = navController::navigateUp)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt
deleted file mode 100644
index 5de1c6933..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt
+++ /dev/null
@@ -1,41 +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 com.geeksville.mesh.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navDeepLink
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.MapRoutes
-import org.meshtastic.core.navigation.NodesRoutes
-import org.meshtastic.feature.map.MapScreen
-
-fun NavGraphBuilder.mapGraph(navController: NavHostController) {
- composable(deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/map"))) {
- MapScreen(
- onClickNodeChip = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
- launchSingleTop = true
- restoreState = true
- }
- },
- navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
- )
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
deleted file mode 100644
index d9fded5b4..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
+++ /dev/null
@@ -1,350 +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.geeksville.mesh.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.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.navigation.NavDestination
-import androidx.navigation.NavDestination.Companion.hasRoute
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.navigation
-import androidx.navigation.navDeepLink
-import androidx.navigation.toRoute
-import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
-import kotlinx.coroutines.flow.Flow
-import org.jetbrains.compose.resources.StringResource
-import org.meshtastic.core.navigation.ContactsRoutes
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.NodeDetailRoutes
-import org.meshtastic.core.navigation.NodesRoutes
-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.neighbor_info
-import org.meshtastic.core.resources.pax
-import org.meshtastic.core.resources.position_log
-import org.meshtastic.core.resources.power
-import org.meshtastic.core.resources.signal
-import org.meshtastic.core.resources.traceroute
-import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.feature.map.node.NodeMapScreen
-import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
-import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
-import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
-import org.meshtastic.feature.node.metrics.MetricsViewModel
-import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
-import org.meshtastic.feature.node.metrics.PaxMetricsScreen
-import org.meshtastic.feature.node.metrics.PositionLogScreen
-import org.meshtastic.feature.node.metrics.PowerMetricsScreen
-import org.meshtastic.feature.node.metrics.SignalMetricsScreen
-import org.meshtastic.feature.node.metrics.TracerouteLogScreen
-import org.meshtastic.feature.node.metrics.TracerouteMapScreen
-import kotlin.reflect.KClass
-
-fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow) {
- navigation(startDestination = NodesRoutes.Nodes) {
- composable(
- deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/nodes")),
- ) {
- AdaptiveNodeListScreen(
- navController = navController,
- scrollToTopEvents = scrollToTopEvents,
- onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
- )
- }
- nodeDetailGraph(navController, scrollToTopEvents)
- }
-}
-
-@Suppress("LongMethod")
-fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTopEvents: Flow) {
- // We keep this route for deep linking or direct navigation to details,
- // but typically users will navigate via the Adaptive screen in NodesRoutes.Nodes
- navigation(startDestination = NodesRoutes.NodeDetail()) {
- composable(
- deepLinks =
- listOf(
- navDeepLink( // Handles both /node and /node/{destNum} due to destNum: Int?
- basePath = "$DEEP_LINK_BASE_URI/node",
- ),
- ),
- ) { backStackEntry ->
- val args = backStackEntry.toRoute()
- // When navigating directly to NodeDetail (e.g. from Map or deep link),
- // we use the Adaptive screen initialized with the specific node ID.
- AdaptiveNodeListScreen(
- navController = navController,
- scrollToTopEvents = scrollToTopEvents,
- initialNodeId = args.destNum,
- onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
- )
- }
-
- composable(
- deepLinks =
- listOf(
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/node_map"),
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/node_map"),
- ),
- ) { backStackEntry ->
- val parentGraphBackStackEntry =
- remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- val vm = hiltViewModel(parentGraphBackStackEntry)
- NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
- }
-
- composable(
- deepLinks =
- listOf(
- navDeepLink(
- basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
- ),
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
- ),
- ) { backStackEntry ->
- val parentGraphBackStackEntry =
- remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- val metricsViewModel = hiltViewModel(parentGraphBackStackEntry)
-
- val args = backStackEntry.toRoute()
- metricsViewModel.setNodeId(args.destNum)
-
- TracerouteLogScreen(
- viewModel = metricsViewModel,
- onNavigateUp = navController::navigateUp,
- onViewOnMap = { requestId, responseLogUuid ->
- navController.navigate(
- NodeDetailRoutes.TracerouteMap(
- destNum = args.destNum,
- requestId = requestId,
- logUuid = responseLogUuid,
- ),
- )
- },
- )
- }
-
- composable(
- deepLinks =
- listOf(
- navDeepLink(
- basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
- ),
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
- ),
- ) { backStackEntry ->
- val parentGraphBackStackEntry =
- remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- val metricsViewModel = hiltViewModel(parentGraphBackStackEntry)
-
- val args = backStackEntry.toRoute()
- metricsViewModel.setNodeId(args.destNum)
-
- TracerouteMapScreen(
- metricsViewModel = metricsViewModel,
- requestId = args.requestId,
- logUuid = args.logUuid,
- onNavigateUp = navController::navigateUp,
- )
- }
-
- NodeDetailRoute.entries.forEach { entry ->
- when (entry.routeClass) {
- NodeDetailRoutes.DeviceMetrics::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.PositionLog::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.EnvironmentMetrics::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.SignalMetrics::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.PowerMetrics::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.HostMetricsLog::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.PaxMetrics::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- NodeDetailRoutes.NeighborInfoLog::class ->
- addNodeDetailScreenComposable(
- navController,
- entry,
- entry.screenComposable,
- ) {
- it.destNum
- }
- else -> Unit
- }
- }
- }
-}
-
-fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
-
-/**
- * Helper to define a composable route for a screen within the node detail graph.
- *
- * @param R The type of the [Route] object, must be serializable.
- * @param navController The [NavHostController] for navigation.
- * @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
- * @param screenContent A lambda that defines the composable content for the screen.
- * @param getDestNum A lambda to extract the destination number from the route arguments.
- */
-private inline fun NavGraphBuilder.addNodeDetailScreenComposable(
- navController: NavHostController,
- routeInfo: NodeDetailRoute,
- crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
- crossinline getDestNum: (R) -> Int,
-) {
- composable(
- deepLinks =
- listOf(
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/${routeInfo.name.lowercase()}"),
- navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/${routeInfo.name.lowercase()}"),
- ),
- ) { backStackEntry ->
- val parentGraphBackStackEntry =
- remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- val metricsViewModel = hiltViewModel(parentGraphBackStackEntry)
-
- val args = backStackEntry.toRoute()
- val destNum = getDestNum(args)
- metricsViewModel.setNodeId(destNum)
-
- screenContent(metricsViewModel, navController::navigateUp)
- }
-}
-
-enum class NodeDetailRoute(
- val title: StringResource,
- val routeClass: KClass,
- val icon: ImageVector?,
- val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
-) {
- DEVICE(
- Res.string.device,
- NodeDetailRoutes.DeviceMetrics::class,
- Icons.Rounded.Router,
- { metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
- ),
- POSITION_LOG(
- Res.string.position_log,
- NodeDetailRoutes.PositionLog::class,
- Icons.Rounded.LocationOn,
- { metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
- ),
- ENVIRONMENT(
- Res.string.environment,
- NodeDetailRoutes.EnvironmentMetrics::class,
- Icons.Rounded.LightMode,
- { metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
- ),
- SIGNAL(
- Res.string.signal,
- NodeDetailRoutes.SignalMetrics::class,
- Icons.Rounded.CellTower,
- { metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
- ),
- TRACEROUTE(
- Res.string.traceroute,
- NodeDetailRoutes.TracerouteLog::class,
- Icons.Rounded.PermScanWifi,
- { metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
- ),
- NEIGHBOR_INFO(
- Res.string.neighbor_info,
- NodeDetailRoutes.NeighborInfoLog::class,
- Icons.Rounded.Groups,
- { metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
- ),
- POWER(
- Res.string.power,
- NodeDetailRoutes.PowerMetrics::class,
- Icons.Rounded.Power,
- { metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
- ),
- HOST(
- Res.string.host,
- NodeDetailRoutes.HostMetricsLog::class,
- Icons.Rounded.Memory,
- { metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
- ),
- PAX(
- Res.string.pax,
- NodeDetailRoutes.PaxMetrics::class,
- Icons.Rounded.People,
- { metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
- ),
-}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
deleted file mode 100644
index aa498f009..000000000
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ /dev/null
@@ -1,204 +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("Wrapping", "SpacingAroundColon")
-
-package com.geeksville.mesh.navigation
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navDeepLink
-import androidx.navigation.navigation
-import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.Graph
-import org.meshtastic.core.navigation.NodesRoutes
-import org.meshtastic.core.navigation.Route
-import org.meshtastic.core.navigation.SettingsRoutes
-import org.meshtastic.feature.settings.AboutScreen
-import org.meshtastic.feature.settings.SettingsScreen
-import org.meshtastic.feature.settings.debugging.DebugScreen
-import org.meshtastic.feature.settings.filter.FilterSettingsScreen
-import org.meshtastic.feature.settings.navigation.ConfigRoute
-import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
-import org.meshtastic.feature.settings.radio.RadioConfigViewModel
-import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
-import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
-import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
-import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
-import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
-import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
-import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen
-import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
-import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen
-import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
-import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
-import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
-import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
-import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
-import org.meshtastic.feature.settings.radio.component.PositionConfigScreen
-import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
-import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
-import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
-import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
-import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
-import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
-import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
-import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
-import org.meshtastic.feature.settings.radio.component.UserConfigScreen
-import kotlin.reflect.KClass
-
-@Suppress("LongMethod")
-fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
- navigation(startDestination = SettingsRoutes.Settings()) {
- composable(
- deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/settings")),
- ) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
- SettingsScreen(
- viewModel = hiltViewModel(parentEntry),
- onClickNodeChip = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
- launchSingleTop = true
- restoreState = true
- }
- },
- ) {
- navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
- }
- }
-
- composable(
- deepLinks =
- listOf(
- navDeepLink(
- basePath = "$DEEP_LINK_BASE_URI/settings/radio/clean_node_db",
- ),
- ),
- ) {
- CleanNodeDatabaseScreen()
- }
-
- ConfigRoute.entries.forEach { entry ->
- navController.configComposable(
- route = entry.route::class,
- parentGraphRoute = SettingsRoutes.SettingsGraph::class,
- ) { viewModel ->
- when (entry) {
- ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.DEVICE -> DeviceConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.POSITION -> PositionConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ConfigRoute.SECURITY -> SecurityConfigScreen(viewModel, onBack = navController::popBackStack)
- }
- }
- }
-
- ModuleRoute.entries.forEach { entry ->
- navController.configComposable(
- route = entry.route::class,
- parentGraphRoute = SettingsRoutes.SettingsGraph::class,
- ) { viewModel ->
- when (entry) {
- ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.EXT_NOTIFICATION ->
- ExternalNotificationConfigScreen(viewModel = viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.STORE_FORWARD ->
- StoreForwardConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.CANNED_MESSAGE ->
- CannedMessageConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.REMOTE_HARDWARE ->
- RemoteHardwareConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.NEIGHBOR_INFO ->
- NeighborInfoConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.AMBIENT_LIGHTING ->
- AmbientLightingConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.DETECTION_SENSOR ->
- DetectionSensorConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = navController::popBackStack)
-
- ModuleRoute.STATUS_MESSAGE ->
- StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
- }
- }
- }
-
- composable(
- deepLinks =
- listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/settings/debug_panel")),
- ) {
- DebugScreen(onNavigateUp = navController::navigateUp)
- }
-
- composable { AboutScreen(onNavigateUp = navController::navigateUp) }
-
- composable { FilterSettingsScreen(onBack = navController::navigateUp) }
- }
-}
-
-context(_: NavGraphBuilder)
-inline fun NavHostController.configComposable(
- noinline content: @Composable (RadioConfigViewModel) -> Unit,
-) {
- configComposable(route = R::class, parentGraphRoute = G::class, content = content)
-}
-
-context(navGraphBuilder: NavGraphBuilder)
-fun NavHostController.configComposable(
- route: KClass,
- parentGraphRoute: KClass,
- content: @Composable (RadioConfigViewModel) -> Unit,
-) {
- navGraphBuilder.composable(route = route) { backStackEntry ->
- val parentEntry = remember(backStackEntry) { getBackStackEntry(parentGraphRoute) }
- content(hiltViewModel(parentEntry))
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt
deleted file mode 100644
index 7ad3b4d69..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt
+++ /dev/null
@@ -1,178 +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.geeksville.mesh.repository.network
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.first
-import okio.ByteString.Companion.toByteString
-import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions
-import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
-import org.eclipse.paho.client.mqttv3.MqttAsyncClient
-import org.eclipse.paho.client.mqttv3.MqttAsyncClient.generateClientId
-import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions
-import org.eclipse.paho.client.mqttv3.MqttMessage
-import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
-import org.meshtastic.core.common.util.ignoreException
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.model.util.subscribeList
-import org.meshtastic.proto.MqttClientProxyMessage
-import java.net.URI
-import java.security.SecureRandom
-import javax.inject.Inject
-import javax.inject.Singleton
-import javax.net.ssl.SSLContext
-import javax.net.ssl.TrustManager
-
-@Singleton
-class MQTTRepository
-@Inject
-constructor(
- private val radioConfigRepository: RadioConfigRepository,
- private val nodeRepository: NodeRepository,
-) {
-
- companion object {
- /**
- * Quality of Service (QoS) levels in MQTT:
- * - QoS 0: "at most once". Packets are sent once without validation if it has been received.
- * - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server.
- * MQTT ensures delivery, but duplicates may occur.
- * - QoS 2: "exactly once". Similar to QoS 1, but with no duplicates.
- */
- private const val DEFAULT_QOS = 1
- private const val DEFAULT_TOPIC_ROOT = "msh"
- 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 var mqttClient: MqttAsyncClient? = null
-
- fun disconnect() {
- Logger.i { "MQTT Disconnected" }
- mqttClient?.apply {
- if (isConnected) {
- ignoreException { disconnect() }
- }
- ignoreException { close(true) }
- }
- mqttClient = null
- }
-
- val proxyMessageFlow: Flow = callbackFlow {
- val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: generateClientId()}"
- val channelSet = radioConfigRepository.channelSetFlow.first()
- val mqttConfig = radioConfigRepository.moduleConfigFlow.first().mqtt
-
- val sslContext = SSLContext.getInstance("TLS")
- // Create a custom SSLContext that trusts all certificates
- sslContext.init(null, arrayOf(TrustAllX509TrustManager()), SecureRandom())
-
- val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT }
-
- val connectOptions =
- MqttConnectOptions().apply {
- userName = mqttConfig?.username
- password = mqttConfig?.password?.toCharArray()
- isAutomaticReconnect = true
- if (mqttConfig?.tls_enabled == true) {
- socketFactory = sslContext.socketFactory
- }
- }
-
- val bufferOptions =
- DisconnectedBufferOptions().apply {
- isBufferEnabled = true
- bufferSize = 512
- isPersistBuffer = false
- isDeleteOldestMessages = true
- }
-
- val callback =
- object : MqttCallbackExtended {
- override fun connectComplete(reconnect: Boolean, serverURI: String) {
- Logger.i { "MQTT connectComplete: $serverURI reconnect: $reconnect" }
- channelSet.subscribeList
- .ifEmpty {
- return
- }
- .forEach { globalId ->
- subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
- if (mqttConfig?.json_enabled == true) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
- }
- subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
- }
-
- override fun connectionLost(cause: Throwable) {
- Logger.i { "MQTT connectionLost cause: $cause" }
- if (cause is IllegalArgumentException) close(cause)
- }
-
- override fun messageArrived(topic: String, message: MqttMessage) {
- trySend(
- MqttClientProxyMessage(
- topic = topic,
- data_ = message.payload.toByteString(),
- retained = message.isRetained,
- ),
- )
- }
-
- override fun deliveryComplete(token: IMqttDeliveryToken?) {
- Logger.i { "MQTT deliveryComplete messageId: ${token?.messageId}" }
- }
- }
-
- val scheme = if (mqttConfig?.tls_enabled == true) "ssl" else "tcp"
- val (host, port) =
- (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let {
- it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1)
- }
-
- mqttClient =
- MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply {
- setCallback(callback)
- setBufferOpts(bufferOptions)
- connect(connectOptions)
- }
-
- awaitClose { disconnect() }
- }
-
- private fun subscribe(topic: String) {
- mqttClient?.subscribe(topic, DEFAULT_QOS)
- Logger.i { "MQTT Subscribed to topic: $topic" }
- }
-
- fun publish(topic: String, data: ByteArray, retained: Boolean) {
- try {
- val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained)
- Logger.i { "MQTT Publish messageId: ${token?.messageId}" }
- } catch (ex: Exception) {
- if (ex.message?.contains("Client is disconnected") == true) {
- Logger.w { "MQTT Publish skipped: Client is disconnected" }
- } else {
- Logger.e(ex) { "MQTT Publish error: ${ex.message}" }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt
deleted file mode 100644
index 21812f5e8..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt
+++ /dev/null
@@ -1,43 +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 com.geeksville.mesh.repository.network
-
-import android.app.Application
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.nsd.NsdManager
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-
-@Module
-@InstallIn(SingletonComponent::class)
-class NetworkRepositoryModule {
- companion object {
- @Provides
- fun provideConnectivityManager(application: Application): ConnectivityManager {
- return application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- }
-
- @Provides
- fun provideNsdManager(application: Application): NsdManager {
- return application.getSystemService(Context.NSD_SERVICE) as NsdManager
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
deleted file mode 100644
index ffb34c2a8..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
+++ /dev/null
@@ -1,58 +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 com.geeksville.mesh.repository.radio
-
-import javax.inject.Inject
-import javax.inject.Provider
-
-/**
- * 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).
- */
-class InterfaceFactory @Inject constructor(
- private val nopInterfaceFactory: NopInterfaceFactory,
- private val specMap: Map>>
-) {
- internal val nopInterface by lazy {
- nopInterfaceFactory.create("")
- }
-
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String {
- return "${interfaceId.id}$rest"
- }
-
- fun createInterface(address: String): IRadioInterface {
- val (spec, rest) = splitAddress(address)
- return spec?.createInterface(rest) ?: nopInterface
- }
-
- fun addressValid(address: String?): Boolean {
- return address?.let {
- val (spec, rest) = splitAddress(it)
- spec?.addressValid(rest)
- } ?: false
- }
-
- private fun splitAddress(address: String): Pair?, String> {
- val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it]?.get() }
- val rest = address.substring(1)
- return Pair(c, rest)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
deleted file mode 100644
index 3ab5b5300..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ /dev/null
@@ -1,452 +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.geeksville.mesh.repository.radio
-
-import android.annotation.SuppressLint
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.service.RadioNotConnectedException
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.channelFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withTimeout
-import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
-import no.nordicsemi.kotlin.ble.client.android.CentralManager
-import no.nordicsemi.kotlin.ble.client.android.Peripheral
-import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
-import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
-import no.nordicsemi.kotlin.ble.core.ConnectionState
-import no.nordicsemi.kotlin.ble.core.WriteType
-import org.meshtastic.core.ble.BleConnection
-import org.meshtastic.core.ble.BleError
-import org.meshtastic.core.ble.BleScanner
-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.SERVICE_UUID
-import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
-import org.meshtastic.core.ble.retryBleOperation
-import org.meshtastic.core.common.util.nowMillis
-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 val SCAN_TIMEOUT = 5.seconds
-
-/**
- * A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
- * https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
- *
- * This class is responsible for connecting to and communicating with a Meshtastic device over BLE.
- *
- * @param serviceScope The coroutine scope to use for launching coroutines.
- * @param centralManager The central manager provided by Nordic BLE Library.
- * @param service The [RadioInterfaceService] to use for handling radio events.
- * @param address The BLE address of the device to connect to.
- */
-@SuppressLint("MissingPermission")
-class NordicBleInterface
-@AssistedInject
-constructor(
- private val serviceScope: CoroutineScope,
- private val centralManager: CentralManager,
- private val service: RadioInterfaceService,
- @Assisted val address: String,
-) : IRadioInterface {
-
- 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" }
- }
- }
- service.onDisconnect(BleError.from(throwable))
- }
-
- private val connectionScope: CoroutineScope =
- CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
- private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
- private val drainMutex: Mutex = Mutex()
- 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
-
- private var toRadioCharacteristic: RemoteCharacteristic? = null
- private var fromNumCharacteristic: RemoteCharacteristic? = null
- private var fromRadioCharacteristic: RemoteCharacteristic? = null
- private var logRadioCharacteristic: RemoteCharacteristic? = null
- private var fromRadioSyncCharacteristic: RemoteCharacteristic? = null
-
- init {
- connect()
- }
-
- // --- Packet Flow Management ---
-
- private fun fromRadioPacketFlow(): Flow = channelFlow {
- while (isActive) {
- val packet =
- try {
- fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() }
- } catch (e: InvalidAttributeException) {
- Logger.w(e) { "[$address] Attribute invalidated during read, clearing characteristics" }
- handleInvalidAttribute(e)
- null
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Error reading fromRadioCharacteristic (likely disconnected)" }
- null
- }
-
- if (packet == null) {
- Logger.d { "[$address] fromRadio queue drain complete or error reading characteristic" }
- break
- }
- send(packet)
- }
- }
-
- private fun dispatchPacket(packet: ByteArray) {
- packetsReceived++
- bytesReceived += packet.size
- Logger.d {
- "[$address] Dispatching packet to service.handleFromRadio() - " +
- "Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
- }
- try {
- service.handleFromRadio(p = packet)
- } catch (t: Throwable) {
- Logger.e(t) { "[$address] Failed to execute service.handleFromRadio()" }
- }
- }
-
- private suspend fun drainPacketQueueAndDispatch() {
- drainMutex.withLock {
- fromRadioPacketFlow()
- .onEach { packet ->
- Logger.d { "[$address] Read packet from queue (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { ex -> Logger.w(ex) { "[$address] Exception while draining packet queue" } }
- .collect()
- }
- }
-
- // --- Connection & Discovery Logic ---
-
- /** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
- private suspend fun findPeripheral(): Peripheral {
- centralManager
- .getBondedPeripherals()
- .firstOrNull { it.address == address }
- ?.let {
- return it
- }
-
- Logger.i { "[$address] Device not found in bonded list, scanning..." }
- val scanner = BleScanner(centralManager)
-
- repeat(SCAN_RETRY_COUNT) { attempt ->
- val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
- if (p != null) return p
-
- if (attempt < SCAN_RETRY_COUNT - 1) {
- delay(SCAN_RETRY_DELAY_MS)
- }
- }
-
- throw RadioNotConnectedException("Device not found at address $address")
- }
-
- private fun connect() {
- connectionScope.launch {
- try {
- connectionStartTime = nowMillis
- Logger.i { "[$address] BLE connection attempt started" }
-
- bleConnection.connectionState
- .onEach { state ->
- if (state is ConnectionState.Disconnected) {
- onDisconnected(state)
- }
- }
- .launchIn(connectionScope)
-
- val p = retryBleOperation(tag = address) { findPeripheral() }
- val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
- if (state !is ConnectionState.Connected) {
- throw RadioNotConnectedException("Failed to connect to device at address $address")
- }
-
- onConnected()
- discoverServicesAndSetupCharacteristics()
- } catch (e: Exception) {
- val failureTime = nowMillis - connectionStartTime
- Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
- service.onDisconnect(BleError.from(e))
- }
- }
- }
-
- private suspend fun onConnected() {
- try {
- bleConnection.peripheral?.let { p ->
- val rssi = retryBleOperation(tag = address) { p.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(state: ConnectionState.Disconnected) {
- clearCharacteristics()
-
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.w {
- "[$address] BLE disconnected - Reason: ${state.reason}, " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
- }
- service.onDisconnect(BleError.Disconnected(reason = state.reason))
- }
-
- private suspend fun discoverServicesAndSetupCharacteristics() {
- try {
- val chars =
- bleConnection.discoverCharacteristics(
- serviceUuid = SERVICE_UUID,
- requiredUuids =
- listOf(
- TORADIO_CHARACTERISTIC,
- FROMNUM_CHARACTERISTIC,
- FROMRADIO_CHARACTERISTIC,
- LOGRADIO_CHARACTERISTIC,
- ),
- optionalUuids = listOf(FROMRADIOSYNC_CHARACTERISTIC),
- )
-
- if (chars != null) {
- toRadioCharacteristic = chars[TORADIO_CHARACTERISTIC]
- fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
- fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
- logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
- fromRadioSyncCharacteristic = chars[FROMRADIOSYNC_CHARACTERISTIC]
-
- Logger.d { "[$address] Characteristics discovered successfully" }
- setupNotifications()
- service.onConnect()
- } else {
- Logger.w { "[$address] Discovery failed: missing required characteristics" }
- service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
- }
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Service discovery failed" }
- bleConnection.disconnect()
- service.onDisconnect(BleError.from(e))
- }
- }
-
- // --- Notification Setup ---
-
- @Suppress("LongMethod")
- private suspend fun setupNotifications() {
- val fromRadioReady = CompletableDeferred()
- val logRadioReady = CompletableDeferred()
-
- // 1. Prefer FromRadioSync (Indicate) if available
- if (fromRadioSyncCharacteristic != null) {
- Logger.i { "[$address] Using FromRadioSync for packet reception" }
- fromRadioSyncCharacteristic
- ?.subscribe {
- Logger.d { "[$address] FromRadioSync subscription active" }
- fromRadioReady.complete(Unit)
- }
- ?.onEach { payload ->
- Logger.d { "[$address] FromRadioSync Indication (${payload.size} bytes)" }
- dispatchPacket(payload)
- }
- ?.catch { e ->
- if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in fromRadioSyncCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
- } else {
- // 2. Fallback to legacy FromNum (Notify) + FromRadio (Read)
- Logger.i { "[$address] Using legacy FromNum/FromRadio for packet reception" }
- fromNumCharacteristic
- ?.subscribe {
- Logger.d { "[$address] FromNum subscription active" }
- fromRadioReady.complete(Unit)
- }
- ?.onEach { notifyBytes ->
- Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
- connectionScope.launch { drainPacketQueueAndDispatch() }
- }
- ?.catch { e ->
- if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
- }
-
- logRadioCharacteristic
- ?.subscribe {
- Logger.d { "[$address] LogRadio subscription active" }
- logRadioReady.complete(Unit)
- }
- ?.onEach { notifyBytes ->
- Logger.d { "[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet" }
- dispatchPacket(notifyBytes)
- }
- ?.catch { e ->
- if (!logRadioReady.isCompleted) logRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in logRadioCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: logRadioReady.complete(Unit)
-
- try {
- withTimeout(CONNECTION_TIMEOUT_MS) {
- fromRadioReady.await()
- logRadioReady.await()
- }
- Logger.d { "[$address] All notifications successfully subscribed" }
- } catch (e: Exception) {
- Logger.e(e) { "[$address] Timeout or error waiting for characteristic subscriptions" }
- throw e
- }
- }
-
- // --- IRadioInterface Implementation ---
-
- /**
- * Sends a packet to the radio with retry support.
- *
- * @param p The packet to send.
- */
- override fun handleSendToRadio(p: ByteArray) {
- toRadioCharacteristic?.let { characteristic ->
- connectionScope.launch {
- writeMutex.withLock {
- try {
- val writeType =
- if (characteristic.properties.contains(CharacteristicProperty.WRITE_WITHOUT_RESPONSE)) {
- WriteType.WITHOUT_RESPONSE
- } else {
- WriteType.WITH_RESPONSE
- }
-
- retryBleOperation(tag = address) { characteristic.write(p, writeType = writeType) }
-
- packetsSent++
- bytesSent += p.size
- Logger.d {
- "[$address] Successfully wrote packet #$packetsSent " +
- "to toRadioCharacteristic with $writeType - " +
- "${p.size} bytes (Total TX: $bytesSent bytes)"
- }
-
- // Only manually drain if we are using the legacy FromNum/FromRadio flow
- if (fromRadioSyncCharacteristic == null) {
- drainPacketQueueAndDispatch()
- }
- } catch (e: InvalidAttributeException) {
- Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" }
- handleInvalidAttribute(e)
- } catch (e: Exception) {
- Logger.w(e) {
- "[$address] Failed to write packet to toRadioCharacteristic after " +
- "$packetsSent successful writes"
- }
- service.onDisconnect(BleError.from(e))
- }
- }
- }
- } ?: Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
- }
-
- override fun keepAlive() {
- Logger.d { "[$address] BLE keepAlive" }
- }
-
- /** Closes the connection to the device. */
- override fun close() {
- runBlocking {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.i {
- "[$address] BLE close() called - " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
- }
- connectionScope.cancel()
- bleConnection.disconnect()
- service.onDisconnect(true)
- }
- }
-
- private fun handleInvalidAttribute(e: InvalidAttributeException) {
- clearCharacteristics()
- service.onDisconnect(BleError.from(e))
- }
-
- private fun clearCharacteristics() {
- toRadioCharacteristic = null
- fromNumCharacteristic = null
- fromRadioCharacteristic = null
- logRadioCharacteristic = null
- fromRadioSyncCharacteristic = null
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
deleted file mode 100644
index 49f989452..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
+++ /dev/null
@@ -1,43 +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.geeksville.mesh.repository.radio
-
-import co.touchlab.kermit.Logger
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.model.util.anonymize
-import javax.inject.Inject
-
-/** Bluetooth backend implementation. */
-class NordicBleInterfaceSpec
-@Inject
-constructor(
- private val factory: NordicBleInterfaceFactory,
- private val bluetoothRepository: BluetoothRepository,
-) : InterfaceSpec {
- override fun createInterface(rest: String): NordicBleInterface = factory.create(rest)
-
- /** Return true if this address is still acceptable. For BLE that means, still bonded */
- override fun addressValid(rest: String): Boolean {
- val allPaired = bluetoothRepository.state.value.bondedDevices.map { it.address }.toSet()
- return if (!allPaired.contains(rest)) {
- Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
- false
- } else {
- true
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
deleted file mode 100644
index 7d1ebfbd5..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ /dev/null
@@ -1,397 +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.geeksville.mesh.repository.radio
-
-import android.app.Application
-import android.provider.Settings
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.network.NetworkRepository
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-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.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import no.nordicsemi.android.common.core.simpleSharedFlow
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.ble.BleError
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.common.util.BinaryLogFile
-import org.meshtastic.core.common.util.BuildUtils
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.ignoreException
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.toRemoteExceptions
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.di.ProcessLifecycle
-import org.meshtastic.core.model.util.anonymize
-import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.ToRadio
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
- * etc...
- *
- * This service is not exposed outside of this process.
- *
- * Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
- * can be stubbed out with a simulated version as needed.
- */
-@Suppress("LongParameterList")
-@Singleton
-open class RadioInterfaceService
-@Inject
-constructor(
- private val context: Application,
- private val dispatchers: CoroutineDispatchers,
- private val bluetoothRepository: BluetoothRepository,
- private val networkRepository: NetworkRepository,
- @ProcessLifecycle private val processLifecycle: Lifecycle,
- private val radioPrefs: RadioPrefs,
- private val interfaceFactory: InterfaceFactory,
- private val analytics: PlatformAnalytics,
-) {
-
- private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow = _connectionState.asStateFlow()
-
- private val _receivedData = simpleSharedFlow()
- val receivedData: SharedFlow = _receivedData
-
- private val _connectionError = simpleSharedFlow()
- val connectionError: SharedFlow = _connectionError.asSharedFlow()
-
- // Thread-safe StateFlow for tracking device address changes
- private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
- val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
-
- private val logSends = false
- private val logReceives = false
- private lateinit var sentPacketsLog: BinaryLogFile
- private lateinit var receivedPacketsLog: BinaryLogFile
-
- val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
-
- /** We recreate this scope each time we stop an interface */
- var serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
-
- private var radioIf: IRadioInterface = NopInterface("")
-
- /**
- * true if we have started our interface
- *
- * Note: an interface may be started without necessarily yet having a connection
- */
- private var isStarted = false
-
- @Volatile private var listenersInitialized = false
-
- private fun initStateListeners() {
- if (listenersInitialized) return
- synchronized(this) {
- if (listenersInitialized) return
- listenersInitialized = true
-
- bluetoothRepository.state
- .onEach { state ->
- if (state.enabled) {
- startInterface()
- } else if (radioIf is NordicBleInterface) {
- stopInterface()
- }
- }
- .launchIn(processLifecycle.coroutineScope)
-
- networkRepository.networkAvailable
- .onEach { state ->
- if (state) {
- startInterface()
- } else if (radioIf is TCPInterface) {
- stopInterface()
- }
- }
- .launchIn(processLifecycle.coroutineScope)
- }
- }
-
- companion object {
- private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
- }
-
- private var lastHeartbeatMillis = 0L
-
- fun keepAlive(now: Long = nowMillis) {
- if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
- if (radioIf is SerialInterface) {
- Logger.i { "Sending ToRadio heartbeat" }
- val heartbeat = ToRadio(heartbeat = Heartbeat())
- handleSendToRadio(heartbeat.encode())
- } else {
- // For BLE and TCP this will check if the connection is still alive
- radioIf.keepAlive()
- }
- lastHeartbeatMillis = now
- }
- }
-
- /** Constructs a full radio address for the specific interface type. */
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
- interfaceFactory.toInterfaceAddress(interfaceId, rest)
-
- fun isMockInterface(): Boolean =
- BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
-
- /**
- * Determines whether to default to mock interface for device address. This keeps the decision logic separate and
- * easy to extend.
- */
- private fun shouldDefaultToMockInterface(): Boolean = BuildUtils.isEmulator
-
- /**
- * Return the device we are configured to use, or null for none device address strings are of the form:
- *
- * at
- *
- * where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
- * path)
- */
- fun getDeviceAddress(): String? {
- // If the user has unpaired our device, treat things as if we don't have one
- var address = radioPrefs.devAddr
-
- // If we are running on the emulator we default to the mock interface, so we can have some data to show to the
- // user
- if (address == null && shouldDefaultToMockInterface()) {
- address = mockInterfaceAddress
- }
-
- return address
- }
-
- /**
- * Like getDeviceAddress, but filtered to return only devices we are currently bonded with
- *
- * at
- *
- * where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
- * path)
- */
- fun getBondedDeviceAddress(): String? {
- // If the user has unpaired our device, treat things as if we don't have one
- val address = getDeviceAddress()
- return if (interfaceFactory.addressValid(address)) {
- address
- } else {
- null
- }
- }
-
- private fun broadcastConnectionChanged(newState: ConnectionState) {
- Logger.d { "Broadcasting connection state change to $newState" }
- processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newState) }
- }
-
- // Send a packet/command out the radio link, this routine can block if it needs to
- private fun handleSendToRadio(p: ByteArray) {
- radioIf.handleSendToRadio(p)
- emitSendActivity()
- }
-
- // Handle an incoming packet from the radio, broadcasts it as an android intent
- open fun handleFromRadio(p: ByteArray) {
- if (logReceives) {
- try {
- receivedPacketsLog.write(p)
- receivedPacketsLog.flush()
- } catch (t: Throwable) {
- Logger.w(t) { "Failed to write receive log in handleFromRadio" }
- }
- }
-
- try {
- processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
- emitReceiveActivity()
- } catch (t: Throwable) {
- Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
- }
- }
-
- fun onConnect() {
- if (_connectionState.value != ConnectionState.Connected) {
- broadcastConnectionChanged(ConnectionState.Connected)
- }
- }
-
- fun onDisconnect(isPermanent: Boolean) {
- val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
- if (_connectionState.value != newTargetState) {
- broadcastConnectionChanged(newTargetState)
- }
- }
-
- fun onDisconnect(error: BleError) {
- processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) }
- onDisconnect(!error.shouldReconnect)
- }
-
- /** Start our configured interface (if it isn't already running) */
- private fun startInterface() {
- if (radioIf !is NopInterface) {
- // Already running
- return
- } else {
- val address = getBondedDeviceAddress()
- if (address == null) {
- Logger.w { "No bonded mesh radio, can't start interface" }
- } else {
- Logger.i { "Starting radio ${address.anonymize}" }
- isStarted = true
-
- if (logSends) {
- sentPacketsLog = BinaryLogFile(context, "sent_log.pb")
- }
- if (logReceives) {
- receivedPacketsLog = BinaryLogFile(context, "receive_log.pb")
- }
-
- radioIf = interfaceFactory.createInterface(address)
- startHeartbeat()
- }
- }
- }
-
- private var heartbeatJob: kotlinx.coroutines.Job? = null
-
- private fun startHeartbeat() {
- heartbeatJob?.cancel()
- heartbeatJob =
- serviceScope.launch {
- while (true) {
- delay(HEARTBEAT_INTERVAL_MILLIS)
- keepAlive()
- }
- }
- }
-
- private fun stopInterface() {
- val r = radioIf
- Logger.i { "stopping interface $r" }
- isStarted = false
- radioIf = interfaceFactory.nopInterface
- r.close()
-
- // cancel any old jobs and get ready for the new ones
- serviceScope.cancel("stopping interface")
- serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
-
- if (logSends) {
- sentPacketsLog.close()
- }
- if (logReceives) {
- receivedPacketsLog.close()
- }
-
- // Don't broadcast disconnects if we were just using the nop device
- if (r !is NopInterface) {
- onDisconnect(isPermanent = true) // Tell any clients we are now offline
- }
- }
-
- /**
- * Change to a new device
- *
- * @return true if the device changed, false if no change
- */
- private fun setBondedDeviceAddress(address: String?): Boolean =
- if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
- Logger.w { "Ignoring setBondedDevice ${address.anonymize}, because we are already using that device" }
- false
- } else {
- // Record that this use has configured a new radio
- analytics.track("mesh_bond")
-
- // Ignore any errors that happen while closing old device
- ignoreException { stopInterface() }
-
- // The device address "n" can be used to mean none
-
- Logger.d { "Setting bonded device to ${address.anonymize}" }
-
- // Stores the address if non-null, otherwise removes the pref
- radioPrefs.devAddr = address
- _currentDeviceAddressFlow.value = address
-
- // Force the service to reconnect
- startInterface()
- true
- }
-
- fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { setBondedDeviceAddress(deviceAddr) }
-
- /**
- * If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
- * will not connect to a radio until this call is received.
- */
- fun connect() = toRemoteExceptions {
- // We don't start actually talking to our device until MeshService binds to us - this prevents
- // broadcasting connection events before MeshService is ready to receive them
- startInterface()
- initStateListeners()
- }
-
- fun sendToRadio(a: ByteArray) {
- // Do this in the IO thread because it might take a while (and we don't care about the result code)
- serviceScope.handledLaunch { handleSendToRadio(a) }
- }
-
- private val _meshActivity = simpleSharedFlow()
- val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
-
- private fun emitSendActivity() {
- // Use tryEmit for SharedFlow as it's non-blocking
- val emitted = _meshActivity.tryEmit(MeshActivity.Send)
- if (!emitted) {
- Logger.d { "MeshActivity.Send event was not emitted due to buffer overflow or no collectors" }
- }
- }
-
- private fun emitReceiveActivity() {
- val emitted = _meshActivity.tryEmit(MeshActivity.Receive)
- if (!emitted) {
- Logger.d { "MeshActivity.Receive event was not emitted due to buffer overflow or no collectors" }
- }
- }
-}
-
-sealed class MeshActivity {
- data object Send : MeshActivity()
-
- data object Receive : MeshActivity()
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
deleted file mode 100644
index 6a1d91f1a..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
+++ /dev/null
@@ -1,48 +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 com.geeksville.mesh.repository.radio
-
-import dagger.Binds
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import dagger.multibindings.IntoMap
-import dagger.multibindings.Multibinds
-
-@Suppress("unused") // Used by hilt
-@Module
-@InstallIn(SingletonComponent::class)
-abstract class RadioRepositoryModule {
-
- @Multibinds abstract fun interfaceMap(): Map>
-
- @[Binds IntoMap InterfaceMapKey(InterfaceId.BLUETOOTH)]
- abstract fun bindBluetoothInterfaceSpec(spec: NordicBleInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
-
- @[Binds IntoMap InterfaceMapKey(InterfaceId.MOCK)]
- abstract fun bindMockInterfaceSpec(spec: MockInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
-
- @[Binds IntoMap InterfaceMapKey(InterfaceId.NOP)]
- abstract fun bindNopInterfaceSpec(spec: NopInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
-
- @[Binds IntoMap InterfaceMapKey(InterfaceId.SERIAL)]
- abstract fun bindSerialInterfaceSpec(spec: SerialInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
-
- @[Binds IntoMap InterfaceMapKey(InterfaceId.TCP)]
- abstract fun bindTCPInterfaceSpec(spec: TCPInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*>
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt
deleted file mode 100644
index 294e5eb1d..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt
+++ /dev/null
@@ -1,50 +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.geeksville.mesh.repository.radio
-
-import android.hardware.usb.UsbManager
-import com.geeksville.mesh.repository.usb.UsbRepository
-import com.hoho.android.usbserial.driver.UsbSerialDriver
-import javax.inject.Inject
-
-/** Serial/USB interface backend implementation. */
-class SerialInterfaceSpec
-@Inject
-constructor(
- private val factory: SerialInterfaceFactory,
- private val usbManager: dagger.Lazy,
- private val usbRepository: UsbRepository,
-) : InterfaceSpec {
- override fun createInterface(rest: String): SerialInterface = factory.create(rest)
-
- override fun addressValid(rest: String): Boolean {
- usbRepository.serialDevices.value.filterValues { usbManager.get().hasPermission(it.device) }
- findSerial(rest)?.let { d ->
- return usbManager.get().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/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
deleted file mode 100644
index 538f4088a..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
+++ /dev/null
@@ -1,165 +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.geeksville.mesh.repository.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-
-/**
- * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
- * probably)
- */
-abstract class StreamInterface(protected val service: RadioInterfaceService) : IRadioInterface {
- companion object {
- private const val START1 = 0x94.toByte()
- private const val START2 = 0xc3.toByte()
- private const val MAX_TO_FROM_RADIO_SIZE = 512
- }
-
- private val debugLineBuf = kotlin.text.StringBuilder()
-
- private val writeMutex = Mutex()
-
- /** The index of the next byte we are hoping to receive */
- private var ptr = 0
-
- /** The two halves of our length */
- private var msb = 0
- private var lsb = 0
- private var packetLen = 0
-
- override fun close() {
- Logger.d { "Closing stream for good" }
- onDeviceDisconnect(true)
- }
-
- /**
- * Tell MeshService our device has gone away, but wait for it to come back
- *
- * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the
- * manager callbacks
- */
- protected open fun onDeviceDisconnect(waitForStopped: Boolean) {
- service.onDisconnect(
- isPermanent = true,
- ) // if USB device disconnects it is definitely permanently gone, not sleeping)
- }
-
- protected open fun connect() {
- // Before telling mesh service, send a few START1s to wake a sleeping device
- val wakeBytes = byteArrayOf(START1, START1, START1, START1)
- sendBytes(wakeBytes)
-
- // Now tell clients they can (finally use the api)
- service.onConnect()
- }
-
- abstract fun sendBytes(p: ByteArray)
-
- // If subclasses need to flash at the end of a packet they can implement
- open fun flushBytes() {}
-
- override fun handleSendToRadio(p: ByteArray) {
- // This method is called from a continuation and it might show up late, so check for uart being null
-
- service.serviceScope.launch {
- writeMutex.withLock {
- val header = ByteArray(4)
- header[0] = START1
- header[1] = START2
- header[2] = (p.size shr 8).toByte()
- header[3] = (p.size and 0xff).toByte()
-
- sendBytes(header)
- sendBytes(p)
- flushBytes()
- }
- }
- }
-
- /** Print device serial debug output somewhere */
- private fun debugOut(b: Byte) {
- when (val c = b.toInt().toChar()) {
- '\r' -> {} // ignore
- '\n' -> {
- Logger.d { "DeviceLog: $debugLineBuf" }
- debugLineBuf.clear()
- }
- else -> debugLineBuf.append(c)
- }
- }
-
- private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE)
-
- protected fun readChar(c: Byte) {
- // Assume we will be advancing our pointer
- var nextPtr = ptr + 1
-
- fun lostSync() {
- Logger.e { "Lost protocol sync" }
- nextPtr = 0
- }
-
- // Deliver our current packet and restart our reader
- fun deliverPacket() {
- val buf = rxPacket.copyOf(packetLen)
- service.handleFromRadio(buf)
-
- nextPtr = 0 // Start parsing the next packet
- }
-
- when (ptr) {
- 0 -> // looking for START1
- if (c != START1) {
- debugOut(c)
- nextPtr = 0 // Restart from scratch
- }
- 1 -> // Looking for START2
- if (c != START2) {
- lostSync() // Restart from scratch
- }
- 2 -> // Looking for MSB of our 16 bit length
- msb = c.toInt() and 0xff
- 3 -> { // Looking for LSB of our 16 bit length
- lsb = c.toInt() and 0xff
-
- // We've read our header, do one big read for the packet itself
- packetLen = (msb shl 8) or lsb
- if (packetLen > MAX_TO_FROM_RADIO_SIZE) {
- lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for
- // START1 again
- } else if (packetLen == 0) {
- deliverPacket() // zero length packets are valid and should be delivered immediately (because there
- // won't be a next byte of payload)
- }
- }
- else -> {
- // We are looking at the packet bytes now
- rxPacket[ptr - 4] = c
-
- // Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this
- // code will be run with ptr of4
- if (ptr - 4 + 1 == packetLen) {
- deliverPacket()
- }
- }
- }
- ptr = nextPtr
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
deleted file mode 100644
index e2eeefa4c..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
+++ /dev/null
@@ -1,251 +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.geeksville.mesh.repository.radio
-
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.network.NetworkRepository
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-import org.meshtastic.core.common.util.Exceptions
-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
-import java.io.IOException
-import java.io.OutputStream
-import java.net.InetAddress
-import java.net.Socket
-import java.net.SocketTimeoutException
-
-open class TCPInterface
-@AssistedInject
-constructor(
- service: RadioInterfaceService,
- private val dispatchers: CoroutineDispatchers,
- @Assisted private val address: String,
-) : StreamInterface(service) {
-
- companion object {
- const val MAX_RETRIES_ALLOWED = Int.MAX_VALUE
- const val MIN_BACKOFF_MILLIS = 1 * 1000L // 1 second
- const val MAX_BACKOFF_MILLIS = 5 * 60 * 1000L // 5 minutes
- const val SOCKET_TIMEOUT = 5000
- const val SOCKET_RETRIES = 18
- const val SERVICE_PORT = NetworkRepository.SERVICE_PORT
- const val TIMEOUT_LOG_INTERVAL = 5 // Log every Nth timeout
- }
-
- private var retryCount = 1
- private var backoffDelay = MIN_BACKOFF_MILLIS
-
- private var socket: Socket? = null
- private var outStream: OutputStream? = null
-
- 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
-
- init {
- connect()
- }
-
- override fun sendBytes(p: ByteArray) {
- val stream = outStream
- if (stream == null) {
- Logger.w { "[$address] TCP cannot send ${p.size} bytes: outStream is null (connection not established)" }
- return
- }
-
- packetsSent++
- bytesSent += p.size
- Logger.d { "[$address] TCP sending packet #$packetsSent - ${p.size} bytes (Total TX: $bytesSent bytes)" }
- try {
- stream.write(p)
- } catch (ex: IOException) {
- // TCP write errors are common when the connection is lost; log as warning to avoid Crashlytics noise
- Logger.w(ex) { "[$address] TCP write error: ${ex.message}" }
- onDeviceDisconnect(false)
- }
- }
-
- override fun flushBytes() {
- val stream = outStream ?: return
- Logger.d { "[$address] TCP flushing output stream" }
- try {
- stream.flush()
- } catch (ex: IOException) {
- // TCP flush errors are common when the connection is lost; log as warning to avoid Crashlytics noise
- Logger.w(ex) { "[$address] TCP flush error: ${ex.message}" }
- onDeviceDisconnect(false)
- }
- }
-
- override fun onDeviceDisconnect(waitForStopped: Boolean) {
- val s = socket
- if (s != null) {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.w {
- "[$address] TCP disconnecting - " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes), " +
- "Timeout events: $timeoutEvents"
- }
- s.close()
- socket = null
- outStream = null
- }
- super.onDeviceDisconnect(waitForStopped)
- }
-
- override fun connect() {
- service.serviceScope.handledLaunch {
- while (true) {
- try {
- startConnect()
- } catch (ex: IOException) {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- // Connection failures are common when the radio is offline or out of range
- Logger.w(ex) { "[$address] TCP connection error after ${uptime}ms - ${ex.message}" }
- onDeviceDisconnect(false)
- } catch (ex: Throwable) {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.e(ex) { "[$address] TCP exception after ${uptime}ms - ${ex.message}" }
- Exceptions.report(ex, "Exception in TCP reader")
- onDeviceDisconnect(false)
- }
-
- if (retryCount > MAX_RETRIES_ALLOWED) {
- Logger.e { "[$address] TCP max retries ($MAX_RETRIES_ALLOWED) exceeded, giving up" }
- break
- }
-
- Logger.i {
- "[$address] TCP reconnect attempt #$retryCount in ${backoffDelay / 1000}s " +
- "(backoff: ${backoffDelay}ms)"
- }
- delay(backoffDelay)
-
- retryCount++
- backoffDelay = minOf(backoffDelay * 2, MAX_BACKOFF_MILLIS)
- }
- Logger.i { "[$address] TCP reader exiting" }
- }
- }
-
- override fun keepAlive() {
- Logger.d { "[$address] TCP keepAlive" }
- val heartbeat = ToRadio(heartbeat = Heartbeat())
- handleSendToRadio(heartbeat.encode())
- }
-
- // Create a socket to make the connection with the server
- private suspend fun startConnect() = withContext(dispatchers.io) {
- val attemptStart = nowMillis
- Logger.i { "[$address] TCP connection attempt starting..." }
-
- val parts = address.split(":", limit = 2)
- val host = parts[0]
- val port = parts.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT
-
- Logger.d { "[$address] Resolving host '$host' and connecting to port $port..." }
-
- Socket(InetAddress.getByName(host), port).use { socket ->
- socket.tcpNoDelay = true
- socket.keepAlive = true
- socket.soTimeout = SOCKET_TIMEOUT
- this@TCPInterface.socket = socket
-
- val connectTime = nowMillis - attemptStart
- connectionStartTime = nowMillis
- Logger.i {
- "[$address] TCP socket connected in ${connectTime}ms - " +
- "Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}"
- }
-
- BufferedOutputStream(socket.getOutputStream()).use { outputStream ->
- outStream = outputStream
-
- BufferedInputStream(socket.getInputStream()).use { inputStream ->
- super.connect()
-
- retryCount = 1
- backoffDelay = MIN_BACKOFF_MILLIS
-
- var timeoutCount = 0
- while (timeoutCount < SOCKET_RETRIES) {
- try { // close after 90s of inactivity
- val c = inputStream.read()
- if (c == -1) {
- Logger.w {
- "[$address] TCP got EOF on stream after $packetsReceived packets received"
- }
- break
- } else {
- timeoutCount = 0
- packetsReceived++
- bytesReceived++
- readChar(c.toByte())
- }
- } catch (ex: SocketTimeoutException) {
- timeoutCount++
- timeoutEvents++
- if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
- Logger.d {
- "[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " +
- "(total timeouts: $timeoutEvents)"
- }
- }
- // Ignore and start another read
- }
- }
- if (timeoutCount >= SOCKET_RETRIES) {
- val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT
- Logger.w {
- "[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " +
- "(${inactivityMs}ms of inactivity)"
- }
- }
- }
- }
- onDeviceDisconnect(false)
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/README.md b/app/src/main/java/com/geeksville/mesh/repository/usb/README.md
deleted file mode 100644
index 0b3fac3d4..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/usb/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# USB Module
-
-This module provides a repository for acessing USB devices.
-
-## Device Support
-
-In order to be picked up, devices need to be supported by two different mechanisms:
-- Android needs to be supplied with a device filter so that it knows what devices to inform
- the app about. These are expressed as vendor and device IDs in `src/res/xml/device_filter.xml`.
-- The USB driver library also needs to have a mapping between the vendor + device IDs and the
- driver to use for communications. Many mappings are already natively supported by the driver
- but unknown devices can have manual mappings added via `ProbeTableProvider`.
-
-The [Serial USB Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_usb_terminal)
-app in the Google Play Store seems to be a good app for determining both the vendor and
-device IDs as well as testing different underlying drivers.
-
-
-## Testing
-
-When granting permissions to a USB device, the Android platform remembers the user's decision.
-In order to test the permission granting logic, re-install the app. This will cause Android
-to forget previously granted permissions and will re-trigger the permission acquisition logic.
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt
deleted file mode 100644
index 8aeb0abd7..000000000
--- a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt
+++ /dev/null
@@ -1,44 +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 com.geeksville.mesh.repository.usb
-
-import android.app.Application
-import android.content.Context
-import android.hardware.usb.UsbManager
-import com.hoho.android.usbserial.driver.ProbeTable
-import com.hoho.android.usbserial.driver.UsbSerialProber
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-
-@Module
-@InstallIn(SingletonComponent::class)
-interface UsbRepositoryModule {
- companion object {
- @Provides
- fun provideUsbManager(application: Application): UsbManager? =
- application.getSystemService(Context.USB_SERVICE) as UsbManager?
-
- @Provides
- fun provideProbeTable(provider: ProbeTableProvider): ProbeTable = provider.get()
-
- @Provides
- fun provideUsbSerialProber(probeTable: ProbeTable): UsbSerialProber = UsbSerialProber(probeTable)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
deleted file mode 100644
index a771b6fa2..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
+++ /dev/null
@@ -1,84 +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.geeksville.mesh.service
-
-import co.touchlab.kermit.Logger
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.FromRadio
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing
- * for config, metadata, and specialized system messages.
- */
-@Singleton
-class FromRadioPacketHandler
-@Inject
-constructor(
- private val serviceRepository: ServiceRepository,
- private val router: MeshRouter,
- private val mqttManager: MeshMqttManager,
- private val packetHandler: PacketHandler,
- private val serviceNotifications: MeshServiceNotifications,
-) {
- @Suppress("CyclomaticComplexMethod")
- fun handleFromRadio(proto: FromRadio) {
- val myInfo = proto.my_info
- val metadata = proto.metadata
- val nodeInfo = proto.node_info
- val configCompleteId = proto.config_complete_id
- val mqttProxyMessage = proto.mqttClientProxyMessage
- val queueStatus = proto.queueStatus
- val config = proto.config
- val moduleConfig = proto.moduleConfig
- val channel = proto.channel
- val clientNotification = proto.clientNotification
-
- when {
- myInfo != null -> router.configFlowManager.handleMyInfo(myInfo)
- metadata != null -> router.configFlowManager.handleLocalMetadata(metadata)
- nodeInfo != null -> {
- router.configFlowManager.handleNodeInfo(nodeInfo)
- serviceRepository.setConnectionProgress("Nodes (${router.configFlowManager.newNodeCount})")
- }
- configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
- mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
- queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
- config != null -> router.configHandler.handleDeviceConfig(config)
- moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
- channel != null -> router.configHandler.handleChannel(channel)
- clientNotification != null -> {
- serviceRepository.setClientNotification(clientNotification)
- serviceNotifications.showClientNotification(clientNotification)
- packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
- }
- // Logging-only variants are handled by MeshMessageProcessor before dispatching here
- proto.packet != null ||
- proto.log_record != null ||
- proto.rebooted != null ||
- proto.xmodemPacket != null ||
- proto.deviceuiConfig != null ||
- proto.fileInfo != null -> {
- /* No specialized routing needed here */
- }
-
- else -> Logger.d { "Dispatcher ignoring FromRadio variant" }
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
deleted file mode 100644
index ad3f64d34..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ /dev/null
@@ -1,206 +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.geeksville.mesh.service
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.proto.DeviceMetadata
-import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.MyNodeInfo
-import org.meshtastic.proto.NodeInfo
-import org.meshtastic.proto.ToRadio
-import java.io.IOException
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Suppress("LongParameterList")
-@Singleton
-class MeshConfigFlowManager
-@Inject
-constructor(
- private val nodeManager: MeshNodeManager,
- private val connectionManager: MeshConnectionManager,
- private val nodeRepository: NodeRepository,
- private val radioConfigRepository: RadioConfigRepository,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
- private val analytics: PlatformAnalytics,
- private val commandSender: MeshCommandSender,
- private val packetHandler: PacketHandler,
-) {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private val configOnlyNonce = 69420
- private val nodeInfoNonce = 69421
- private val wantConfigDelay = 100L
-
- fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
- private val newNodes = mutableListOf()
- val newNodeCount: Int
- get() = newNodes.size
-
- private var rawMyNodeInfo: MyNodeInfo? = null
- private var lastMetadata: DeviceMetadata? = null
- private var newMyNodeInfo: MyNodeEntity? = null
- private var myNodeInfo: MyNodeEntity? = null
-
- fun handleConfigComplete(configCompleteId: Int) {
- when (configCompleteId) {
- configOnlyNonce -> handleConfigOnlyComplete()
- nodeInfoNonce -> handleNodeInfoComplete()
- else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
- }
- }
-
- private fun handleConfigOnlyComplete() {
- Logger.i { "Config-only complete (Stage 1)" }
- if (newMyNodeInfo == null) {
- Logger.w {
- "newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
- }
- regenMyNodeInfo(lastMetadata)
- }
-
- val finalizedInfo = newMyNodeInfo
- if (finalizedInfo == null) {
- Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
- } else {
- myNodeInfo = finalizedInfo
- Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
- connectionManager.onRadioConfigLoaded()
- }
-
- scope.handledLaunch {
- delay(wantConfigDelay)
- sendHeartbeat()
- delay(wantConfigDelay)
- Logger.i { "Requesting NodeInfo (Stage 2)" }
- connectionManager.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() {
- Logger.i { "NodeInfo complete (Stage 2)" }
- val entities =
- newNodes.map { info ->
- nodeManager.installNodeInfo(info, withBroadcast = false)
- nodeManager.nodeDBbyNodeNum[info.num]!!
- }
- newNodes.clear()
-
- scope.handledLaunch {
- myNodeInfo?.let {
- nodeRepository.installConfig(it, entities)
- sendAnalytics(it)
- }
- nodeManager.isNodeDbReady.value = true
- nodeManager.allowNodeDbWrites.value = true
- connectionStateHolder.setState(ConnectionState.Connected)
- serviceBroadcasts.broadcastConnection()
- connectionManager.onNodeDbReady()
- }
- }
-
- private fun sendAnalytics(mi: MyNodeEntity) {
- analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
- }
-
- fun handleMyInfo(myInfo: MyNodeInfo) {
- Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
- rawMyNodeInfo = myInfo
- nodeManager.myNodeNum = myInfo.my_node_num
- regenMyNodeInfo(lastMetadata)
-
- scope.handledLaunch {
- radioConfigRepository.clearChannelSet()
- radioConfigRepository.clearLocalConfig()
- radioConfigRepository.clearLocalModuleConfig()
- }
- }
-
- fun handleLocalMetadata(metadata: DeviceMetadata) {
- Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
- lastMetadata = metadata
- regenMyNodeInfo(metadata)
- }
-
- fun handleNodeInfo(info: NodeInfo) {
- newNodes.add(info)
- }
-
- private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
- val myInfo = rawMyNodeInfo
- if (myInfo != null) {
- try {
- val mi =
- with(myInfo) {
- MyNodeEntity(
- myNodeNum = my_node_num ?: 0,
- model =
- when (val hwModel = metadata?.hw_model) {
- null,
- HardwareModel.UNSET,
- -> null
- else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
- },
- firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
- couldUpdate = false,
- shouldUpdate = false,
- currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
- messageTimeoutMsec = 300000,
- minAppVersion = min_app_version,
- maxChannels = 8,
- hasWifi = metadata?.hasWifi == true,
- deviceId = device_id.utf8(),
- pioEnv = myInfo.pio_env.ifEmpty { null },
- )
- }
- if (metadata != null && metadata != DeviceMetadata()) {
- scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
- }
- newMyNodeInfo = mi
- Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
- } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
- Logger.e(ex) { "Failed to regenMyNodeInfo" }
- }
- } else {
- Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
deleted file mode 100644
index 616529d14..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
+++ /dev/null
@@ -1,87 +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.geeksville.mesh.service
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.Channel
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.LocalModuleConfig
-import org.meshtastic.proto.ModuleConfig
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class MeshConfigHandler
-@Inject
-constructor(
- private val radioConfigRepository: RadioConfigRepository,
- private val serviceRepository: ServiceRepository,
- private val nodeManager: MeshNodeManager,
-) {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
- private val _localConfig = MutableStateFlow(LocalConfig())
- val localConfig = _localConfig.asStateFlow()
-
- private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
- val moduleConfig = _moduleConfig.asStateFlow()
-
- fun start(scope: CoroutineScope) {
- this.scope = scope
- radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
-
- radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
- }
-
- fun handleDeviceConfig(config: Config) {
- scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
- serviceRepository.setConnectionProgress("Device config received")
- }
-
- fun handleModuleConfig(config: ModuleConfig) {
- scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
- serviceRepository.setConnectionProgress("Module config received")
-
- config.statusmessage?.let { sm ->
- nodeManager.myNodeNum?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
- }
- }
-
- fun handleChannel(ch: Channel) {
- // We always want to save channel settings we receive from the radio
- scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
-
- // Update status message if we have node info, otherwise use a generic one
- val mi = nodeManager.getMyNodeInfo()
- val index = ch.index ?: 0
- if (mi != null) {
- serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
- } else {
- serviceRepository.setConnectionProgress("Channels (${index + 1})")
- }
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
deleted file mode 100644
index bd777c538..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ /dev/null
@@ -1,338 +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.geeksville.mesh.service
-
-import android.app.Notification
-import android.content.Context
-import androidx.glance.appwidget.updateAll
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.geeksville.mesh.widget.LocalStatsWidget
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import org.meshtastic.core.analytics.DataPair
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.model.TelemetryType
-import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.connected
-import org.meshtastic.core.resources.connecting
-import org.meshtastic.core.resources.device_sleeping
-import org.meshtastic.core.resources.disconnected
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.meshtastic_app_name
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.proto.AdminMessage
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.ToRadio
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.DurationUnit
-
-@Suppress("LongParameterList", "TooManyFunctions")
-@Singleton
-class MeshConnectionManager
-@Inject
-constructor(
- @ApplicationContext private val context: Context,
- private val radioInterfaceService: RadioInterfaceService,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
- private val serviceNotifications: MeshServiceNotifications,
- private val uiPrefs: UiPrefs,
- private val packetHandler: PacketHandler,
- private val nodeRepository: NodeRepository,
- private val locationManager: MeshLocationManager,
- private val mqttManager: MeshMqttManager,
- private val historyManager: MeshHistoryManager,
- private val radioConfigRepository: RadioConfigRepository,
- private val commandSender: MeshCommandSender,
- private val nodeManager: MeshNodeManager,
- private val analytics: PlatformAnalytics,
-) {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private var sleepTimeout: Job? = null
- private var locationRequestsJob: Job? = null
- private var handshakeTimeout: Job? = null
- private var connectTimeMsec = 0L
-
- @OptIn(FlowPreview::class)
- fun start(scope: CoroutineScope) {
- this.scope = scope
- radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
-
- // Ensure notification title and content stay in sync with state changes
- connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
-
- // Kickstart the widget composition. The widget internally uses collectAsState()
- // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
- scope.launch {
- try {
- LocalStatsWidget().updateAll(context)
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
- }
- }
-
- nodeRepository.myNodeInfo
- .onEach { myNodeEntity ->
- locationRequestsJob?.cancel()
- if (myNodeEntity != null) {
- locationRequestsJob =
- uiPrefs
- .shouldProvideNodeLocation(myNodeEntity.myNodeNum)
- .onEach { shouldProvide ->
- if (shouldProvide) {
- locationManager.start(scope) { pos -> commandSender.sendPosition(pos) }
- } else {
- locationManager.stop()
- }
- }
- .launchIn(scope)
- }
- }
- .launchIn(scope)
- }
-
- private fun onRadioConnectionState(newState: ConnectionState) {
- scope.handledLaunch {
- val localConfig = radioConfigRepository.localConfigFlow.first()
- val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
- val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
-
- val effectiveState =
- when (newState) {
- is ConnectionState.Connected -> ConnectionState.Connected
- is ConnectionState.DeviceSleep ->
- if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
- is ConnectionState.Connecting -> ConnectionState.Connecting
- is ConnectionState.Disconnected -> ConnectionState.Disconnected
- }
- onConnectionChanged(effectiveState)
- }
- }
-
- private fun onConnectionChanged(c: ConnectionState) {
- val current = connectionStateHolder.connectionState.value
- if (current == c) return
-
- // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
- if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
- Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
- return
- }
-
- Logger.i { "onConnectionChanged: $current -> $c" }
-
- sleepTimeout?.cancel()
- sleepTimeout = null
- handshakeTimeout?.cancel()
- handshakeTimeout = null
-
- when (c) {
- is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
- is ConnectionState.Connected -> handleConnected()
- is ConnectionState.DeviceSleep -> handleDeviceSleep()
- is ConnectionState.Disconnected -> handleDisconnected()
- }
- }
-
- private fun handleConnected() {
- // The service state remains 'Connecting' until config is fully loaded
- if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
- connectionStateHolder.setState(ConnectionState.Connecting)
- }
- serviceBroadcasts.broadcastConnection()
- Logger.i { "Starting mesh handshake (Stage 1)" }
- connectTimeMsec = nowMillis
- startConfigOnly()
-
- // Guard against handshake stalls
- handshakeTimeout =
- scope.handledLaunch {
- delay(HANDSHAKE_TIMEOUT)
- if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
- Logger.w { "Handshake stall detected! Retrying Stage 1." }
- startConfigOnly()
- // Recursive timeout for one more try
- delay(HANDSHAKE_TIMEOUT)
- if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
- Logger.e { "Handshake still stalled after retry. Resetting connection." }
- onConnectionChanged(ConnectionState.Disconnected)
- }
- }
- }
- }
-
- private fun handleDeviceSleep() {
- connectionStateHolder.setState(ConnectionState.DeviceSleep)
- packetHandler.stopPacketQueue()
- locationManager.stop()
- mqttManager.stop()
-
- if (connectTimeMsec != 0L) {
- val now = nowMillis
- val duration = now - connectTimeMsec
- connectTimeMsec = 0L
- analytics.track(
- EVENT_CONNECTED_SECONDS,
- DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)),
- )
- }
-
- sleepTimeout =
- 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" }
- delay(timeout.seconds)
- Logger.w { "Device timeout out, setting disconnected" }
- onConnectionChanged(ConnectionState.Disconnected)
- } catch (_: CancellationException) {
- Logger.d { "device sleep timeout cancelled" }
- }
- }
-
- serviceBroadcasts.broadcastConnection()
- }
-
- private fun handleDisconnected() {
- connectionStateHolder.setState(ConnectionState.Disconnected)
- packetHandler.stopPacketQueue()
- locationManager.stop()
- mqttManager.stop()
-
- analytics.track(
- EVENT_MESH_DISCONNECT,
- DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
- DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
- )
- analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
-
- serviceBroadcasts.broadcastConnection()
- }
-
- fun startConfigOnly() {
- packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
- }
-
- fun startNodeInfoOnly() {
- packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
- }
-
- fun onRadioConfigLoaded() {
- commandSender.processQueuedPackets()
-
- val myNodeNum = nodeManager.myNodeNum ?: 0
- // Set time
- commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
- }
-
- fun onNodeDbReady() {
- handshakeTimeout?.cancel()
- handshakeTimeout = null
-
- // Start MQTT if enabled
- scope.handledLaunch {
- val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
- mqttManager.start(
- scope,
- moduleConfig.mqtt?.enabled == true,
- moduleConfig.mqtt?.proxy_to_client_enabled == true,
- )
- }
-
- reportConnection()
-
- val myNodeNum = nodeManager.myNodeNum ?: 0
- // Request history
- scope.handledLaunch {
- val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
- moduleConfig.store_forward?.let {
- historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown")
- }
- }
-
- // Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
- }
-
- private fun reportConnection() {
- val myNode = nodeManager.getMyNodeInfo()
- val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown")
- analytics.track(
- EVENT_MESH_CONNECT,
- DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
- DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
- radioModel,
- )
- }
-
- fun updateTelemetry(telemetry: Telemetry) {
- telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
- updateStatusNotification(telemetry)
- }
-
- fun updateStatusNotification(telemetry: Telemetry? = null): Notification {
- val summary =
- when (connectionStateHolder.connectionState.value) {
- is ConnectionState.Connected ->
- getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
- is ConnectionState.Disconnected -> getString(Res.string.disconnected)
- is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
- is ConnectionState.Connecting -> getString(Res.string.connecting)
- }
- return serviceNotifications.updateServiceStateNotification(summary, telemetry = telemetry)
- }
-
- companion object {
- private const val CONFIG_ONLY_NONCE = 69420
- private const val NODE_INFO_NONCE = 69421
- private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
- private val HANDSHAKE_TIMEOUT = 10.seconds
-
- private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
- private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
- private const val EVENT_NUM_NODES = "num_nodes"
- private const val EVENT_MESH_CONNECT = "mesh_connect"
-
- private const val KEY_NUM_NODES = "num_nodes"
- private const val KEY_NUM_ONLINE = "num_online"
- private const val KEY_RADIO_MODEL = "radio_model"
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
deleted file mode 100644
index 36338d493..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ /dev/null
@@ -1,804 +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.geeksville.mesh.service
-
-import android.util.Log
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Severity
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.radio.InterfaceId
-import dagger.Lazy
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.first
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.analytics.DataPair
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.Packet
-import org.meshtastic.core.database.entity.ReactionEntity
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.MessageStatus
-import org.meshtastic.core.model.util.SfppHasher
-import org.meshtastic.core.model.util.decodeOrNull
-import org.meshtastic.core.model.util.toOneLiner
-import org.meshtastic.core.prefs.mesh.MeshPrefs
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.critical_alert
-import org.meshtastic.core.resources.error_duty_cycle
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.unknown_username
-import org.meshtastic.core.resources.waypoint_received
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.filter.MessageFilterService
-import org.meshtastic.proto.AdminMessage
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.Paxcount
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Position
-import org.meshtastic.proto.Routing
-import org.meshtastic.proto.StatusMessage
-import org.meshtastic.proto.StoreAndForward
-import org.meshtastic.proto.StoreForwardPlusPlus
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.User
-import org.meshtastic.proto.Waypoint
-import java.io.IOException
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.time.Duration.Companion.milliseconds
-
-@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
-@Singleton
-class MeshDataHandler
-@Inject
-constructor(
- private val nodeManager: MeshNodeManager,
- private val packetHandler: PacketHandler,
- private val serviceRepository: ServiceRepository,
- private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
- private val serviceNotifications: MeshServiceNotifications,
- private val analytics: PlatformAnalytics,
- private val dataMapper: MeshDataMapper,
- private val configHandler: MeshConfigHandler,
- private val configFlowManager: MeshConfigFlowManager,
- private val commandSender: MeshCommandSender,
- private val historyManager: MeshHistoryManager,
- private val meshPrefs: MeshPrefs,
- private val connectionManager: MeshConnectionManager,
- private val tracerouteHandler: MeshTracerouteHandler,
- private val neighborInfoHandler: MeshNeighborInfoHandler,
- private val radioConfigRepository: RadioConfigRepository,
- private val messageFilterService: MessageFilterService,
-) {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
- fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
- private val rememberDataType =
- setOf(
- PortNum.TEXT_MESSAGE_APP.value,
- PortNum.ALERT_APP.value,
- PortNum.WAYPOINT_APP.value,
- PortNum.NODE_STATUS_APP.value,
- )
-
- fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) {
- val dataPacket = dataMapper.toDataPacket(packet) ?: return
- val fromUs = myNodeNum == packet.from
- dataPacket.status = MessageStatus.RECEIVED
-
- val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
-
- if (shouldBroadcast) {
- serviceBroadcasts.broadcastReceivedData(dataPacket)
- }
- analytics.track("num_data_receive", DataPair("num_data_receive", 1))
- }
-
- private fun handleDataPacket(
- packet: MeshPacket,
- dataPacket: DataPacket,
- myNodeNum: Int,
- fromUs: Boolean,
- logUuid: String?,
- logInsertJob: Job?,
- ): Boolean {
- var shouldBroadcast = !fromUs
- val decoded = packet.decoded ?: return shouldBroadcast
- when (decoded.portnum) {
- PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
- PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
- PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
- PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
- PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
- PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
- PortNum.TELEMETRY_APP -> handleTelemetry(packet, dataPacket, myNodeNum)
- else ->
- shouldBroadcast =
- handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
- }
- return shouldBroadcast
- }
-
- private fun handleSpecializedDataPacket(
- packet: MeshPacket,
- dataPacket: DataPacket,
- myNodeNum: Int,
- fromUs: Boolean,
- logUuid: String?,
- logInsertJob: Job?,
- ): Boolean {
- var shouldBroadcast = !fromUs
- val decoded = packet.decoded ?: return shouldBroadcast
- when (decoded.portnum) {
- PortNum.TRACEROUTE_APP -> {
- tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
- shouldBroadcast = false
- }
- PortNum.ROUTING_APP -> {
- handleRouting(packet, dataPacket)
- shouldBroadcast = true
- }
-
- PortNum.PAXCOUNTER_APP -> {
- handlePaxCounter(packet)
- }
-
- PortNum.STORE_FORWARD_APP -> {
- handleStoreAndForward(packet, dataPacket, myNodeNum)
- }
-
- PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
- handleStoreForwardPlusPlus(packet)
- }
-
- PortNum.ADMIN_APP -> {
- handleAdminMessage(packet, myNodeNum)
- }
-
- PortNum.NEIGHBORINFO_APP -> {
- neighborInfoHandler.handleNeighborInfo(packet)
- shouldBroadcast = true
- }
-
- PortNum.ATAK_PLUGIN,
- PortNum.ATAK_FORWARDER,
- PortNum.PRIVATE_APP,
- -> {
- shouldBroadcast = true
- }
-
- PortNum.RANGE_TEST_APP,
- PortNum.DETECTION_SENSOR_APP,
- -> {
- handleRangeTest(dataPacket, myNodeNum)
- shouldBroadcast = true
- }
-
- else -> {
- // By default, if we don't know what it is, we should probably broadcast it
- // so that external apps can handle it.
- shouldBroadcast = true
- }
- }
- return shouldBroadcast
- }
-
- private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
- val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value)
- rememberDataPacket(u, myNodeNum)
- }
-
- private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val u = StoreAndForward.ADAPTER.decode(payload)
- handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
- }
-
- @Suppress("LongMethod")
- private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
- val payload = packet.decoded?.payload ?: return
- val sfpp =
- try {
- StoreForwardPlusPlus.ADAPTER.decode(payload)
- } catch (e: IOException) {
- Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
- return
- }
- Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
-
- when (sfpp.sfpp_message_type) {
- StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
- StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
- StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
- -> {
- val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
-
- // If it has a commit hash, it's already on the chain (Confirmed)
- // Otherwise it's still being routed via SF++ (Routing)
- val status =
- if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
-
- // Prefer a full 16-byte hash calculated from the message bytes if available
- // But only if it's NOT a fragment, otherwise the calculated hash would be wrong
- val hash =
- when {
- sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
- !isFragment && sfpp.message.size != 0 -> {
- SfppHasher.computeMessageHash(
- encryptedPayload = sfpp.message.toByteArray(),
- // Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
- to =
- if (sfpp.encapsulated_to == 0) {
- DataPacket.NODENUM_BROADCAST
- } else {
- sfpp.encapsulated_to
- },
- from = sfpp.encapsulated_from,
- id = sfpp.encapsulated_id,
- )
- }
- else -> null
- } ?: return
-
- Logger.d {
- "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
- "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
- }
- scope.handledLaunch {
- packetRepository
- .get()
- .updateSFPPStatus(
- packetId = sfpp.encapsulated_id,
- from = sfpp.encapsulated_from,
- to = sfpp.encapsulated_to,
- hash = hash,
- status = status,
- rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
- myNodeNum = nodeManager.myNodeNum ?: 0,
- )
- serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
- }
- }
-
- StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
- scope.handledLaunch {
- sfpp.message_hash.let {
- packetRepository
- .get()
- .updateSFPPStatusByHash(
- hash = it.toByteArray(),
- status = MessageStatus.SFPP_CONFIRMED,
- rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
- )
- }
- }
- }
-
- StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
- Logger.i { "SF++: Node ${packet.from} is querying chain status" }
- }
-
- StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
- Logger.i { "SF++: Node ${packet.from} is requesting links" }
- }
- }
- }
-
- private fun handlePaxCounter(packet: MeshPacket) {
- val payload = packet.decoded?.payload ?: return
- val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
- nodeManager.handleReceivedPaxcounter(packet.from, p)
- }
-
- private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
- Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" }
- nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
- }
-
- private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val u = Waypoint.ADAPTER.decode(payload)
- if (u.locked_to != 0 && u.locked_to != packet.from) return
- val currentSecond = nowSeconds.toInt()
- rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
- }
-
- private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val u = AdminMessage.ADAPTER.decode(payload)
- u.session_passkey.let { commandSender.setSessionPasskey(it) }
-
- val fromNum = packet.from
- u.get_module_config_response?.let { config ->
- if (fromNum == myNodeNum) {
- configHandler.handleModuleConfig(config)
- } else {
- config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
- }
- }
-
- if (fromNum == myNodeNum) {
- u.get_config_response?.let { configHandler.handleDeviceConfig(it) }
- u.get_channel_response?.let { configHandler.handleChannel(it) }
- }
-
- u.get_device_metadata_response?.let { metadata ->
- if (fromNum == myNodeNum) {
- configFlowManager.handleLocalMetadata(metadata)
- } else {
- nodeManager.insertMetadata(fromNum, metadata)
- }
- }
- }
-
- private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val decoded = packet.decoded ?: return
- if (decoded.reply_id != 0 && decoded.emoji != 0) {
- rememberReaction(packet)
- } else {
- rememberDataPacket(dataPacket, myNodeNum)
- }
- }
-
- private fun handleNodeInfo(packet: MeshPacket) {
- 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 }
- nodeManager.handleReceivedUser(packet.from, u, packet.channel)
- }
-
- private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return
- nodeManager.handleReceivedNodeStatus(packet.from, s)
- rememberDataPacket(dataPacket, myNodeNum)
- }
-
- private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
- val payload = packet.decoded?.payload ?: return
- val t =
- (Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
- if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
- }
- Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
- val fromNum = packet.from
- val isRemote = (fromNum != myNodeNum)
- if (!isRemote) {
- connectionManager.updateTelemetry(t)
- }
-
- nodeManager.updateNodeInfo(fromNum) { nodeEntity ->
- val metrics = t.device_metrics
- val environment = t.environment_metrics
- val power = t.power_metrics
- when {
- metrics != null -> {
- nodeEntity.deviceTelemetry = t
- if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
- if (
- (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
- (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
- ) {
- if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
- serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
- }
- } else {
- if (batteryPercentCooldowns.containsKey(fromNum)) {
- batteryPercentCooldowns.remove(fromNum)
- }
- serviceNotifications.cancelLowBatteryNotification(nodeEntity)
- }
- }
- }
-
- environment != null -> nodeEntity.environmentTelemetry = t
- power != null -> nodeEntity.powerTelemetry = t
- }
- }
- }
-
- private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
- val isRemote = (fromNum != myNodeNum)
- var shouldDisplay = false
- var forceDisplay = false
- val metrics = t.device_metrics ?: return false
- val batteryLevel = metrics.battery_level ?: 0
- when {
- batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
- shouldDisplay = true
- forceDisplay = true
- }
-
- batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
- batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
-
- isRemote -> shouldDisplay = true
- }
- if (shouldDisplay) {
- val now = nowSeconds
- if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
- if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
- batteryPercentCooldowns[fromNum] = now
- return true
- }
- }
- return false
- }
-
- private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
- val payload = packet.decoded?.payload ?: return
- val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
- if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
- serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle), Severity.Warn)
- }
- handleAckNak(
- packet.decoded?.request_id ?: 0,
- nodeManager.toNodeID(packet.from),
- r.error_reason?.value ?: 0,
- dataPacket.relayNode,
- )
- packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) }
- }
-
- @Suppress("CyclomaticComplexMethod", "LongMethod")
- private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
- scope.handledLaunch {
- val isAck = routingError == Routing.Error.NONE.value
- val p = packetRepository.get().getPacketById(requestId)
- val reaction = packetRepository.get().getReactionByPacketId(requestId)
-
- @Suppress("MaxLineLength")
- Logger.d {
- val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
- "[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
- "packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
- }
-
- val m =
- when {
- isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
- isAck -> MessageStatus.DELIVERED
- else -> MessageStatus.ERROR
- }
- if (p != null && p.data.status != MessageStatus.RECEIVED) {
- p.data.status = m
- p.routingError = routingError
- if (isAck) {
- p.data.relays += 1
- }
- p.data.relayNode = relayNode
- packetRepository.get().update(p)
- }
-
- reaction?.let { r ->
- if (r.status != MessageStatus.RECEIVED) {
- var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode)
- if (isAck) {
- updated = updated.copy(relays = updated.relays + 1)
- }
- packetRepository.get().updateReaction(updated)
- }
- }
-
- serviceBroadcasts.broadcastMessageStatus(requestId, m)
- }
- }
-
- private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
- Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
- val transport = currentTransport()
- val h = s.history
- val lastRequest = h?.last_request ?: 0
- val baseContext = "transport=$transport from=${dataPacket.from}"
- historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" }
- when {
- s.stats != null -> {
- val text = s.stats.toString()
- val u =
- dataPacket.copy(
- bytes = text.encodeToByteArray().toByteString(),
- dataType = PortNum.TEXT_MESSAGE_APP.value,
- )
- rememberDataPacket(u, myNodeNum)
- }
- h != null -> {
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}"
- }
- val text =
- "Total messages: ${h.history_messages}\n" +
- "History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
- "Last request: ${h.last_request}"
- val u =
- dataPacket.copy(
- bytes = text.encodeToByteArray().toByteString(),
- dataType = PortNum.TEXT_MESSAGE_APP.value,
- )
- rememberDataPacket(u, myNodeNum)
- historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport)
- }
- s.heartbeat != null -> {
- val hb = s.heartbeat!!
- historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" }
- }
- s.text != null -> {
- if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
- dataPacket.to = DataPacket.ID_BROADCAST
- }
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember"
- }
- val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
- rememberDataPacket(u, myNodeNum)
- }
- else -> {}
- }
- }
-
- fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) {
- if (dataPacket.dataType !in rememberDataType) return
- val fromLocal =
- dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
- val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
- val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
-
- // contactKey: unique contact key filter (channel)+(nodeId)
- val contactKey = "${dataPacket.channel}$contactId"
-
- scope.handledLaunch {
- packetRepository.get().apply {
- // Check for duplicates before inserting
- val existingPackets = findPacketsWithId(dataPacket.id)
- if (existingPackets.isNotEmpty()) {
- Logger.d {
- "Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
- "to=${dataPacket.to} contactKey=$contactKey" +
- " (already have ${existingPackets.size} packet(s))"
- }
- return@handledLaunch
- }
-
- // Check if message should be filtered
- val isFiltered = shouldFilterMessage(dataPacket, contactKey)
-
- val packetToSave =
- Packet(
- uuid = 0L,
- myNodeNum = myNodeNum,
- packetId = dataPacket.id,
- port_num = dataPacket.dataType,
- contact_key = contactKey,
- received_time = nowMillis,
- read = fromLocal || isFiltered,
- data = dataPacket,
- snr = dataPacket.snr,
- rssi = dataPacket.rssi,
- hopsAway = dataPacket.hopsAway,
- filtered = isFiltered,
- )
-
- insert(packetToSave)
- if (!isFiltered) {
- handlePacketNotification(packetToSave, dataPacket, contactKey, updateNotification)
- }
- }
- }
- }
-
- @Suppress("ReturnCount")
- private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
- val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
- if (isIgnored) return true
-
- if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
- val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
- return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
- }
-
- private suspend fun handlePacketNotification(
- packet: Packet,
- dataPacket: DataPacket,
- contactKey: String,
- updateNotification: Boolean,
- ) {
- val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
- val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
- val isSilent = conversationMuted || nodeMuted
- if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) {
- serviceNotifications.showAlertNotification(
- contactKey,
- getSenderName(dataPacket),
- dataPacket.alert ?: getString(Res.string.critical_alert),
- )
- } else if (updateNotification && !isSilent) {
- scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
- }
- }
-
- private fun getSenderName(packet: DataPacket): String {
- if (packet.from == DataPacket.ID_LOCAL) {
- val myId = nodeManager.getMyId()
- return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username)
- }
- return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username)
- }
-
- private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
- when (dataPacket.dataType) {
- PortNum.TEXT_MESSAGE_APP.value -> {
- val message = dataPacket.text!!
- val channelName =
- if (dataPacket.to == DataPacket.ID_BROADCAST) {
- radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
- } else {
- null
- }
- serviceNotifications.updateMessageNotification(
- contactKey,
- getSenderName(dataPacket),
- message,
- dataPacket.to == DataPacket.ID_BROADCAST,
- channelName,
- isSilent,
- )
- }
-
- PortNum.WAYPOINT_APP.value -> {
- val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
- serviceNotifications.updateWaypointNotification(
- contactKey,
- getSenderName(dataPacket),
- message,
- dataPacket.waypoint!!.id,
- isSilent,
- )
- }
-
- else -> return
- }
- }
-
- @Suppress("LongMethod", "KotlinConstantConditions")
- private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
- val decoded = packet.decoded ?: return@handledLaunch
- val emoji = decoded.payload.toByteArray().decodeToString()
- val fromId = nodeManager.toNodeID(packet.from)
- val toId = nodeManager.toNodeID(packet.to)
-
- val reaction =
- ReactionEntity(
- myNodeNum = nodeManager.myNodeNum ?: 0,
- replyId = decoded.reply_id,
- userId = fromId,
- emoji = emoji,
- timestamp = nowMillis,
- snr = packet.rx_snr,
- rssi = packet.rx_rssi,
- hopsAway =
- if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
- HOPS_AWAY_UNAVAILABLE
- } else {
- packet.hop_start - packet.hop_limit
- },
- packetId = packet.id,
- status = MessageStatus.RECEIVED,
- to = toId,
- channel = packet.channel,
- )
-
- // Check for duplicates before inserting
- val existingReactions = packetRepository.get().findReactionsWithId(packet.id)
- if (existingReactions.isNotEmpty()) {
- Logger.d {
- "Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " +
- "from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))"
- }
- return@handledLaunch
- }
-
- packetRepository.get().insertReaction(reaction)
-
- // Find the original packet to get the contactKey
- packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original ->
- // Skip notification if the original message was filtered
- if (original.packet.filtered) return@let
-
- val contactKey = original.packet.contact_key
- val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
- val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
- val isSilent = conversationMuted || nodeMuted
-
- if (!isSilent) {
- val channelName =
- if (original.packet.data.to == DataPacket.ID_BROADCAST) {
- radioConfigRepository.channelSetFlow
- .first()
- .settings
- .getOrNull(original.packet.data.channel)
- ?.name
- } else {
- null
- }
- serviceNotifications.updateReactionNotification(
- contactKey,
- getSenderName(dataMapper.toDataPacket(packet)!!),
- emoji,
- original.packet.data.to == DataPacket.ID_BROADCAST,
- channelName,
- isSilent,
- )
- }
- }
- }
-
- private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
- InterfaceId.BLUETOOTH.id -> "BLE"
- InterfaceId.TCP.id -> "TCP"
- InterfaceId.SERIAL.id -> "Serial"
- InterfaceId.MOCK.id -> "Mock"
- InterfaceId.NOP.id -> "NOP"
- else -> "Unknown"
- }
-
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag("HistoryReplay")
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
- }
-
- companion object {
- private const val HOPS_AWAY_UNAVAILABLE = -1
-
- private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
- private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
- private const val BATTERY_PERCENT_LOW_DIVISOR = 5
- private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
- private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
- private val batteryPercentCooldowns = ConcurrentHashMap()
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
deleted file mode 100644
index f1da54dd7..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ /dev/null
@@ -1,262 +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.geeksville.mesh.service
-
-import android.util.Log
-import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
-import dagger.Lazy
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.model.util.isLora
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.FromRadio
-import org.meshtastic.proto.LogRecord
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.PortNum
-import java.util.ArrayDeque
-import java.util.Locale
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.uuid.Uuid
-
-@Suppress("TooManyFunctions")
-@Singleton
-class MeshMessageProcessor
-@Inject
-constructor(
- private val nodeManager: MeshNodeManager,
- private val serviceRepository: ServiceRepository,
- private val meshLogRepository: Lazy,
- private val router: MeshRouter,
- private val fromRadioDispatcher: FromRadioPacketHandler,
-) {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private val logUuidByPacketId = ConcurrentHashMap