Compare commits

..

No commits in common. "main" and "v2.7.14-internal.35" have entirely different histories.

1203 changed files with 25535 additions and 45184 deletions

View file

@ -1,27 +0,0 @@
# Ignore build artifacts and generated files from Copilot indexing
# This saves context window tokens and prevents Copilot from hallucinating off of minified code.
# Build directories
**/build/**
.gradle/
.idea/
# Android generated files
**/generated/**
.cxx/
.externalNativeBuild/
# Git history & worktrees
.git/
.worktrees/
# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers)
core/proto/
# Environment and secrets
local.properties
secrets.properties
*.jks
# Agent References (Prevents pollution of project space with external code)
.agent_refs/

View file

@ -1,5 +0,0 @@
{
"context": {
"fileName": ["AGENTS.md", "GEMINI.md"]
}
}

View file

@ -1,40 +0,0 @@
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

View file

@ -1,52 +0,0 @@
#
# 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

View file

@ -1,27 +0,0 @@
# GitHub Copilot Commit Message Instructions
<role>
You are an expert Git maintainer enforcing Conventional Commits.
</role>
<instructions>
1. **Format:** Use the Conventional Commits format: `<type>(<scope>): <subject>` (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".
</instructions>

View file

@ -1,6 +1,149 @@
# Meshtastic Android - GitHub Copilot Guide # Meshtastic Android - Agent Guide
> **Note:** The canonical instructions for all AI Agents have been deduplicated. This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. For execution-focused recipes, see `docs/agent-playbooks/README.md`.
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
## 1. Project Vision & Architecture
Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
- **Language:** Kotlin (primary), AIDL.
- **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
- **Flavors:**
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
## 2. 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.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`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`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
### C. Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
## 4. Execution Protocol
### A. Environment Setup
1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
```properties
MAPS_API_KEY=dummy_key
datadogApplicationId=dummy_id
datadogClientToken=dummy_token
```
### B. Strict Execution Commands
Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
**Baseline (recommended order):**
```bash
./gradlew clean
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew detekt
./gradlew assembleDebug
./gradlew test
```
**Testing:**
```bash
./gradlew test # Run local unit tests
./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
./gradlew connectedAndroidTest # Run instrumented tests
./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
```
*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
**CI workflow conventions (GitHub Actions):**
- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
### C. Documentation Sync
Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
## 5. Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Missing Secrets:** Check `local.properties`.
- **JDK Version:** JDK 17 is required.
- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).

View file

@ -1,18 +0,0 @@
# GitHub Copilot Pull Request Instructions
<role>
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.
</role>
<instructions>
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.
</instructions>

View file

@ -1,11 +0,0 @@
---
applyTo: "**/androidMain/**/*.kt"
---
# Android Source-Set Rules
- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here.
- Do NOT put business logic here. Business logic belongs in `commonMain`.
- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`.
- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI.
- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors.

View file

@ -1,10 +0,0 @@
---
applyTo: "build-logic/**/*.kt"
---
# Build-Logic Convention Plugin Rules
- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs).
- Avoid `afterEvaluate` unless there is no viable lazy alternative.
- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones.
- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.

View file

@ -1,14 +0,0 @@
---
applyTo: "**/*.yml"
excludeAgent: "code-review"
---
# CI Workflow Rules
- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`).
- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.
- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`.
- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners.
- Gradle-heavy jobs: use `ubuntu-24.04` runners.

View file

@ -1,20 +0,0 @@
---
applyTo: "**/commonMain/**/*.kt"
---
# KMP commonMain Rules
- NEVER import `java.*` or `android.*` in `commonMain`.
- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`.
- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`.
- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`.
- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`.
- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies.
- Use `compose-multiplatform-*` catalog aliases for CMP dependencies.
- Never use plain `androidx.compose` dependencies in `commonMain`.
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
- Check `gradle/libs.versions.toml` before adding dependencies.
- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.

35
.github/labeler.yml vendored Normal file
View file

@ -0,0 +1,35 @@
# 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).)*$']

12
.github/lsp.json vendored
View file

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

221
.github/renovate.json vendored
View file

@ -49,31 +49,236 @@
"automerge": true "automerge": true
}, },
{ {
"description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [ "matchPackageNames": [
"https://github.com/meshtastic/protobufs.git" "https://github.com/meshtastic/protobufs.git"
], ],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
"groupName": "Meshtastic Protobufs",
"groupSlug": "meshtastic-protobufs",
"automerge": true "automerge": true
}, },
{ {
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
"groupName": "compose-multiplatform", "groupName": "AndroidX (General)",
"groupSlug": "androidx-general",
"matchPackageNames": [ "matchPackageNames": [
"/^org\\.jetbrains\\.compose/", "/^androidx\\./",
"androidx.compose.runtime:runtime-tracing", "!/^androidx\\.room/",
"androidx.compose.ui:ui-test-manifest" "!/^androidx\\.lifecycle/",
"!/^androidx\\.navigation/",
"!/^androidx\\.datastore/",
"!/^androidx\\.compose\\.material3\\.adaptive/",
"!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
"!/^androidx\\.test\\.espresso/",
"!/^androidx\\.test\\.ext/",
"!/^androidx\\.compose\\.ui:ui-test-junit4$/",
"!/^androidx\\.hilt/"
] ]
}, },
{ {
"description": "Restrict sensitive infrastructure to manual minor updates", "description": "Group Kotlin standard library, coroutines, and serialization",
"groupName": "Kotlin Ecosystem",
"groupSlug": "kotlin",
"matchPackageNames": [
"/^org\\.jetbrains\\.kotlin/",
"/^org\\.jetbrains\\.kotlinx/"
]
},
{
"description": "Group Dagger and Hilt dependencies",
"groupName": "Dagger & Hilt",
"groupSlug": "hilt",
"matchPackageNames": [
"/^com\\.google\\.dagger/",
"/^androidx\\.hilt/"
]
},
{
"description": "Group Accompanist libraries",
"groupName": "Accompanist",
"groupSlug": "accompanist",
"matchPackageNames": [
"/^com\\.google\\.accompanist/"
]
},
{
"description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
"groupName": "JVM Testing Libraries",
"groupSlug": "jvm-testing",
"matchPackageNames": [
"/^junit:junit$/",
"/^org\\.mockito:/",
"/^org\\.robolectric:robolectric$/"
],
"automerge": true
},
{
"description": "Group AndroidX Testing libraries",
"groupName": "AndroidX Testing",
"groupSlug": "androidx-testing",
"matchPackageNames": [
"/^androidx\\.test\\.espresso/",
"/^androidx\\.test\\.ext/",
"/^androidx\\.compose\\.ui:ui-test-junit4$/"
],
"automerge": true
},
{
"description": "Group Static Analysis tools (Detekt, Spotless)",
"groupName": "Static Analysis",
"groupSlug": "static-analysis",
"matchPackageNames": [
"/^io\\.gitlab\\.arturbosch\\.detekt/",
"/^io\\.nlopez\\.compose\\.rules/",
"/^com\\.diffplug\\.spotless/"
],
"automerge": true
},
{
"description": "Group Square networking libraries (OkHttp, Retrofit)",
"groupName": "Square Networking",
"groupSlug": "square-network",
"matchPackageNames": [
"/^com\\.squareup\\.okhttp3/",
"/^com\\.squareup\\.retrofit2/"
],
"automerge": true
},
{
"description": "Group Coil image loading library",
"groupName": "Coil",
"groupSlug": "coil",
"matchPackageNames": [
"/^io\\.coil-kt\\.coil3/"
],
"automerge": true
},
{
"description": "Group ZXing barcode scanning libraries",
"groupName": "ZXing",
"groupSlug": "zxing",
"matchPackageNames": [
"/^com\\.journeyapps:zxing-android-embedded/",
"/^com\\.google\\.zxing:core/"
],
"automerge": true
},
{
"description": "Group Eclipse Paho MQTT client libraries",
"groupName": "MQTT Paho Client",
"groupSlug": "mqtt-paho",
"matchPackageNames": [
"/^org\\.eclipse\\.paho/"
],
"automerge": true
},
{
"description": "Group Mike Penz Markdown renderer libraries",
"groupName": "Markdown Renderer (Mike Penz)",
"groupSlug": "markdown-renderer-mikepenz",
"matchPackageNames": [
"/^com\\.mikepenz/"
],
"automerge": true
},
{
"description": "Group Firebase libraries",
"groupName": "Firebase",
"groupSlug": "firebase",
"matchPackageNames": [
"/^com\\.google\\.firebase/"
],
"automerge": true
},
{
"description": "Group Datadog libraries",
"groupName": "Datadog",
"groupSlug": "datadog",
"matchPackageNames": [
"/^com\\.datadoghq/"
],
"automerge": true
},
{
"description": "Group OpenStreetMap (OSM) libraries",
"groupName": "OSM Libraries",
"groupSlug": "osm-libraries",
"matchPackageNames": [
"/^org\\.osmdroid/",
"/^com\\.github\\.MKergall\\.osmbonuspack/",
"/^mil\\.nga/"
],
"automerge": true
},
{
"description": "Group Google Maps Compose libraries",
"groupName": "Google Maps Compose",
"groupSlug": "google-maps-compose",
"matchPackageNames": [
"/^com\\.google\\.android\\.gms:play-services-location/",
"/^com\\.google\\.maps\\.android/"
],
"automerge": true
},
{
"description": "Group Google Protobuf runtime libraries",
"groupName": "Protobuf Runtime",
"groupSlug": "protobuf-runtime",
"matchPackageNames": [
"/^com\\.google\\.protobuf/",
"!https://github.com/meshtastic/protobufs.git"
]
},
{
"description": "Group AndroidX Room libraries",
"groupName": "AndroidX Room",
"groupSlug": "androidx-room",
"matchPackageNames": [
"/^androidx\\.room/"
],
"automerge": true
},
{
"description": "Group AndroidX Lifecycle libraries",
"groupName": "AndroidX Lifecycle",
"groupSlug": "androidx-lifecycle",
"matchPackageNames": [
"/^androidx\\.lifecycle/"
]
},
{
"description": "Group AndroidX Navigation libraries",
"groupName": "AndroidX Navigation",
"groupSlug": "androidx-navigation",
"matchPackageNames": [
"/^androidx\\.navigation/"
]
},
{
"description": "Group AndroidX DataStore libraries",
"groupName": "AndroidX DataStore",
"groupSlug": "androidx-datastore",
"matchPackageNames": [
"/^androidx\\.datastore/"
]
},
{
"description": "Group AndroidX Adaptive UI libraries",
"groupName": "AndroidX Adaptive UI",
"groupSlug": "androidx-adaptive-ui",
"matchPackageNames": [
"/^androidx\\.compose\\.material3\\.adaptive/",
"/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
]
},
{
"description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
"matchUpdateTypes": [ "matchUpdateTypes": [
"minor" "minor"
], ],
"matchPackageNames": [ "matchPackageNames": [
"/^org\\.jetbrains\\.kotlin/", "/^org\\.jetbrains\\.kotlin/",
"/^org\\.jetbrains\\.kotlinx/", "/^org\\.jetbrains\\.kotlinx/",
"/^org\\.jetbrains\\.compose/",
"/^com\\.google\\.dagger/", "/^com\\.google\\.dagger/",
"/^androidx\\.hilt/", "/^androidx\\.hilt/",
"/^com\\.google\\.protobuf/", "/^com\\.google\\.protobuf/",

108
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,108 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
# push:
# branches: [ "main" ]
# pull_request:
# branches: [ "main" ]
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-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'
token: ${{ github.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View file

@ -20,11 +20,6 @@ on:
required: true required: true
type: boolean type: boolean
default: false default: false
build_desktop:
description: 'Whether to build the desktop distribution'
required: true
type: boolean
default: false
permissions: permissions:
contents: write contents: write
@ -34,7 +29,7 @@ permissions:
jobs: jobs:
determine-tags: determine-tags:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
outputs: outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }} tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
release_name: ${{ steps.calculate_tags.outputs.release_name }} release_name: ${{ steps.calculate_tags.outputs.release_name }}
@ -129,7 +124,6 @@ jobs:
tag_name: ${{ needs.determine-tags.outputs.final_tag }} tag_name: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }} channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }} base_version: ${{ inputs.base_version }}
build_desktop: ${{ inputs.build_desktop }}
secrets: inherit secrets: inherit
call-promote-workflow: call-promote-workflow:
@ -148,7 +142,7 @@ jobs:
cleanup-on-failure: cleanup-on-failure:
needs: [determine-tags, call-release-workflow] needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }} if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6

View file

@ -10,7 +10,7 @@ permissions:
jobs: jobs:
dependency-submission: dependency-submission:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
if: github.repository == 'meshtastic/Meshtastic-Android' if: github.repository == 'meshtastic/Meshtastic-Android'
steps: steps:
@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-java@v5 - uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 17
token: ${{ github.token }} token: ${{ github.token }}
- name: Generate and submit dependency graph - name: Generate and submit dependency graph

View file

@ -6,16 +6,6 @@ on:
push: push:
branches: branches:
- main - 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 # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
@ -39,16 +29,16 @@ permissions:
pages: write pages: write
id-token: write id-token: write
# Allow only one concurrent deployment; cancel queued runs since only the latest # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# main state matters for documentation. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency: concurrency:
group: "pages" group: "pages"
cancel-in-progress: true cancel-in-progress: false
jobs: jobs:
build-docs: build-docs:
if: github.repository == 'meshtastic/Meshtastic-Android' if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@ -57,16 +47,21 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
ref: ${{ inputs.ref || '' }} ref: ${{ inputs.ref || '' }}
- name: Gradle Setup - name: Set up JDK 17
uses: ./.github/actions/gradle-setup uses: actions/setup-java@v5
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
distribution: 'temurin'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Build Dokka HTML documentation - name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v5 uses: actions/upload-pages-artifact@v4
with: with:
path: build/dokka/html path: build/dokka/html
@ -75,7 +70,7 @@ jobs:
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
needs: build-docs needs: build-docs
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages

View file

@ -20,7 +20,8 @@ jobs:
uses: ./.github/workflows/reusable-check.yml uses: ./.github/workflows/reusable-check.yml
with: with:
run_lint: true run_lint: true
run_unit_tests: false run_unit_tests: true
run_desktop_builds: false run_instrumented_tests: true
api_levels: '[35]' # One API level is enough for post-merge sanity check
upload_artifacts: true upload_artifacts: true
secrets: inherit secrets: inherit

View file

@ -16,7 +16,7 @@ concurrency:
jobs: jobs:
main-push-changelog: main-push-changelog:
name: Generate main push changelog name: Generate main push changelog
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@ -39,10 +39,6 @@ jobs:
fromTag: ${{ steps.last_prod_tag.outputs.tag }} fromTag: ${{ steps.last_prod_tag.outputs.tag }}
toTag: ${{ github.sha }} toTag: ${{ github.sha }}
outputFile: main-push-changelog.md outputFile: main-push-changelog.md
fetchViaCommits: true
fetchReviewers: false
fetchReleaseInformation: false
fetchReviews: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -18,12 +18,14 @@ jobs:
with: with:
run_lint: true run_lint: true
run_unit_tests: true run_unit_tests: true
run_instrumented_tests: true
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
upload_artifacts: false upload_artifacts: false
secrets: inherit secrets: inherit
check-workflow-status: check-workflow-status:
name: Check Workflow Status name: Check Workflow Status
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
permissions: {} permissions: {}
needs: needs:
- android-check - android-check

View file

@ -14,8 +14,8 @@ concurrency:
jobs: jobs:
triage: triage:
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }} if: ${{ github.repository == 'meshtastic/firmware' && github.event.issue.user.type != 'Bot' }}
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam # 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 - name: Apply quality label if needed
if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
uses: actions/github-script@v9 uses: actions/github-script@v8
env: env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }} QUALITY_LABEL: ${{ steps.quality.outputs.response }}
with: with:
@ -80,7 +80,7 @@ jobs:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
- name: Determine if completeness check should be skipped - name: Determine if completeness check should be skipped
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '' if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
uses: actions/github-script@v9 uses: actions/github-script@v8
id: check-skip id: check-skip
with: with:
script: | script: |
@ -98,20 +98,20 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
prompt: | prompt: |
Analyze this GitHub issue for the Meshtastic Android app and determine if it needs labels. Analyze this GitHub issue for completeness and determine if it needs labels.
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: 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:
Android app debug logs: Web Flasher logs:
- Open the Meshtastic app, go to Settings > Debug > Save Logs - Go to https://flasher.meshtastic.org
- Reproduce the problem, then share/attach the exported log file - 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 logcat (if app logs are insufficient): Meshtastic CLI logs:
- Connect phone via USB with USB debugging enabled - Run: meshtastic --port <serial-port> --noproto
- Run: adb logcat -s Meshtastic:* *:E - Reproduce the problem, then copy/paste the terminal output
- Reproduce the problem, then copy/paste the relevant output
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. Also request key context if missing: device model/variant, firmware version, region, steps to reproduce, expected vs actual.
Respond ONLY with JSON: Respond ONLY with JSON:
{ {
@ -120,7 +120,7 @@ jobs:
"label": "needs-logs" | "needs-info" | "none" "label": "needs-logs" | "needs-info" | "none"
} }
Use "needs-logs" if this is an app bug AND no logs are attached. Use "needs-logs" if this is a device bug AND no logs are attached.
Use "needs-info" if basic info like firmware version or steps to reproduce are missing. 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. Use "none" if the issue is complete or is a feature request.
@ -131,7 +131,7 @@ jobs:
- name: Process analysis result - 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 != '' 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@v9 uses: actions/github-script@v8
id: process id: process
env: env:
AI_RESPONSE: ${{ steps.analysis.outputs.response }} AI_RESPONSE: ${{ steps.analysis.outputs.response }}
@ -165,7 +165,7 @@ jobs:
- name: Apply triage label - name: Apply triage label
if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none' if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none'
uses: actions/github-script@v9 uses: actions/github-script@v8
env: env:
LABEL_NAME: ${{ steps.process.outputs.label }} LABEL_NAME: ${{ steps.process.outputs.label }}
with: with:
@ -191,7 +191,7 @@ jobs:
- name: Comment on issue - name: Comment on issue
if: steps.process.outputs.should_comment == 'true' if: steps.process.outputs.should_comment == 'true'
uses: actions/github-script@v9 uses: actions/github-script@v8
env: env:
COMMENT_BODY: ${{ steps.process.outputs.comment_body }} COMMENT_BODY: ${{ steps.process.outputs.comment_body }}
with: with:

View file

@ -15,19 +15,19 @@ concurrency:
jobs: jobs:
triage: triage:
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }} if: ${{ github.repository == 'meshtastic/firmware' && github.event.pull_request.user.type != 'Bot' }}
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# Step 1: Check if PR already has automation/type labels (skip if so) # Step 1: Check if PR already has automation/type labels (skip if so)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
- name: Check existing labels - name: Check existing labels
uses: actions/github-script@v9 uses: actions/github-script@v8
id: check-labels id: check-labels
with: with:
script: | script: |
const skipLabels = new Set(['automation', 'release']); const skipLabels = new Set(['automation']);
const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']); const typeLabels = new Set(['bugfix', 'hardware-support', 'enhancement', 'dependencies', 'submodules', 'github_actions', 'trunk', 'cleanup']);
const prLabels = context.payload.pull_request.labels.map(l => l.name); const prLabels = context.payload.pull_request.labels.map(l => l.name);
const shouldSkipAll = prLabels.some(l => skipLabels.has(l)); const shouldSkipAll = prLabels.some(l => skipLabels.has(l));
@ -44,16 +44,13 @@ jobs:
uses: actions/ai-inference@v2 uses: actions/ai-inference@v2
id: quality id: quality
continue-on-error: true continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with: with:
max-tokens: 20 max-tokens: 20
prompt: | prompt: |
Is this GitHub pull request spam, AI-generated slop, or low quality? Is this GitHub pull request spam, AI-generated slop, or low quality?
Title: ${{ env.PR_TITLE }} Title: ${{ github.event.pull_request.title }}
Body: ${{ env.PR_BODY }} Body: ${{ github.event.pull_request.body }}
Respond with exactly one of: spam, ai-generated, needs-review, ok 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. system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
@ -61,7 +58,7 @@ jobs:
- name: Apply quality label if needed - name: Apply quality label if needed
if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
uses: actions/github-script@v9 uses: actions/github-script@v8
id: quality-label id: quality-label
env: env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }} QUALITY_LABEL: ${{ steps.quality.outputs.response }}
@ -90,35 +87,32 @@ jobs:
core.setOutput('is_spam', 'true'); core.setOutput('is_spam', 'true');
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# Step 3: Auto-label PR type (bugfix/enhancement/refactor) # Step 3: Auto-label PR type (bugfix/hardware-support/enhancement)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
- name: Classify PR for labeling - 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 == '') 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 uses: actions/ai-inference@v2
id: classify id: classify
continue-on-error: true continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with: with:
max-tokens: 30 max-tokens: 30
prompt: | prompt: |
Classify this pull request for the Meshtastic Android app into exactly one category. Classify this pull request into exactly one category.
Return exactly one of: bugfix, enhancement, refactor Return exactly one of: bugfix, hardware-support, enhancement
Use bugfix if it fixes a bug, crash, or incorrect behavior. Use bugfix if it fixes a bug, crash, or incorrect behavior.
Use enhancement if it adds a new feature, improves performance, or adds new functionality. Use hardware-support if it adds or improves support for a specific hardware device/variant.
Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture. Use enhancement if it adds a new feature, improves performance, or refactors code.
Title: ${{ env.PR_TITLE }} Title: ${{ github.event.pull_request.title }}
Body: ${{ env.PR_BODY }} Body: ${{ github.event.pull_request.body }}
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
model: openai/gpt-4o-mini model: openai/gpt-4o-mini
- name: Apply type label - name: Apply type label
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != '' if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != ''
uses: actions/github-script@v9 uses: actions/github-script@v8
env: env:
TYPE_LABEL: ${{ steps.classify.outputs.response }} TYPE_LABEL: ${{ steps.classify.outputs.response }}
with: with:
@ -126,8 +120,8 @@ jobs:
const label = (process.env.TYPE_LABEL || '').trim().toLowerCase(); const label = (process.env.TYPE_LABEL || '').trim().toLowerCase();
const labelMeta = { const labelMeta = {
'bugfix': { color: 'd73a4a', description: 'Bug fix' }, 'bugfix': { color: 'd73a4a', description: 'Bug fix' },
'hardware-support': { color: '0e8a16', description: 'Hardware support addition or improvement' },
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' }, 'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
}; };
const meta = labelMeta[label]; const meta = labelMeta[label];
if (!meta) return; if (!meta) return;

View file

@ -9,8 +9,7 @@ on:
jobs: jobs:
spam-detection: spam-detection:
if: github.repository == 'meshtastic/Meshtastic-Android' runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write

View file

@ -18,7 +18,7 @@ permissions:
jobs: jobs:
cleanup_prereleases: cleanup_prereleases:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
environment: Release environment: Release
steps: steps:
- name: Checkout code - name: Checkout code

View file

@ -4,34 +4,29 @@ on:
pull_request: pull_request:
types: [edited, labeled] types: [edited, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions: permissions:
pull-requests: read pull-requests: read
contents: read contents: read
jobs: jobs:
check-label: check-label:
# Skip bot PRs — they already have labels from the workflows/bots that create them runs-on: ubuntu-latest
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: steps:
- name: Check for PR labels - name: Check for PR labels
uses: actions/github-script@v9 uses: actions/github-script@v8
with: with:
script: | script: |
// Extract labels from the payload directly to avoid extra API calls // Always fetch the latest labels from the GitHub API to avoid stale context
const latestLabels = context.payload.pull_request.labels.map(label => label.name); 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);
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor']; const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor'];
console.log('Labels from payload:', latestLabels);
const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label)); const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label));
console.log('Latest labels:', latestLabels);
if (!hasRequiredLabel) { if (!hasRequiredLabel) {
core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`); core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`);
} }

View file

@ -65,9 +65,9 @@ permissions:
jobs: jobs:
prepare-build-info: prepare-build-info:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
outputs: outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }} APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps: steps:
- name: Checkout code - name: Checkout code
@ -102,7 +102,7 @@ jobs:
shell: bash shell: bash
promote-release: promote-release:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
environment: Release environment: Release
needs: [ prepare-build-info ] needs: [ prepare-build-info ]
steps: steps:
@ -116,7 +116,7 @@ jobs:
user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }} user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }}
update-github-release: update-github-release:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
needs: [ prepare-build-info, promote-release ] needs: [ prepare-build-info, promote-release ]
steps: steps:
- name: Checkout code - name: Checkout code
@ -139,7 +139,6 @@ jobs:
gh release edit ${{ inputs.tag_name }} \ gh release edit ${{ inputs.tag_name }} \
--tag ${{ inputs.final_tag }} \ --tag ${{ inputs.final_tag }} \
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \ --title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
--draft=false \
--prerelease=${{ inputs.channel != 'production' }} --prerelease=${{ inputs.channel != 'production' }}
- name: Notify Discord - name: Notify Discord

View file

@ -12,7 +12,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -23,10 +23,19 @@ jobs:
with: with:
submodules: 'recursive' submodules: 'recursive'
- name: Gradle Setup - name: Set up JDK 17
uses: ./.github/actions/gradle-setup uses: actions/setup-java@v5
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
distribution: 'temurin'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Configure Version - name: Configure Version
id: version id: version

View file

@ -1,67 +1,15 @@
name: "Pull Request Labeler" name: "Pull Request Labeler"
on: on:
pull_request_target: - pull_request_target
types: [opened, synchronize] # Do not execute arbitary code on this workflow.
# 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 # 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: jobs:
labeler: labeler:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
- name: Auto-label PR - id: label-the-PR
uses: actions/github-script@v9 uses: actions/labeler@v6
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.');
}

View file

@ -3,6 +3,10 @@ name: Pull Request CI
on: on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.gitignore'
permissions: permissions:
contents: read contents: read
@ -15,7 +19,7 @@ jobs:
# 1. CHANGE DETECTION: Prevents unnecessary builds # 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes: check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' ) if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
outputs: outputs:
android: ${{ steps.filter.outputs.android }} android: ${{ steps.filter.outputs.android }}
steps: steps:
@ -35,6 +39,7 @@ jobs:
- 'desktop/**' - 'desktop/**'
- 'core/**' - 'core/**'
- 'feature/**' - 'feature/**'
- 'mesh_service_example/**'
# Shared build infrastructure # Shared build infrastructure
- 'build-logic/**' - 'build-logic/**'
- 'config/**' - 'config/**'
@ -52,7 +57,7 @@ jobs:
# 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots # 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
verify-check-changes-filter: verify-check-changes-filter:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' ) if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Verify module roots are represented in check-changes filter - name: Verify module roots are represented in check-changes filter
@ -94,25 +99,23 @@ jobs:
PY PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml # 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# We disable coverage and desktop builds for PRs to keep feedback fast # We disable instrumented tests for PRs to keep feedback fast (< 10 mins).
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
# task in the shard-app test shard.
validate-and-build: validate-and-build:
needs: check-changes needs: [check-changes, verify-check-changes-filter]
if: needs.check-changes.outputs.android == 'true' if: needs.check-changes.outputs.android == 'true'
uses: ./.github/workflows/reusable-check.yml uses: ./.github/workflows/reusable-check.yml
with: with:
run_lint: true run_lint: true
run_unit_tests: true run_unit_tests: true
run_coverage: false run_instrumented_tests: false
run_desktop_builds: false api_levels: '[35]'
upload_artifacts: true upload_artifacts: true
secrets: inherit secrets: inherit
# 3. WORKFLOW STATUS: Ensures required checks are satisfied # 3. WORKFLOW STATUS: Ensures required checks are satisfied
check-workflow-status: check-workflow-status:
name: Check Workflow Status name: Check Workflow Status
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
permissions: {} permissions: {}
needs: [check-changes, verify-check-changes-filter, validate-and-build] needs: [check-changes, verify-check-changes-filter, validate-and-build]
if: always() if: always()

View file

@ -19,11 +19,6 @@ on:
description: 'The channel to create a release for or promote to' description: 'The channel to create a release for or promote to'
required: true required: true
type: string type: string
build_desktop:
description: 'Whether to build the desktop distribution'
required: false
type: boolean
default: false
secrets: secrets:
GSERVICES: GSERVICES:
required: true required: true
@ -66,9 +61,9 @@ permissions:
jobs: jobs:
prepare-build-info: prepare-build-info:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
outputs: outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }} APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
env: env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
@ -106,7 +101,7 @@ jobs:
shell: bash shell: bash
release-google: release-google:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
needs: [prepare-build-info] needs: [prepare-build-info]
environment: Release environment: Release
env: env:
@ -120,12 +115,19 @@ jobs:
ref: ${{ inputs.tag_name }} ref: ${{ inputs.tag_name }}
fetch-depth: 0 fetch-depth: 0
submodules: 'recursive' submodules: 'recursive'
- name: Set up JDK 17
- name: Gradle Setup uses: actions/setup-java@v5
uses: ./.github/actions/gradle-setup
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: 'false' distribution: 'jetbrains'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
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: Load secrets - name: Load secrets
env: env:
@ -191,7 +193,7 @@ jobs:
subject-path: app/build/outputs/apk/google/release/*.apk subject-path: app/build/outputs/apk/google/release/*.apk
release-fdroid: release-fdroid:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
needs: [prepare-build-info] needs: [prepare-build-info]
environment: Release environment: Release
env: env:
@ -205,12 +207,19 @@ jobs:
ref: ${{ inputs.tag_name }} ref: ${{ inputs.tag_name }}
fetch-depth: 0 fetch-depth: 0
submodules: 'recursive' submodules: 'recursive'
- name: Set up JDK 17
- name: Gradle Setup uses: actions/setup-java@v5
uses: ./.github/actions/gradle-setup
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: 'false' distribution: 'jetbrains'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
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: Load secrets - name: Load secrets
env: env:
@ -251,14 +260,13 @@ jobs:
subject-path: app/build/outputs/apk/fdroid/release/*.apk subject-path: app/build/outputs/apk/fdroid/release/*.apk
release-desktop: release-desktop:
if: ${{ inputs.build_desktop }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: [prepare-build-info] needs: [prepare-build-info]
environment: Release environment: Release
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] os: [macos-latest, windows-latest, ubuntu-22.04, ubuntu-22.04-arm]
env: env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
@ -271,11 +279,20 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: 'recursive' submodules: 'recursive'
- name: Gradle Setup - name: Set up JDK 17
uses: ./.github/actions/gradle-setup uses: actions/setup-java@v5
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: 'false' distribution: 'jetbrains'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
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: Install dependencies for AppImage - name: Install dependencies for AppImage
if: runner.os == 'Linux' if: runner.os == 'Linux'
@ -285,7 +302,7 @@ jobs:
env: env:
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
APPIMAGE_EXTRACT_AND_RUN: 1 APPIMAGE_EXTRACT_AND_RUN: 1
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS --no-daemon
- name: List Desktop Binaries - name: List Desktop Binaries
if: runner.os == 'Linux' if: runner.os == 'Linux'
@ -307,8 +324,7 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
github-release: github-release:
if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
needs: [prepare-build-info, release-google, release-fdroid, release-desktop] needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
env: env:
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }} INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
@ -328,7 +344,7 @@ jobs:
path: ./artifacts path: ./artifacts
- name: Create or Update GitHub Release - name: Create or Update GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ inputs.tag_name }} tag_name: ${{ inputs.tag_name }}
target_commitish: ${{ inputs.commit_sha || github.sha }} target_commitish: ${{ inputs.commit_sha || github.sha }}
@ -341,7 +357,7 @@ jobs:
- name: Create or Update internal GitHub Release - name: Create or Update internal GitHub Release
continue-on-error: true continue-on-error: true
if: ${{ env.INTERNAL_BUILDS_HOST != '' }} if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }} repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }} token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}

View file

@ -9,12 +9,12 @@ on:
run_unit_tests: run_unit_tests:
type: boolean type: boolean
default: true default: true
run_coverage: run_instrumented_tests:
type: boolean
default: true
run_desktop_builds:
type: boolean type: boolean
default: true default: true
api_levels:
type: string
default: '[35]'
upload_artifacts: upload_artifacts:
type: boolean type: boolean
default: true default: true
@ -44,272 +44,218 @@ env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} 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: jobs:
# ── Lint & Static Analysis ────────────────────────────────────────── host-check:
lint-check: runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
timeout-minutes: 30 timeout-minutes: 60
outputs:
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
version_code: ${{ steps.version_code.outputs.version_code }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
filter: 'blob:none' submodules: 'recursive'
submodules: true
- name: Determine cache read-only setting - name: Validate Gradle Wrapper
id: cache_config uses: gradle/actions/wrapper-validation@v6
shell: bash
run: |
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
- name: Calculate version code from git commit count - name: Set up JDK 17
id: version_code uses: actions/setup-java@v5
shell: bash
run: |
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: Gradle Setup
uses: ./.github/actions/gradle-setup
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }} distribution: 'temurin'
token: ${{ github.token }}
- name: Lint, Analysis & KMP Smoke Compile - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.event_name != 'merge_group' && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/') }}
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-cleanup: on-success
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: Code Style & Static Analysis
if: inputs.run_lint == true if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan run: ./gradlew spotlessCheck detekt -Pci=true --scan
- name: KMP Smoke Compile (lint skipped) - name: Android Lint
if: inputs.run_lint == false if: inputs.run_lint == true
run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan run: ./gradlew app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug -Pci=true --continue --scan
# ── Sharded Unit Tests ────────────────────────────────────────────── - name: Shared Unit Tests & Coverage
# Tests are split into 3 shards that run in parallel: if: inputs.run_unit_tests == true
# shard-core: core:* KMP module tests (allTests) run: ./gradlew test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport -Pci=true --continue --scan
# 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: KMP Smoke Compile
- name: Checkout code run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :core:proto:compileKotlinIosSimulatorArm64 :core:common:compileKotlinIosSimulatorArm64 :core:model:compileKotlinIosSimulatorArm64 :core:repository:compileKotlinIosSimulatorArm64 :core:di:compileKotlinIosSimulatorArm64 :core:navigation:compileKotlinIosSimulatorArm64 :core:resources:compileKotlinIosSimulatorArm64 :core:datastore:compileKotlinIosSimulatorArm64 :core:database:compileKotlinIosSimulatorArm64 :core:domain:compileKotlinIosSimulatorArm64 :core:prefs:compileKotlinIosSimulatorArm64 :core:network:compileKotlinIosSimulatorArm64 :core:data:compileKotlinIosSimulatorArm64 :core:ble:compileKotlinIosSimulatorArm64 :core:nfc:compileKotlinIosSimulatorArm64 :core:service:compileKotlinIosSimulatorArm64 :core:testing:compileKotlinIosSimulatorArm64 :core:ui:compileKotlinIosSimulatorArm64 :feature:intro:compileKotlinIosSimulatorArm64 :feature:messaging:compileKotlinIosSimulatorArm64 :feature:connections:compileKotlinIosSimulatorArm64 :feature:map:compileKotlinIosSimulatorArm64 :feature:node:compileKotlinIosSimulatorArm64 :feature:settings:compileKotlinIosSimulatorArm64 :feature:firmware:compileKotlinIosSimulatorArm64 -Pci=true --continue --scan
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup - name: Upload coverage results to Codecov
uses: ./.github/actions/gradle-setup if: ${{ !cancelled() && inputs.run_unit_tests }}
with: uses: codecov/codecov-action@v5
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@v6
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }} flags: host-unit
fail_ci_if_error: false
files: "**/build/reports/kover/report*.xml"
- name: Upload unit test results to Codecov
if: ${{ !cancelled() && inputs.run_unit_tests }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: host-unit
fail_ci_if_error: false fail_ci_if_error: false
report_type: test_results report_type: test_results
files: "**/build/test-results/**/*.xml" files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov - name: Upload host reports
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 }} if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: reports-${{ matrix.shard.name }} name: reports-host
path: | path: |
**/build/reports **/build/reports
**/build/test-results **/build/test-results
retention-days: 7 retention-days: 7
# ── Android Build ────────────────────────────────────────────────────
android-check: android-check:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
timeout-minutes: 60 timeout-minutes: 60
needs: lint-check strategy:
env: fail-fast: true
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} matrix:
api_level: ${{ fromJson(inputs.api_levels) }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 0
submodules: true submodules: 'recursive'
- name: Gradle Setup - name: Validate Gradle Wrapper
uses: ./.github/actions/gradle-setup uses: gradle/actions/wrapper-validation@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} distribution: 'temurin'
token: ${{ github.token }}
- name: Build Android APKs - name: Setup Gradle
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.event_name != 'merge_group' && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/') }}
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-cleanup: on-success
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: Determine matrix metadata
id: matrix_meta
shell: bash
run: |
first_api=$(python3 - <<'PY'
import json
print(json.loads('${{ inputs.api_levels }}')[0])
PY
)
if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then
echo "is_first_api=true" >> "$GITHUB_OUTPUT"
else
echo "is_first_api=false" >> "$GITHUB_OUTPUT"
fi
- name: Determine Android tasks
id: tasks
shell: bash
run: |
tasks=(
"app:assembleFdroidDebug"
"app:assembleGoogleDebug"
"mesh_service_example:assembleDebug"
)
if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then
tasks+=(
"app:connectedFdroidDebugAndroidTest"
"app:connectedGoogleDebugAndroidTest"
"core:barcode:connectedFdroidDebugAndroidTest"
"core:barcode:connectedGoogleDebugAndroidTest"
)
fi
printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT"
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Android Build & Instrumented Tests
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
- name: Run Android Build
if: inputs.run_instrumented_tests == false
run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
- name: Upload instrumented test results to Codecov
if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: android-instrumented
fail_ci_if_error: false
report_type: test_results
files: "**/build/outputs/androidTest-results/**/*.xml"
- name: Upload debug artifact - name: Upload debug artifact
if: ${{ inputs.upload_artifacts }} if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: app-debug-apks name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk path: app/build/outputs/apk/*/debug/*.apk
retention-days: 7 retention-days: 14
- name: Report App Size - name: Report App Size
if: always() if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
run: | run: |
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
# ── Desktop Build ─────────────────────────────────────────────────── - name: Upload Android reports
build-desktop: if: ${{ always() && inputs.upload_artifacts }}
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 uses: actions/upload-artifact@v7
with: with:
name: desktop-app-${{ runner.os }}-${{ runner.arch }} name: reports-android-api-${{ matrix.api_level }}
path: desktop/build/compose/binaries/main/app/ path: |
**/build/outputs/androidTest-results
retention-days: 7 retention-days: 7
if-no-files-found: ignore

View file

@ -2,12 +2,12 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
on: on:
schedule: schedule:
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) - cron: '0 * * * *' # Run every hour
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
jobs: jobs:
update_assets: update_assets:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
if: github.repository == 'meshtastic/Meshtastic-Android' if: github.repository == 'meshtastic/Meshtastic-Android'
permissions: permissions:
contents: write # To commit files and push branches contents: write # To commit files and push branches
@ -81,11 +81,21 @@ jobs:
- name: Fix file permissions - name: Fix file permissions
run: sudo chown -R $USER:$USER . run: sudo chown -R $USER:$USER .
- name: Gradle Setup - name: Set up JDK 17
uses: ./.github/actions/gradle-setup uses: actions/setup-java@v5
with: with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} java-version: '17'
cache_read_only: 'false' distribution: 'temurin'
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
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: Update Graphs - name: Update Graphs
run: ./gradlew graphUpdate run: ./gradlew graphUpdate
@ -132,7 +142,7 @@ jobs:
check-workflow-status: check-workflow-status:
name: Check Workflow Status name: Check Workflow Status
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
permissions: {} permissions: {}
needs: needs:
- update_assets - update_assets

View file

@ -12,7 +12,7 @@ permissions:
jobs: jobs:
stale_issues: stale_issues:
name: Close Stale Issues name: Close Stale Issues
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
if: github.repository == 'meshtastic/Meshtastic-Android' if: github.repository == 'meshtastic/Meshtastic-Android'
steps: steps:
@ -20,7 +20,7 @@ jobs:
uses: actions/stale@v10.2.0 uses: actions/stale@v10.2.0
with: with:
days-before-stale: 30 days-before-stale: 30
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. 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.
exempt-issue-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies' 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' exempt-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
operations-per-run: 100 operations-per-run: 100

3
.gitignore vendored
View file

@ -53,6 +53,3 @@ wireless-install.sh
.worktrees/ .worktrees/
/firebase-debug.log.jdk/ /firebase-debug.log.jdk/
firebase-debug.log firebase-debug.log
.agent_plans/
.agent_refs/
.agent_artifacts/

View file

@ -1,295 +0,0 @@
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..2a27b96906
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+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>(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 <https://www.gnu.org/licenses/>.
+ */
+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<DebugViewModel.U
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
- 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<DebugViewModel.U
return@launch
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
// Run file dialog to ask user where to save
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
fileDialog.file = fileName
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9fb71379fc..bfbb85bc0d 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
if (directory != null && file != null) {
val targetFile = File(directory, file)
val data = dataPackageProvider()
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
}
}

View file

@ -1,66 +0,0 @@
# Skill: Code Review
## Description
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
## Code Review Checklist
When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
### 1. KMP Architecture & Source Set Boundaries
- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets.
- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries:
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
- `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
### 3. Navigation & State
- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.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<T>` 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.

View file

@ -1,61 +0,0 @@
# Skill: Compose Multiplatform (CMP) UI
## Description
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
## 1. UI Components & Layouts
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
## 2. Strings & Resources
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
```kotlin
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
stringResource(Res.string.battery_percent, formatted) // uses %1$s
```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
### String Formatting Decision Tree
Choose the right tool for the job:
| Scenario | Tool | Example |
|----------|------|---------|
| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)``"77.0°F"` |
| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
**Rules:**
1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.<key>` 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`

View file

@ -1,41 +0,0 @@
# Skill: Implement a Feature
## Description
A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture.
## Workflow
### 1. Update Dependencies & Aliases
- Check `gradle/libs.versions.toml` before adding libraries.
- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`.
- Use `compose-multiplatform-*` aliases for CMP dependencies.
### 2. Define the State & ViewModels
- Follow MVI/UDF patterns.
- Extend shared ViewModel logic in `feature/<name>/src/commonMain/kotlin/org/meshtastic/feature/<name>/<Name>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<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.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
```

View file

@ -1,61 +0,0 @@
# Skill: KMP Architecture & Source-Set Bridging
## Description
Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules.
## 1. Source-Set Boundaries
- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports.
- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings).
- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks.
- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`.
## 2. Bridging Strategies
- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`.
- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`.
- **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors.
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
## 3. Core Libraries & Constraints
- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
- **Standard Library Replacements:**
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
- **BLE:** Route through `core:ble` using **Kable**.
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
## 4. Hierarchy & Source-Set Conventions
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`.
## 5. Dependency Catalog Aliases
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`.
- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available.
## 6. I/O & Serialization
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Room Patterns:**
- Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
- Use `LIMIT 1` on `@Query` methods that expect a single row.
- Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List<T>)` 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/`

View file

@ -1,56 +0,0 @@
# Skill: DI and Navigation 3 Architecture
## Description
This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
## Dependency Injection (Koin)
### Guidelines
1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
### Anti-Patterns
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
### Koin Startup Pattern (K2 Compiler Plugin)
The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin<T>()` 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<AndroidKoinApp> {
androidContext(this@MeshUtilApplication)
workManagerFactory()
}
```
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (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<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.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<NavKey>` 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`

View file

@ -1,79 +0,0 @@
# Skill: New Branch Bootstrap
## Description
Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
This replaces the ad-hoc prose that used to be retyped at the start of every session.
## When to Use
- Starting any new feature, fix, chore, or refactor.
- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
- Reproducing a CI failure from a clean baseline.
## Preconditions (verify before branching)
1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
`meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
workspace bootstrap rules.
4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
(required for `google` flavor builds).
## Standard Recipe
```bash
# 1. Fetch latest upstream
git fetch upstream --prune --tags
# 2. Create the branch from upstream/main (never from a local stale main)
git switch -c <branch-name> upstream/main
# 3. Ensure submodules track the new base
git submodule update --init --recursive
# 4. Sanity check
git --no-pager log -1 --oneline
```
## Branch Naming
Use conventional-commit style prefixes that match the PR title convention in AGENTS.md
`<git_and_prs>`:
| Prefix | Use for |
| :--- | :--- |
| `feat/<scope>` | New user-visible behavior |
| `fix/<scope>` | Bug fixes |
| `refactor/<scope>` | Code structure changes, no behavior change |
| `chore/<scope>` | Tooling, deps, CI, cleanup |
| `docs/<scope>` | Documentation only |
Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
## Rebase Variant
When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*:
```bash
git fetch upstream --prune
gh pr checkout <NNNN> # checks out the PR head locally
git rebase upstream/main
git submodule update --init --recursive
# Resolve conflicts, then:
git push --force-with-lease
```
Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
## Post-Branch Checklist
- [ ] Branch name follows conventional prefix.
- [ ] Submodules up to date.
- [ ] `local.properties` exists.
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
## Tip: Prefer `/delegate` for Long Audits
If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since
v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider
suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR
end-to-end while the user keeps working locally. See AGENTS.md `<copilot_cli_workflow>`.

View file

@ -1,83 +0,0 @@
# Skill: Project Overview & Codebase Map
## Description
Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
## Codebase Map
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
## Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
## Environment Setup
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
```properties
MAPS_API_KEY=dummy_key
datadogApplicationId=dummy_id
datadogClientToken=dummy_token
```
## Workspace Bootstrap (MUST run before any build)
Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you.
1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
```bash
# Check common macOS/Linux locations in order of preference
if [ -z "$ANDROID_HOME" ]; then
for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi
done
fi
```
All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path.
2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors:
```bash
git submodule update --init
```
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
```bash
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.

View file

@ -1,85 +0,0 @@
# Skill: Testing and CI Verification
## Description
Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type.
## 1) Baseline local verification order
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
```bash
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
> **Why `test allTests` and not just `test`:**
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
## 2) Change-type verification matrix
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.
- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`.
- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`.
- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available.
- If touching any KMP module, also run `kmpSmokeCompile`.
- `worker/service/background` changes: Broad tests, targeted WorkManager checks.
- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`.
## 3) Flavor checks
Run these when relevant to map, provider, or flavor-specific behavior:
```bash
./gradlew lintFdroidDebug lintGoogleDebug
./gradlew testFdroidDebug testGoogleDebug
```
## 4) CI Pipeline Architecture
CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups:
1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
- `shard-core`: `allTests` for all `core:*` KMP modules.
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`).
### Runner Strategy (Three Tiers)
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times.
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility.
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging.
### CI Gradle Properties
`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
- `org.gradle.daemon=false` (single-use runners)
- `kotlin.incremental=false` (fresh checkouts)
- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon
- VFS watching disabled, workers capped at 4
- `org.gradle.isolated-projects=true` for better parallelism
- Disables unused Android build features (`resvalues`, `shaders`)
### CI Conventions
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level.
- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10).
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI.
- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache.
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.

239
AGENTS.md
View file

@ -1,108 +1,151 @@
# Meshtastic Android - Unified Agent & Developer Guide # Meshtastic Android - Agent Guide
<role> This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns.
</role>
<context_and_memory> For execution-focused recipes, see `docs/agent-playbooks/README.md`.
- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience.
- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP.
- **Core Architecture:**
- `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings.
- App root DI and graph assembly live in the `app` and `desktop` host shells.
- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work:
- `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting.
- `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions.
- `.skills/compose-ui/` - Adaptive UI, placeholders, string resources.
- `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations.
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
- `.skills/implement-feature/` - Step-by-step feature workflow.
- `.skills/code-review/` - PR validation checklist.
- `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
</context_and_memory>
<process> ## 1. Project Vision & Architecture
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically: Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
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).
</process>
<agent_tools> - **Language:** Kotlin (primary), AIDL.
- **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. - **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
- **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. - **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
- **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. - **Flavors:**
- **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: - `fdroid`: Open source only, no tracking/analytics.
- `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs) - `google`: Includes Google Play Services (Maps) and DataDog analytics.
- `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x) - **Core Architecture:** Modern Android Development (MAD) with KMP core.
- `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI) - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- `https://github.com/JuulLabs/kable` (BLE) - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- `https://github.com/coil-kt/coil` (Coil 3 KMP) - **UI:** Jetpack Compose Multiplatform (Material 3).
- `https://github.com/ktorio/ktor` (Ktor Networking) - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing. - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
</agent_tools> - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
<documentation_sync> ## 2. Codebase Map
`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here:
- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`.
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed. | Directory | Description |
</documentation_sync> | :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `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`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
<rules> ## 3. Development Guidelines & Coding Standards
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
</rules>
<copilot_cli_workflow> ### A. UI Development (Jetpack Compose)
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this - **Material 3:** The app uses Material 3.
section. - **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet" ### B. Logic & Data Layer
prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*, - **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
*"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub - **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
session on work that can run unattended. - `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"* - `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
research across GitHub and the web with better source grounding than an ad-hoc prompt. - `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation - **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to - **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent - **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to - **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
`.agent_plans/` (git-ignored) for multi-module refactors. - **Concurrency:** Use Kotlin Coroutines and Flow.
- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle - **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
quality passes, offer `/share` to export the findings to a gist or markdown file. These - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
reports are valuable artifacts — don't let them die in session history. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts - **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration. - **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
Avoid re-issuing the same prompt verbatim. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main" - **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`.
or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe. - **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
</copilot_cli_workflow> - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
<git_and_prs> ### C. Namespacing
- **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. - **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **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. - **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
</git_and_prs> ## 4. Execution Protocol
### A. Environment Setup
1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
```properties
MAPS_API_KEY=dummy_key
datadogApplicationId=dummy_id
datadogClientToken=dummy_token
```
### B. Strict Execution Commands
Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
**Baseline (recommended order):**
```bash
./gradlew clean
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew detekt
./gradlew assembleDebug
./gradlew test
```
**Testing:**
```bash
./gradlew test # Run local unit tests
./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
./gradlew connectedAndroidTest # Run instrumented tests
./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
```
*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
**CI workflow conventions (GitHub Actions):**
- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
### C. Documentation Sync
Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
## 5. Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Missing Secrets:** Check `local.properties`.
- **JDK Version:** JDK 17 is required.
- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).

View file

@ -1,9 +0,0 @@
# Meshtastic Android - Claude Code Guide
@AGENTS.md
## Claude-Specific Instructions
- **Think First:** Always outline your step-by-step reasoning inside `<thinking>` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first.
- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task.
- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `<copilot_cli_workflow>` section.

View file

@ -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. - **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**. - **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
- Note: If using Java 21, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues. - Note: If using Java 17, 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). - **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 #### Guidelines for Testing

149
GEMINI.md
View file

@ -1,6 +1,147 @@
# Meshtastic Android - Google Gemini Guide # Meshtastic Android - Agent Guide
> **Note:** The canonical instructions for all AI Agents have been deduplicated. This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. For execution-focused recipes, see `docs/agent-playbooks/README.md`.
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
## 1. Project Vision & Architecture
Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
- **Language:** Kotlin (primary), AIDL.
- **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
- **Flavors:**
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
## 2. 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.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`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`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
### C. Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
## 4. Execution Protocol
### A. Environment Setup
1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
```properties
MAPS_API_KEY=dummy_key
datadogApplicationId=dummy_id
datadogClientToken=dummy_token
```
### B. Strict Execution Commands
Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
**Baseline (recommended order):**
```bash
./gradlew clean
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew detekt
./gradlew assembleDebug
./gradlew test
```
**Testing:**
```bash
./gradlew test # Run local unit tests
./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
./gradlew connectedAndroidTest # Run instrumented tests
./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
```
*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
**CI workflow conventions (GitHub Actions):**
- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
### C. Documentation Sync
Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
## 5. Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Missing Secrets:** Check `local.properties`.
- **JDK Version:** JDK 17 is required.
- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).

View file

@ -3,13 +3,13 @@ GEM
specs: specs:
CFPropertyList (3.0.8) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.9.0) addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1240.0) aws-partitions (1.1213.0)
aws-sdk-core (3.245.0) aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -17,11 +17,11 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.123.0) aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.219.0) aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
@ -29,7 +29,7 @@ GEM
babosa (1.0.4) babosa (1.0.4)
base64 (0.2.0) base64 (0.2.0)
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (4.1.2) bigdecimal (4.0.1)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.4) faraday-retry (1.0.3)
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.1) fastimage (2.4.0)
fastlane (2.233.0) fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2) abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0) fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
@ -122,9 +122,10 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1) xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0) fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.99.0) google-apis-androidpublisher_v3 (0.95.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0) google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
@ -138,15 +139,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0) google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0) google-apis-storage_v1 (0.59.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0) google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1) google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0) google-cloud-errors (1.5.0)
google-cloud-storage (1.59.0) google-cloud-storage (1.58.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2) google-apis-core (>= 0.18, < 2)
@ -168,13 +169,13 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.19.4) json (2.18.1)
jwt (2.10.2) jwt (2.10.2)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.20.1) multi_json (1.19.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
@ -184,13 +185,13 @@ GEM
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (7.0.5) public_suffix (7.0.2)
rake (13.4.2) rake (13.3.1)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.4.1) retriable (3.1.2)
rexml (3.4.4) rexml (3.4.4)
rouge (3.28.0) rouge (3.28.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@ -204,6 +205,7 @@ GEM
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)

View file

@ -51,10 +51,10 @@ You can generate the documentation locally to preview your changes.
1. **Run the Dokka task:** 1. **Run the Dokka task:**
```bash ```bash
./gradlew dokkaGeneratePublicationHtml ./gradlew :app:dokkaHtml
``` ```
2. **View the output:** 2. **View the output:**
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. 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.
## Architecture ## Architecture
@ -80,8 +80,6 @@ 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). 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 ## Building the Android App
> [!WARNING] > [!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. > 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.

31
SOUL.md Normal file
View file

@ -0,0 +1,31 @@
# Meshtastic-Android: AI Agent Soul (SOUL.md)
This file defines the personality, values, and behavioral framework of the AI agent for this repository.
## 1. Core Identity
I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack.
## 2. Core Truths & Values
- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets.
- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible.
- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic.
- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility.
## 3. Communication Style (The "Vibe")
- **Direct & Concise:** I skip the fluff. I provide technical rationale first.
- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions.
- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it.
## 4. Operational Boundaries
- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules.
- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic.
- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity.
- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system.
## 5. Evolution
I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers.
For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth.
For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`.

View file

@ -6,7 +6,7 @@ The `:app` module is the entry point for the Meshtastic Android application. It
## Key Components ## Key Components
### 1. `MainActivity` & `Main.kt` ### 1. `MainActivity` & `Main.kt`
The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.). The single Activity of the application. It hosts the `NavHost` and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService` ### 2. `MeshService`
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. 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.
@ -42,7 +42,6 @@ graph TB
:app -.-> :core:resources :app -.-> :core:resources
:app -.-> :core:ui :app -.-> :core:ui
:app -.-> :core:barcode :app -.-> :core:barcode
:app -.-> :core:takserver
:app -.-> :feature:intro :app -.-> :feature:intro
:app -.-> :feature:messaging :app -.-> :feature:messaging
:app -.-> :feature:connections :app -.-> :feature:connections
@ -50,7 +49,6 @@ graph TB
:app -.-> :feature:node :app -.-> :feature:node
:app -.-> :feature:settings :app -.-> :feature:settings
:app -.-> :feature:firmware :app -.-> :feature:firmware
:app -.-> :feature:wifi-provision
:app -.-> :feature:widget :app -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -150,7 +150,7 @@ configure<ApplicationExtension> {
includeInBundle = false includeInBundle = false
} }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "org.meshtastic.app.TestRunner"
} }
// Configure existing product flavors (defined by convention plugin) // Configure existing product flavors (defined by convention plugin)
@ -171,6 +171,8 @@ configure<ApplicationExtension> {
} else { } else {
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = false isDebuggable = false
} }
} }
@ -217,7 +219,6 @@ dependencies {
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network) implementation(projects.core.network)
implementation(projects.core.nfc) implementation(projects.core.nfc)
implementation(projects.core.prefs) implementation(projects.core.prefs)
@ -226,7 +227,6 @@ dependencies {
implementation(projects.core.resources) implementation(projects.core.resources)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.barcode) implementation(projects.core.barcode)
implementation(projects.core.takserver)
implementation(projects.feature.intro) implementation(projects.feature.intro)
implementation(projects.feature.messaging) implementation(projects.feature.messaging)
implementation(projects.feature.connections) implementation(projects.feature.connections)
@ -234,17 +234,16 @@ dependencies {
implementation(projects.feature.node) implementation(projects.feature.node)
implementation(projects.feature.settings) implementation(projects.feature.settings)
implementation(projects.feature.firmware) implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget) implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.material) implementation(libs.material)
implementation(libs.compose.multiplatform.animation) implementation(libs.androidx.compose.material3)
implementation(libs.compose.multiplatform.material3) implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.compose.multiplatform.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.compose.multiplatform.ui) implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.appwidget.preview) implementation(libs.androidx.glance.appwidget.preview)
implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.material3)
@ -252,6 +251,7 @@ dependencies {
implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.androidx.paging.compose)
implementation(libs.ktor.client.android) implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@ -264,6 +264,7 @@ dependencies {
implementation(libs.usb.serial.android) implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager) implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations) implementation(libs.koin.annotations)
@ -279,10 +280,9 @@ dependencies {
googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils) googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets) googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum) 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.timber)
googleImplementation(libs.dd.sdk.android.trace) googleImplementation(libs.dd.sdk.android.trace)
googleImplementation(libs.dd.sdk.android.trace.otel) googleImplementation(libs.dd.sdk.android.trace.otel)
@ -294,29 +294,34 @@ dependencies {
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack) fdroidImplementation(libs.osmbonuspack)
testImplementation(kotlin("test-junit")) androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.koin.test)
testImplementation(libs.androidx.work.testing) testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test) testImplementation(libs.koin.test)
testRuntimeOnly(libs.junit.vintage.engine) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.multiplatform.ui.test) testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.glance.appwidget) testImplementation(libs.androidx.glance.appwidget)
} }
aboutLibraries { aboutLibraries {
// Run offline by default to avoid burning GitHub API calls on every build. // Fetch full license text + funding info from GitHub API when on CI with a token
// Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info. val isCi =
val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false) providers
.gradleProperty("ci")
.map { it.toBoolean() }
.getOrElse(providers.environmentVariable("CI").map { it.toBoolean() }.getOrElse(false))
val ghToken = providers.environmentVariable("GITHUB_TOKEN") val ghToken = providers.environmentVariable("GITHUB_TOKEN")
offlineMode = !isReleaseBuild
collect { collect {
fetchRemoteLicense = isReleaseBuild && ghToken.isPresent fetchRemoteLicense = isCi && ghToken.isPresent
fetchRemoteFunding = isReleaseBuild && ghToken.isPresent fetchRemoteFunding = isCi && ghToken.isPresent
if (ghToken.isPresent) { if (ghToken.isPresent) {
gitHubApiToken = ghToken.get() gitHubApiToken = ghToken.get()
} }

View file

@ -1,45 +1,49 @@
# ============================================================================ # Add project specific ProGuard rules here.
# Meshtastic Android ProGuard / R8 rules for release minification # You can control the set of applied configuration files using the
# ============================================================================ # proguardFiles setting in build.gradle.kts.
# Open-source project: obfuscation and optimization are disabled. We rely on
# tree-shaking (unused code removal) for APK size reduction.
# #
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, # For more details, see
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, # http://developer.android.com/guide/developing/tools/proguard.html
# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
# config/proguard/shared-rules.pro and are wired in by the
# AndroidApplicationConventionPlugin. This file holds only Android-specific
# rules and R8-only directives.
# ============================================================================
# ---- General ---------------------------------------------------------------- # If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Open-source no need to obfuscate # Uncomment this to preserve the line number information for
-dontobfuscate # debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# Disable R8 optimization passes. Tree-shaking (unused code removal) still # If you keep the line number information, uncomment this to
# runs only method-body rewrites and call-site transformations are suppressed. # hide the original source file name.
# #-renamesourcefileattribute SourceFile
# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on
# Composer.<clinit>() and ComposerImpl.<clinit>(), plus -assumevalues on
# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives
# let 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
# Dump the full merged R8 configuration (app rules + all library consumer rules) # Room KMP: preserve generated database constructor (required for R8/ProGuard)
# for auditing. Inspect this file after a release build to see what libraries inject. -keep class * extends androidx.room.RoomDatabase { <init>(); }
-printconfiguration build/outputs/mapping/r8-merged-config.txt
# ---- Networking (transitive references from Ktor on Android) ---------------- # Needed for protobufs
-keep class com.google.protobuf.** { *; }
-keep class org.meshtastic.proto.** { *; }
# Networking
-dontwarn org.conscrypt.** -dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
# Compose runtime/ui/animation/foundation/material3 keep rules now live in # ?
# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard) -dontwarn java.lang.reflect.**
# get the same defence-in-depth coverage against CMP 1.11 optimizer folding. -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.** { *; }

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app
import androidx.test.runner.AndroidJUnitRunner
@Suppress("unused")
class TestRunner : AndroidJUnitRunner()

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.filter
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
import org.koin.test.inject
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
@RunWith(AndroidJUnit4::class)
class MessageFilterIntegrationTest : KoinTest {
private val filterPrefs: FilterPrefs by inject()
private val filterService: MessageFilter by inject()
@org.junit.Ignore("Flaky integration test, needs Koin test rule setup")
@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.setFilterEnabled(true)
filterPrefs.setFilterWords(setOf("test", "spam"))
// Wait briefly for DataStore to process the writes and flows to emit
kotlinx.coroutines.delay(100)
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))
assertTrue(filterService.shouldFilter("spam content"))
}
}

View file

@ -23,17 +23,32 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider import org.meshtastic.core.ui.util.MapViewProvider
/** OSMDroid implementation of [MapViewProvider]. */
@Single @Single
class FdroidMapViewProvider : MapViewProvider { class FdroidMapViewProvider : MapViewProvider {
@Composable @Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
val mapViewModel: MapViewModel = koinViewModel() val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
@Suppress("UNCHECKED_CAST")
org.meshtastic.app.map.MapView( org.meshtastic.app.map.MapView(
modifier = modifier, modifier = modifier,
mapViewModel = mapViewModel, mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails, navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
) )
} }
} }

View file

@ -17,8 +17,10 @@
package org.meshtastic.app.map package org.meshtastic.app.map
import android.Manifest import android.Manifest
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@ -30,17 +32,24 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -49,7 +58,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -57,6 +65,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -77,6 +87,7 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.gpsDisabled
@ -94,7 +105,6 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager import org.meshtastic.core.resources.map_cache_manager
@ -104,6 +114,7 @@ import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region 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_node_popup_details
import org.meshtastic.core.resources.map_offline_manager import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail import org.meshtastic.core.resources.map_purge_fail
@ -112,25 +123,21 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites 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_precision_circle
import org.meshtastic.core.resources.show_waypoints 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.waypoint_delete
import org.meshtastic.core.resources.you import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.Check import org.meshtastic.core.ui.theme.TracerouteColors
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.formatAgo
import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.model.TracerouteOverlay
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 org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
@ -149,23 +156,38 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File import java.io.File
import kotlin.math.roundToInt import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
private fun MapView.updateMarkers( private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>, nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>, waypointMarkers: List<MarkerWithLabel>,
trackMarkers: List<Marker>,
trackPolylines: List<Polyline>,
nodeClusterer: RadiusMarkerClusterer, nodeClusterer: RadiusMarkerClusterer,
) { ) {
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } Logger.d {
"Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
}
val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
overlays.removeAll { overlay -> overlays.removeAll { overlay ->
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) overlay is MarkerWithLabel ||
(overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
(overlay is Polyline && overlay !in trackOverlayIds)
} }
overlays.addAll(waypointMarkers) overlays.addAll(waypointMarkers)
overlays.addAll(trackPolylines)
overlays.addAll(trackMarkers)
nodeClusterer.items.clear() nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers) nodeClusterer.items.addAll(nodeMarkers)
@ -203,12 +225,17 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/ */
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod") @Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
@Composable @Composable
fun MapView( fun MapView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(), mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTracks: List<Position>? = null,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) { ) {
var mapFilterExpanded by remember { mutableStateOf(false) } var mapFilterExpanded by remember { mutableStateOf(false) }
@ -307,16 +334,6 @@ 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 nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@ -332,21 +349,77 @@ 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<Polyline>() }
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> { fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null } val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value val ourNode = mapViewModel.ourNodeInfo.value
val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC val displayUnits =
mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node -> return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
if ( if (
mapFilterStateValue.lastHeardFilter.seconds != 0L && mapFilterStateValue.onlyFavorites &&
(nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && !node.isFavorite &&
node.num != ourNode?.num !overlayNodeNums.contains(node.num) &&
!node.equals(ourNode)
) { ) {
return@mapNotNull null return@mapNotNull null
} }
@ -507,6 +580,53 @@ fun MapView(
invalidate() invalidate()
} }
fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
overlays.removeAll(traceroutePolylines)
traceroutePolylines.clear()
fun buildPolyline(points: List<GeoPoint>, 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() { fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon } overlays.removeAll { it is Polygon }
val zoomFactor = 1.3 val zoomFactor = 1.3
@ -569,6 +689,49 @@ fun MapView(
} }
} }
fun MapView.onTracksChanged(nodeTracks: List<Position>?, focusedNodeNum: Int?): Pair<List<Marker>, List<Polyline>> {
if (nodeTracks == null || focusedNodeNum == null) return emptyList<Marker>() to emptyList<Polyline>()
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<Marker>() to emptyList<Polyline>()
val color = focusedNode.colors.second
val trackPolylines = mutableListOf<Polyline>()
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( Scaffold(
modifier = modifier, modifier = modifier,
floatingActionButton = { floatingActionButton = {
@ -585,10 +748,14 @@ fun MapView(
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
update = { mapView -> update = { mapView ->
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) { with(mapView) {
updateMarkers( updateMarkers(
onNodesChanged(nodes), onNodesChanged(nodesForMarkers),
onWaypointChanged(waypoints.values, selectedWaypointId), onWaypointChanged(waypoints.values, selectedWaypointId),
trackMarkers,
trackPolylines,
nodeClusterer, nodeClusterer,
) )
} }
@ -607,34 +774,122 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
} else { } else {
MapControlsOverlay( @Suppress("MagicNumber")
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), Column(
onToggleFilterMenu = { mapFilterExpanded = true }, modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
filterDropdownContent = { verticalArrangement = Arrangement.spacedBy(8.dp),
FdroidMainMapFilterDropdown( ) {
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(
expanded = mapFilterExpanded, expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false }, onDismissRequest = { mapFilterExpanded = false },
mapFilterState = mapFilterState, modifier = Modifier.background(MaterialTheme.colorScheme.surface),
mapViewModel = mapViewModel, ) {
) DropdownMenuItem(
}, text = {
mapTypeContent = { Row(
MapButton( modifier = Modifier.fillMaxWidth(),
icon = MeshtasticIcons.Layers, verticalAlignment = Alignment.CenterVertically,
contentDescription = stringResource(Res.string.map_style_selection), ) {
onClick = { showMapStyleDialog = true }, Icon(
) imageVector = Icons.Rounded.Star,
}, contentDescription = null,
isLocationTrackingEnabled = myLocationOverlay != null, modifier = Modifier.padding(end = 8.dp),
onToggleLocationTracking = { 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),
) {
if (locationPermissionsState.allPermissionsGranted) { if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation() map.toggleMyLocation()
} else { } else {
triggerLocationToggleAfterPermission = true triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest() locationPermissionsState.launchMultiplePermissionRequest()
} }
}, }
) }
} }
} }
} }
@ -713,103 +968,6 @@ 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 @Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) } val selected = remember { mutableStateOf(selectedMapStyle) }
@ -818,7 +976,7 @@ private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelec
CustomTileSource.mTileSources.values.forEachIndexed { index, style -> CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
ListItem( ListItem(
text = style, text = style,
trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, trailingIcon = if (index == selected.value) Icons.Rounded.Check else null,
onClick = { onClick = {
selected.value = index selected.value = index
onSelectMapStyle(index) onSelectMapStyle(index)
@ -861,9 +1019,15 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
onDismiss = onDismiss, onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) { ) {
val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() Text(
val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() modifier = Modifier.padding(16.dp),
Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) text =
stringResource(
Res.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
),
)
} }
} }
@ -959,4 +1123,56 @@ 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 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<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
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))
}
}

View file

@ -124,21 +124,20 @@ fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: ()
return polyline return polyline
} }
fun MapView.addPositionMarkers(positions: List<Position>, onClick: (Int) -> Unit): List<Marker> { fun MapView.addPositionMarkers(positions: List<Position>, onClick: () -> Unit): List<Marker> {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
val markers = val markers = positions.map {
positions.map { pos -> Marker(this).apply {
Marker(this).apply { icon = navIcon
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)
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 { _, _ ->
setOnMarkerClickListener { _, _ -> onClick()
onClick(pos.time) true
true
}
} }
} }
}
overlays.addAll(markers) overlays.addAll(markers)
return markers return markers

View file

@ -16,6 +16,9 @@
*/ */
package org.meshtastic.app.map package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -29,6 +32,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@ -37,6 +41,29 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView 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 MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0 private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0 private const val DEFAULT_ZOOM_LEVEL = 15.0
@ -109,13 +136,22 @@ internal fun rememberMapViewWithLifecycle(
} }
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(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 -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_PAUSE -> { Lifecycle.Event.ON_PAUSE -> {
wakeLock.safeRelease()
mapView.onPause() mapView.onPause()
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
wakeLock.safeAcquire()
mapView.onResume() mapView.onResume()
} }
@ -130,7 +166,10 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer) lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) } onDispose {
lifecycle.removeObserver(observer)
wakeLock.safeRelease()
}
} }
return mapView return mapView
} }

View file

@ -21,6 +21,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally 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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -30,8 +32,6 @@ import androidx.compose.ui.draw.scale
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_download_region import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.ui.icon.Download
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable @Composable
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
@ -50,7 +50,7 @@ fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
) { ) {
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
Icon( Icon(
imageVector = MeshtasticIcons.Download, imageVector = Icons.Rounded.Download,
contentDescription = stringResource(Res.string.map_download_region), contentDescription = stringResource(Res.string.map_download_region),
modifier = Modifier.scale(1.25f), modifier = Modifier.scale(1.25f),
) )

View file

@ -34,6 +34,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -78,9 +81,6 @@ import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog 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.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@ -198,10 +198,7 @@ fun EditWaypointDialog(
modifier = Modifier.fillMaxWidth().size(48.dp), modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Image( Image(imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.locked))
imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Text(stringResource(Res.string.locked)) Text(stringResource(Res.string.locked))
Switch( Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
@ -258,7 +255,7 @@ fun EditWaypointDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Image( Image(
imageVector = MeshtasticIcons.CalendarMonth, imageVector = Icons.Rounded.CalendarMonth,
contentDescription = stringResource(Res.string.expires), contentDescription = stringResource(Res.string.expires),
) )
Text(stringResource(Res.string.expires)) Text(stringResource(Res.string.expires))

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun MapButton(
icon: ImageVector,
contentDescription: StringResource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MapButton(
icon = icon,
contentDescription = stringResource(contentDescription),
modifier = modifier,
onClick = onClick,
)
}
@Composable
fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
FloatingActionButton(onClick = onClick, modifier = modifier) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection) }
}

View file

@ -17,38 +17,48 @@
package org.meshtastic.app.map.node package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.component.MainAppBar 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.feature.map.node.NodeMapViewModel import org.meshtastic.feature.map.node.NodeMapViewModel
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DEG_D = 1e-7
@Composable @Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val density = LocalDensity.current
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
Scaffold( val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
topBar = { val mapView =
MainAppBar( rememberMapViewWithLifecycle(
title = node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
NodeTrackOsmMap(
positions = positions,
applicationId = nodeMapViewModel.applicationId, applicationId = nodeMapViewModel.applicationId,
mapStyleId = nodeMapViewModel.mapStyleId, box = cameraView,
modifier = Modifier.fillMaxSize().padding(paddingValues), tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
) )
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(positionLogs) {}
},
)
} }

View file

@ -1,50 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}

View file

@ -1,162 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Position>,
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,
)
}
}
},
)
}
}

View file

@ -1,41 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View file

@ -1,288 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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<Int, Position>,
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<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, 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<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
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))
}
}

View file

@ -33,11 +33,7 @@ import com.datadog.android.log.LogsConfiguration
import com.datadog.android.privacy.TrackingConsent import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum import com.datadog.android.rum.Rum
import com.datadog.android.rum.RumActionType
import com.datadog.android.rum.RumConfiguration import com.datadog.android.rum.RumConfiguration
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.Trace
import com.datadog.android.trace.TraceConfiguration import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
@ -72,7 +68,7 @@ import co.touchlab.kermit.Logger as KermitLogger
class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) : class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) :
PlatformAnalytics { PlatformAnalytics {
private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private var datadogLogger: Logger? = null private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false private var isFirebaseInitialized = false
@ -141,7 +137,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val configuration = val configuration =
Configuration.Builder( Configuration.Builder(
clientToken = BuildConfig.datadogClientToken, clientToken = BuildConfig.datadogClientToken,
env = if (BuildConfig.DEBUG) "Local" else "Production", env = if (BuildConfig.DEBUG) "debug" else "release",
variant = BuildConfig.FLAVOR, variant = BuildConfig.FLAVOR,
) )
.useSite(DatadogSite.US5) .useSite(DatadogSite.US5)
@ -155,7 +151,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val rumConfiguration = val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId) RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true) .trackAnonymousUser(true)
.trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity .trackBackgroundEvents(false) // Disable background noise
.trackFrustrations(false) // Disable click-tracking based frustration detection .trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks() .trackLongTasks()
.trackNonFatalAnrs(true) .trackNonFatalAnrs(true)
@ -166,19 +162,9 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val logsConfig = LogsConfiguration.Builder().build() val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig) Logs.enable(logsConfig)
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build() val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
Trace.enable(traceConfig) Trace.enable(traceConfig)
// 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)
}
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME)) GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
} }
@ -247,24 +233,6 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
GlobalRumMonitor.get().addAttribute("device_hardware", model) GlobalRumMonitor.get().addAttribute("device_hardware", model)
} }
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 private val isGooglePlayAvailable: Boolean
get() = get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let { GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {

View file

@ -23,17 +23,31 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single @Single
class GoogleMapViewProvider : MapViewProvider { class GoogleMapViewProvider : MapViewProvider {
@Composable @Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
val mapViewModel: MapViewModel = koinViewModel() val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView( org.meshtastic.app.map.MapView(
modifier = modifier, modifier = modifier,
mapViewModel = mapViewModel, mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails, navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
) )
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -28,11 +28,7 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType import com.google.maps.android.compose.MapType
import io.ktor.client.HttpClient import kotlinx.coroutines.Dispatchers
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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -49,7 +45,6 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRepository
@ -82,8 +77,6 @@ data class MapCameraPosition(
@KoinViewModel @KoinViewModel
class MapViewModel( class MapViewModel(
private val application: Application, private val application: Application,
private val dispatchers: CoroutineDispatchers,
private val httpClient: HttpClient,
mapPrefs: MapPrefs, mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs, private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository, nodeRepository: NodeRepository,
@ -411,7 +404,7 @@ class MapViewModel(
} }
private fun loadPersistedLayers() { private fun loadPersistedLayers() {
viewModelScope.launch(dispatchers.io) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val layersDir = File(application.filesDir, "map_layers") val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) { if (layersDir.exists() && layersDir.isDirectory) {
@ -419,33 +412,32 @@ class MapViewModel(
if (persistedLayerFiles != null) { if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems = val loadedItems = persistedLayerFiles.mapNotNull { file ->
persistedLayerFiles.mapNotNull { file -> if (file.isFile) {
if (file.isFile) { val layerType =
val layerType = when (file.extension.lowercase()) {
when (file.extension.lowercase()) { "kml",
"kml", "kmz",
"kmz", -> LayerType.KML
-> LayerType.KML "geojson",
"geojson", "json",
"json", -> LayerType.GEOJSON
-> LayerType.GEOJSON else -> null
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
} }
} else {
null layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
} }
} else {
null
} }
}
val networkItems = val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@ -558,7 +550,7 @@ class MapViewModel(
} }
} }
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
try { try {
val inputStream = application.contentResolver.openInputStream(uri) val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers") val directory = File(application.filesDir, "map_layers")
@ -629,7 +621,7 @@ class MapViewModel(
} }
private suspend fun deleteFileToInternalStorage(uri: Uri) { private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(dispatchers.io) { withContext(Dispatchers.IO) {
try { try {
val file = uri.toFile() val file = uri.toFile()
if (file.exists()) { if (file.exists()) {
@ -644,15 +636,11 @@ class MapViewModel(
@Suppress("Recycle") @Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null val uriToLoad = layerItem.uri ?: return null
return withContext(dispatchers.io) { return withContext(Dispatchers.IO) {
try { try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val response = httpClient.get(uriToLoad.toString()) val url = java.net.URL(uriToLoad.toString())
if (!response.status.isSuccess()) { java.io.BufferedInputStream(url.openStream())
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
return@withContext null
}
response.bodyAsChannel().toInputStream()
} else { } else {
application.contentResolver.openInputStream(uriToLoad) application.contentResolver.openInputStream(uriToLoad)
} }

View file

@ -25,13 +25,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@ -62,11 +66,6 @@ import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
@ -120,22 +119,19 @@ fun CustomMapLayersSheet(
} else { } else {
IconButton(onClick = { onRefreshLayer(layer.id) }) { IconButton(onClick = { onRefreshLayer(layer.id) }) {
Icon( Icon(
imageVector = MeshtasticIcons.Refresh, imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(Res.string.refresh), contentDescription = stringResource(Res.string.refresh),
) )
} }
} }
} }
IconToggleButton( IconButton(onClick = { onToggleVisibility(layer.id) }) {
checked = layer.isVisible,
onCheckedChange = { onToggleVisibility(layer.id) },
) {
Icon( Icon(
imageVector = imageVector =
if (layer.isVisible) { if (layer.isVisible) {
MeshtasticIcons.Visibility Icons.Filled.Visibility
} else { } else {
MeshtasticIcons.VisibilityOff Icons.Filled.VisibilityOff
}, },
contentDescription = contentDescription =
stringResource( stringResource(
@ -149,7 +145,7 @@ fun CustomMapLayersSheet(
} }
IconButton(onClick = { onRemoveLayer(layer.id) }) { IconButton(onClick = { onRemoveLayer(layer.id) }) {
Icon( Icon(
imageVector = MeshtasticIcons.Delete, imageVector = Icons.Filled.Delete,
contentDescription = stringResource(Res.string.remove_layer), contentDescription = stringResource(Res.string.remove_layer),
) )
} }

View file

@ -27,6 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -68,9 +71,6 @@ import org.meshtastic.core.resources.url_must_contain_placeholders
import org.meshtastic.core.resources.url_template import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint import org.meshtastic.core.resources.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog 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.core.ui.util.showToast
@Suppress("LongMethod") @Suppress("LongMethod")
@ -155,13 +155,13 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
}, },
) { ) {
Icon( Icon(
MeshtasticIcons.Edit, Icons.Filled.Edit,
contentDescription = stringResource(Res.string.edit_custom_tile_source), contentDescription = stringResource(Res.string.edit_custom_tile_source),
) )
} }
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) { IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
Icon( Icon(
MeshtasticIcons.Delete, Icons.Filled.Delete,
contentDescription = stringResource(Res.string.delete_custom_tile_source), contentDescription = stringResource(Res.string.delete_custom_tile_source),
) )
} }

View file

@ -33,6 +33,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -57,6 +60,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month import kotlinx.datetime.Month
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
@ -78,9 +82,6 @@ import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.emoji.EmojiPickerDialog 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 org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@ -119,12 +120,12 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0 val expireValue = waypointInput.expire ?: 0
if (isExpiryEnabled) { if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) { if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong()) val instant = Instant.fromEpochSeconds(expireValue.toLong())
val date = java.util.Date(instant.toEpochMilliseconds()) val date = java.util.Date(instant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date) selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date) selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now } else { // If enabled but not set, default to 8 hours from now
val futureInstant = kotlin.time.Clock.System.now() + 8.hours val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
val date = java.util.Date(futureInstant.toEpochMilliseconds()) val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date) selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date) selectedTimeString = timeFormat.format(date)
@ -189,7 +190,7 @@ fun EditWaypointDialog(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Image( Image(
imageVector = MeshtasticIcons.Lock, imageVector = Icons.Rounded.Lock,
contentDescription = stringResource(Res.string.locked), contentDescription = stringResource(Res.string.locked),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@ -208,7 +209,7 @@ fun EditWaypointDialog(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Image( Image(
imageVector = MeshtasticIcons.CalendarMonth, imageVector = Icons.Rounded.CalendarMonth,
contentDescription = stringResource(Res.string.expires), contentDescription = stringResource(Res.string.expires),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@ -222,7 +223,7 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0 val expireValue = waypointInput.expire ?: 0
// Default to 8 hours from now if not already set // Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) { if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
val futureInstant = kotlin.time.Clock.System.now() + 8.hours val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
} }
} else { } else {
@ -236,9 +237,9 @@ fun EditWaypointDialog(
val currentInstant = val currentInstant =
(waypointInput.expire ?: 0).let { (waypointInput.expire ?: 0).let {
if (it != 0 && it != Int.MAX_VALUE) { if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong()) Instant.fromEpochSeconds(it.toLong())
} else { } else {
kotlin.time.Clock.System.now() + 8.hours kotlinx.datetime.Clock.System.now() + 8.hours
} }
} }
val ldt = currentInstant.toLocalDateTime(tz) val ldt = currentInstant.toLocalDateTime(tz)
@ -251,9 +252,9 @@ fun EditWaypointDialog(
(waypointInput.expire ?: 0) (waypointInput.expire ?: 0)
.let { .let {
if (it != 0 && it != Int.MAX_VALUE) { if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong()) Instant.fromEpochSeconds(it.toLong())
} else { } else {
kotlin.time.Clock.System.now() + 8.hours kotlinx.datetime.Clock.System.now() + 8.hours
} }
} }
.toLocalDateTime(tz) .toLocalDateTime(tz)
@ -286,9 +287,9 @@ fun EditWaypointDialog(
(waypointInput.expire ?: 0) (waypointInput.expire ?: 0)
.let { .let {
if (it != 0 && it != Int.MAX_VALUE) { if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong()) Instant.fromEpochSeconds(it.toLong())
} else { } else {
kotlin.time.Clock.System.now() + 8.hours kotlinx.datetime.Clock.System.now() + 8.hours
} }
} }
.toLocalDateTime(tz) .toLocalDateTime(tz)

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.map.component package org.meshtastic.app.map.component
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -24,17 +24,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
/**
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
* across both Google and F-Droid flavors.
*/
@Composable @Composable
fun MapButton( fun MapButton(
modifier: Modifier = Modifier,
icon: ImageVector, icon: ImageVector,
iconTint: Color? = null,
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = null,
) { ) {
FilledIconButton(onClick = onClick, modifier = modifier) { FilledIconButton(onClick = onClick, modifier = modifier) {
Icon( Icon(

View file

@ -0,0 +1,159 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.rounded.LocationDisabled
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@Composable
fun MapControlsOverlay(
modifier: Modifier = Modifier,
mapFilterMenuExpanded: Boolean,
onMapFilterMenuDismissRequest: () -> Unit,
onToggleMapFilterMenu: () -> Unit,
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
mapTypeMenuExpanded: Boolean,
onMapTypeMenuDismissRequest: () -> Unit,
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (isNodeMap) {
MapButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
MapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
}
}
Box {
MapButton(
icon = Icons.Outlined.Map,
contentDescription = stringResource(Res.string.map_tile_source),
onClick = onToggleMapTypeMenu,
)
MapTypeDropdown(
expanded = mapTypeMenuExpanded,
onDismissRequest = onMapTypeMenuDismissRequest,
mapViewModel = mapViewModel, // Pass mapViewModel
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
)
}
MapButton(
icon = Icons.Outlined.Layers,
contentDescription = stringResource(Res.string.manage_map_layers),
onClick = onManageLayersClicked,
)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
} else {
MapButton(
icon = Icons.Filled.Refresh,
contentDescription = stringResource(Res.string.refresh),
onClick = onRefresh,
)
}
}
// Location tracking button
MapButton(
icon =
if (isLocationTrackingEnabled) {
Icons.Rounded.LocationDisabled
} else {
Icons.Outlined.MyLocation
},
contentDescription = stringResource(Res.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
}
}
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val icon = if (isFollowing) Icons.Filled.Navigation else Icons.Outlined.Navigation
MapButton(
modifier = Modifier.rotate(-bearing),
icon = icon,
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)
}

View file

@ -18,6 +18,10 @@ package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding 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.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -41,10 +45,6 @@ import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints 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.LastHeardFilter
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -56,10 +56,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
text = { Text(stringResource(Res.string.only_favorites)) }, text = { Text(stringResource(Res.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() }, onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = { leadingIcon = {
Icon( Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(Res.string.only_favorites))
imageVector = MeshtasticIcons.Favorite,
contentDescription = stringResource(Res.string.only_favorites),
)
}, },
trailingIcon = { trailingIcon = {
Checkbox( Checkbox(
@ -72,10 +69,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
text = { Text(stringResource(Res.string.show_waypoints)) }, text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() }, onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = { leadingIcon = {
Icon( Icon(imageVector = Icons.Filled.Place, contentDescription = stringResource(Res.string.show_waypoints))
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
}, },
trailingIcon = { trailingIcon = {
Checkbox( Checkbox(
@ -89,7 +83,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = MeshtasticIcons.Lens, imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
contentDescription = stringResource(Res.string.show_precision_circle), contentDescription = stringResource(Res.string.show_precision_circle),
) )
}, },

View file

@ -16,6 +16,8 @@
*/ */
package org.meshtastic.app.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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -34,8 +36,6 @@ import org.meshtastic.core.resources.map_type_normal
import org.meshtastic.core.resources.map_type_satellite import org.meshtastic.core.resources.map_type_satellite
import org.meshtastic.core.resources.map_type_terrain import org.meshtastic.core.resources.map_type_terrain
import org.meshtastic.core.resources.selected_map_type import org.meshtastic.core.resources.selected_map_type
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
@ -67,12 +67,7 @@ internal fun MapTypeDropdown(
}, },
trailingIcon = trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) { 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 { } else {
null null
}, },
@ -92,7 +87,7 @@ internal fun MapTypeDropdown(
if (selectedCustomUrl == config.urlTemplate) { if (selectedCustomUrl == config.urlTemplate) {
{ {
Icon( Icon(
MeshtasticIcons.Check, Icons.Filled.Check,
contentDescription = stringResource(Res.string.selected_map_type), contentDescription = stringResource(Res.string.selected_map_type),
) )
} }

View file

@ -16,36 +16,30 @@
*/ */
package org.meshtastic.app.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.BitmapDescriptor
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng 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.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch 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.Res
import org.meshtastic.core.resources.locked import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint import org.meshtastic.proto.Waypoint
@OptIn(MapsComposeExperimentalApi::class) private const val DEG_D = 1e-7
@Composable @Composable
fun WaypointMarkers( fun WaypointMarkers(
displayableWaypoints: List<Waypoint>, displayableWaypoints: List<Waypoint>,
mapFilterState: BaseMapViewModel.MapFilterState, mapFilterState: BaseMapViewModel.MapFilterState,
myNodeNum: Int, myNodeNum: Int,
isConnected: Boolean, isConnected: Boolean,
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
onEditWaypointRequest: (Waypoint) -> Unit, onEditWaypointRequest: (Waypoint) -> Unit,
selectedWaypointId: Int? = null, selectedWaypointId: Int? = null,
) { ) {
@ -64,16 +58,14 @@ 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( Marker(
state = markerState, state = markerState,
icon = icon, icon =
if ((waypoint.icon ?: 0) == 0) {
unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
} else {
unicodeEmojiToBitmapProvider(waypoint.icon!!)
},
title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '), title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '),
snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '), snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '),
visible = true, visible = true,

View file

@ -16,14 +16,13 @@
*/ */
package org.meshtastic.app.map.node package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel import org.meshtastic.feature.map.node.NodeMapViewModel
@ -32,6 +31,7 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val destNum = node?.num
Scaffold( Scaffold(
topBar = { topBar = {
@ -46,9 +46,8 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
) )
}, },
) { paddingValues -> ) { paddingValues ->
MapView( Box(modifier = Modifier.padding(paddingValues)) {
modifier = Modifier.fillMaxSize().padding(paddingValues), MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), }
)
} }
} }

View file

@ -1,58 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(
modifier = modifier,
mode =
GoogleMapMode.NodeTrack(
focusedNode = focusedNode,
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
),
)
}

View file

@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module @Module
@ComponentScan("org.meshtastic.app.map") @ComponentScan("org.meshtastic.app.map")
@ -36,10 +36,9 @@ class GoogleMapsKoinModule {
@Single @Single
@Named("GoogleMapsDataStore") @Named("GoogleMapsDataStore")
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> = fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
PreferenceDataStoreFactory.create( migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
scope = CoroutineScope(dispatchers.io + SupervisorJob()), produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, )
)
} }

View file

@ -1,46 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View file

@ -44,14 +44,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Permissions required for providing location (from phone GPS) to mesh --> <!-- Permissions required for providing location (from phone GPS) to mesh -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:remove="android:maxSdkVersion" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:remove="android:maxSdkVersion" />
<!-- This permission is required for analytics - and soon the MQTT gateway --> <!-- This permission is required for analytics - and soon the MQTT gateway -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Required for Android 17+ (API 37) Local Networking for TAK Server localhost loopback -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- <!--
@ -288,7 +285,7 @@
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/widget_local_stats_info" /> android:resource="@xml/local_stats_widget_info" />
</receiver> </receiver>
<!-- allow for plugin discovery --> <!-- allow for plugin discovery -->

View file

@ -1212,7 +1212,7 @@
"Heltec" "Heltec"
], ],
"requiresDfu": true, "requiresDfu": true,
"hasMui": true, "hasMui": false,
"partitionScheme": "16MB", "partitionScheme": "16MB",
"images": [ "images": [
"heltec_v4.svg" "heltec_v4.svg"
@ -1236,28 +1236,12 @@
"rak_3312.svg" "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, "hwModel": 113,
"hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2", "hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2",
"platformioTarget": "heltec-wireless-tracker-v2", "platformioTarget": "heltec-wireless-tracker-v2",
"architecture": "esp32-s3", "architecture": "esp32s3",
"activelySupported": true, "activelySupported": false,
"supportLevel": 1, "supportLevel": 1,
"displayName": "Heltec Wireless Tracker V2", "displayName": "Heltec Wireless Tracker V2",
"tags": [ "tags": [
@ -1322,7 +1306,7 @@
"hwModelSlug": "THINKNODE_M4", "hwModelSlug": "THINKNODE_M4",
"platformioTarget": "thinknode_m4", "platformioTarget": "thinknode_m4",
"architecture": "nrf52840", "architecture": "nrf52840",
"activelySupported": true, "activelySupported": false,
"supportLevel": 1, "supportLevel": 1,
"displayName": "ThinkNode M4", "displayName": "ThinkNode M4",
"tags": [ "tags": [
@ -1338,7 +1322,7 @@
"hwModelSlug": "THINKNODE_M6", "hwModelSlug": "THINKNODE_M6",
"platformioTarget": "thinknode_m6", "platformioTarget": "thinknode_m6",
"architecture": "nrf52840", "architecture": "nrf52840",
"activelySupported": true, "activelySupported": false,
"supportLevel": 1, "supportLevel": 1,
"displayName": "ThinkNode M6", "displayName": "ThinkNode M6",
"tags": [ "tags": [
@ -1380,7 +1364,7 @@
"hasMui": false, "hasMui": false,
"partitionScheme": "8MB", "partitionScheme": "8MB",
"images": [ "images": [
"t5s3_epaper.svg" "t5s3-epaper-pro.svg"
] ]
}, },
{ {

File diff suppressed because one or more lines are too long

View file

@ -45,12 +45,11 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider import org.meshtastic.app.map.getMapViewProvider
@ -58,8 +57,8 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid import org.meshtastic.core.resources.channel_invalid
@ -70,30 +69,18 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel() private val model: UIViewModel by viewModel()
private val usbRepository: UsbRepository by inject()
/** /**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers * 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. * itself as a LifecycleObserver in its init block.
@ -127,8 +114,6 @@ class MainActivity : ComponentActivity() {
setSingletonImageLoaderFactory { get<ImageLoader>() } setSingletonImageLoaderFactory { get<ImageLoader>() }
val theme by model.theme.collectAsStateWithLifecycle() val theme by model.theme.collectAsStateWithLifecycle()
val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
val dynamic = theme == MODE_DYNAMIC val dynamic = theme == MODE_DYNAMIC
val dark = val dark =
when (theme) { when (theme) {
@ -146,7 +131,7 @@ class MainActivity : ComponentActivity() {
} }
AppCompositionLocals { AppCompositionLocals {
AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn" // Signal to the system that the initial UI is "fully drawn"
@ -154,7 +139,7 @@ class MainActivity : ComponentActivity() {
ReportDrawnWhen { true } ReportDrawnWhen { true }
if (appIntroCompleted) { if (appIntroCompleted) {
MainScreen() MainScreen(uIViewModel = model)
} else { } else {
val introViewModel = koinViewModel<IntroViewModel>() val introViewModel = koinViewModel<IntroViewModel>()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel) AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
@ -169,16 +154,6 @@ class MainActivity : ComponentActivity() {
handleIntent(intent) handleIntent(intent)
} }
override fun onResume() {
super.onResume()
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
// resumed while a USB device is already attached (e.g. process restart, returning
// from another app), the manifest-declared attach intent may have already fired
// before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
// reality without requiring the user to physically replug.
usbRepository.refreshState()
}
@Composable @Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) { private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider( CompositionLocalProvider(
@ -189,48 +164,32 @@ class MainActivity : ComponentActivity() {
LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(), LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
org.meshtastic.app.map.node.NodeTrackMap(
destNum,
positions,
modifier,
selectedPositionTime,
onPositionSelected,
)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp -> { destNum, onNavigateUp ->
val vm = koinViewModel<NodeMapViewModel>() val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
vm.setDestNum(destNum) vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
}, },
LocalTracerouteMapScreenProvider provides org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp -> { destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) } val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") {
org.koin.core.parameter.parametersOf(destNum)
}
metricsViewModel.setNodeId(destNum) metricsViewModel.setNodeId(destNum)
TracerouteMapScreen( org.meshtastic.feature.node.metrics.TracerouteMapScreen(
metricsViewModel = metricsViewModel, metricsViewModel = metricsViewModel,
requestId = requestId, requestId = requestId,
logUuid = logUuid, logUuid = logUuid,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
) )
}, },
LocalMapMainScreenProvider provides org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId -> { onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<SharedMapViewModel>() val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
MapScreen( org.meshtastic.feature.map.MapScreen(
viewModel = viewModel, viewModel = viewModel,
onClickNodeChip = onClickNodeChip, onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails, navigateToNodeDetails = navigateToNodeDetails,
@ -270,11 +229,6 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> { UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" } Logger.d { "USB device attached" }
// Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
// receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
// never sees this event. Forward it explicitly so the serialDevices StateFlow
// refreshes and the device shows up in the Connect → Serial tab.
usbRepository.refreshState()
showSettingsPage() showSettingsPage()
} }
@ -296,7 +250,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) { private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" } Logger.d { "Handling Meshtastic URI: $uri" }
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
} }
private fun createShareIntent(message: String): PendingIntent { private fun createShareIntent(message: String): PendingIntent {

View file

@ -28,7 +28,6 @@ import androidx.work.WorkManager
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -37,8 +36,9 @@ import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.plugin.module.dsl.startKoin import org.koin.core.context.startKoin
import org.meshtastic.app.di.AndroidKoinApp import org.meshtastic.app.di.AppKoinModule
import org.meshtastic.app.di.module
import org.meshtastic.core.common.ContextServices import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.repository.MeshPrefs
@ -57,15 +57,16 @@ open class MeshUtilApplication :
Application(), Application(),
Configuration.Provider { Configuration.Provider {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val applicationScope = CoroutineScope(Dispatchers.Default)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ContextServices.app = this ContextServices.app = this
startKoin<AndroidKoinApp> { startKoin {
androidContext(this@MeshUtilApplication) androidContext(this@MeshUtilApplication)
workManagerFactory() workManagerFactory()
modules(AppKoinModule().module())
} }
// Schedule periodic MeshLog cleanup // Schedule periodic MeshLog cleanup

View file

@ -44,7 +44,6 @@ import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
@ -54,7 +53,6 @@ import org.meshtastic.feature.messaging.di.FeatureMessagingModule
import org.meshtastic.feature.node.di.FeatureNodeModule import org.meshtastic.feature.node.di.FeatureNodeModule
import org.meshtastic.feature.settings.di.FeatureSettingsModule import org.meshtastic.feature.settings.di.FeatureSettingsModule
import org.meshtastic.feature.widget.di.FeatureWidgetModule import org.meshtastic.feature.widget.di.FeatureWidgetModule
import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
@Module( @Module(
includes = includes =
@ -78,7 +76,6 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
CoreServiceAndroidModule::class, CoreServiceAndroidModule::class,
CoreNetworkModule::class, CoreNetworkModule::class,
CoreNetworkAndroidModule::class, CoreNetworkAndroidModule::class,
CoreTakServerModule::class,
CoreUiModule::class, CoreUiModule::class,
FeatureNodeModule::class, FeatureNodeModule::class,
FeatureMessagingModule::class, FeatureMessagingModule::class,
@ -88,7 +85,6 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
FeatureFirmwareModule::class, FeatureFirmwareModule::class,
FeatureIntroModule::class, FeatureIntroModule::class,
FeatureWidgetModule::class, FeatureWidgetModule::class,
FeatureWifiProvisionModule::class,
NetworkModule::class, NetworkModule::class,
FlavorModule::class, FlavorModule::class,
], ],

View file

@ -24,8 +24,6 @@ import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.memoryCacheMaxSizePercentWhileInBackground
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
@ -33,25 +31,18 @@ import coil3.util.DebugLogger
import coil3.util.Logger import coil3.util.Logger
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.url
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okio.Path.Companion.toOkioPath import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02 private const val DISK_CACHE_PERCENT = 0.02
private const val MEMORY_CACHE_PERCENT = 0.25 private const val MEMORY_CACHE_PERCENT = 0.25
private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
@Module @Module
class NetworkModule { class NetworkModule {
@ -72,12 +63,7 @@ class NetworkModule {
buildConfigProvider: BuildConfigProvider, buildConfigProvider: BuildConfigProvider,
): ImageLoader = ImageLoader.Builder(context = application) ): ImageLoader = ImageLoader.Builder(context = application)
.components { .components {
add( add(KtorNetworkFetcherFactory(httpClient = httpClient))
KtorNetworkFetcherFactory(
httpClient = httpClient,
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
),
)
add(SvgDecoder.Factory(scaleToDensity = true)) add(SvgDecoder.Factory(scaleToDensity = true))
} }
.memoryCache { .memoryCache {
@ -90,29 +76,21 @@ class NetworkModule {
.build() .build()
} }
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null) .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
.memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
.crossfade(enable = true) .crossfade(enable = true)
.build() .build()
@Single
fun provideJson(): Json = Json {
isLenient = true
ignoreUnknownKeys = true
}
@Single @Single
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) { HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) } install(plugin = ContentNegotiation) { json(json) }
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
install(plugin = HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(plugin = HttpRequestRetry) {
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
exponentialDelay()
}
if (buildConfigProvider.isDebug) { if (buildConfigProvider.isDebug) {
install(plugin = Logging) { install(plugin = Logging) { level = LogLevel.BODY }
logger = KermitHttpLogger
level = LogLevel.BODY
}
} }
} }
} }

View file

@ -19,27 +19,30 @@
package org.meshtastic.app.ui package org.meshtastic.app.ui
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.must_update import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticAppShell import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph
@ -48,39 +51,41 @@ import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable @Composable
fun MainScreen() { fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
val viewModel: UIViewModel = koinViewModel() val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph)
val backStack = multiBackstack.activeBackStack
AndroidAppVersionCheck(viewModel) AndroidAppVersionCheck(uIViewModel)
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { MeshtasticAppShell(
MeshtasticNavigationSuite( backStack = backStack,
multiBackstack = multiBackstack, uiViewModel = uIViewModel,
uiViewModel = viewModel, hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
backStack = backStack,
uiViewModel = uIViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
val provider = val provider =
entryProvider<NavKey> { entryProvider<NavKey> {
contactsGraph(backStack, viewModel.scrollToTopEventFlow) contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
nodesGraph( nodesGraph(
backStack = backStack, backStack = backStack,
scrollToTopEvents = viewModel.scrollToTopEventFlow, scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
onHandleDeepLink = viewModel::handleDeepLink, onHandleDeepLink = uIViewModel::handleDeepLink,
) )
mapGraph(backStack) mapGraph(backStack)
channelsGraph(backStack) channelsGraph(backStack)
connectionsGraph(backStack) connectionsGraph(backStack)
settingsGraph(backStack) settingsGraph(backStack)
firmwareGraph(backStack) firmwareGraph(backStack)
wifiProvisionGraph(backStack)
} }
MeshtasticNavDisplay( NavDisplay(
multiBackstack = multiBackstack, backStack = backStack,
entryProvider = provider, entryProvider = provider,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
) )
@ -94,6 +99,7 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
// Check if the device is running an old app version
LaunchedEffect(connectionState, myNodeInfo) { LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) { if (connectionState == ConnectionState.Connected) {
myNodeInfo?.let { info -> myNodeInfo?.let { info ->

View file

@ -25,14 +25,13 @@ import androidx.work.WorkerParameters
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.HttpClientEngine
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import org.koin.plugin.module.dsl.koinApplication import org.junit.Test
import org.koin.test.verify.definition import org.koin.test.verify.definition
import org.koin.test.verify.injectedParameters import org.koin.test.verify.injectedParameters
import org.koin.test.verify.verify import org.koin.test.verify.verify
import org.meshtastic.app.map.MapViewModel import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.feature.node.metrics.MetricsViewModel import org.meshtastic.feature.node.metrics.MetricsViewModel
import kotlin.test.Test
class KoinVerificationTest { class KoinVerificationTest {
@ -61,19 +60,4 @@ class KoinVerificationTest {
), ),
) )
} }
@Test
fun verifyTypedBootstrapLoadsModuleGraph() {
// koinApplication<T>() is a K2 compiler plugin stub. If the plugin fails to
// transform it, the stub throws NotImplementedError at runtime. This test
// validates that the production bootstrap path is correctly transformed by
// successfully creating and closing the generated Koin application.
val app = koinApplication<AndroidKoinApp>()
try {
// No-op: reaching this point proves the typed bootstrap path did not
// throw and the generated application could be created.
} finally {
app.close()
}
}
} }

View file

@ -16,6 +16,7 @@
*/ */
package org.meshtastic.app.service package org.meshtastic.app.service
import android.app.Notification
import dev.mokkery.MockMode import dev.mokkery.MockMode
import dev.mokkery.mock import dev.mokkery.mock
import org.meshtastic.core.model.Node import org.meshtastic.core.model.Node
@ -36,7 +37,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun updateServiceStateNotification( override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState, state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?, telemetry: Telemetry?,
) {} ): Notification = mock(MockMode.autofill)
override suspend fun updateMessageNotification( override suspend fun updateMessageNotification(
contactKey: String, contactKey: String,

View file

@ -16,15 +16,15 @@
*/ */
package org.meshtastic.app.ui package org.meshtastic.app.ui
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.runComposeUiTest
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.map.navigation.mapGraph
@ -35,15 +35,16 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) @Config(sdk = [34])
class NavigationAssemblyTest { class NavigationAssemblyTest {
@get:Rule val composeTestRule = createComposeRule()
@Test @Test
fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest { fun verifyNavigationGraphsAssembleWithoutCrashing() {
setContent { composeTestRule.setContent {
val backStack = rememberNavBackStack(NodesRoute.NodesGraph) val backStack = rememberNavBackStack(NodesRoutes.NodesGraph)
entryProvider<NavKey> { entryProvider<NavKey> {
contactsGraph(backStack, emptyFlow()) contactsGraph(backStack, emptyFlow())
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow()) nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())

View file

@ -16,9 +16,9 @@
*/ */
package org.meshtastic.app.ui package org.meshtastic.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.model.util.getInitials import org.meshtastic.core.model.util.getInitials
import kotlin.test.Test
import kotlin.test.assertEquals
class UIUnitTest { class UIUnitTest {
@Test @Test

View file

@ -16,12 +16,11 @@
*/ */
package org.meshtastic.app.ui.metrics package org.meshtastic.app.ui.metrics
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Telemetry
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertTrue
class EnvironmentMetricsTest { class EnvironmentMetricsTest {
@ -66,12 +65,11 @@ class EnvironmentMetricsTest {
val resultTelemetry = processedTelemetries.first() val resultTelemetry = processedTelemetries.first()
assertTrue( assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environment_metrics?.temperature ?: 0f, 0.01f)
abs(expectedTemperatureFahrenheit - (resultTelemetry.environment_metrics?.temperature ?: 0f)) < 0.01f, assertEquals(
) expectedSoilTemperatureFahrenheit,
assertTrue( resultTelemetry.environment_metrics?.soil_temperature ?: 0f,
abs(expectedSoilTemperatureFahrenheit - (resultTelemetry.environment_metrics?.soil_temperature ?: 0f)) < 0.01f,
0.01f,
) )
} }
} }

View file

@ -25,14 +25,14 @@ plugins {
group = "org.meshtastic.buildlogic" group = "org.meshtastic.buildlogic"
// Configure the build-logic plugins to target JDK 21 // Configure the build-logic plugins to target JDK 17
// This improves compatibility for developers building the project or consuming its libraries. // This improves compatibility for developers building the project or consuming its libraries.
java { java {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_17
} }
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
dependencies { dependencies {
// This allows the use of the 'libs' type-safe accessor in the Kotlin source of the plugins // This allows the use of the 'libs' type-safe accessor in the Kotlin source of the plugins
@ -54,6 +54,7 @@ dependencies {
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.androidx.room.gradlePlugin) compileOnly(libs.androidx.room.gradlePlugin)
compileOnly(libs.secrets.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.test.retry.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin)

View file

@ -17,18 +17,15 @@
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.datadog.gradle.plugin.DdExtension import com.datadog.gradle.plugin.DdExtension
import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask import com.datadog.gradle.plugin.InstrumentationMode
import com.datadog.gradle.plugin.SdkCheckLevel import com.datadog.gradle.plugin.SdkCheckLevel
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
import org.meshtastic.buildlogic.libs import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin import org.meshtastic.buildlogic.plugin
import java.io.File
/** /**
* Convention plugin for analytics (Google Services, Crashlytics, Datadog). Segregates these plugins to only affect the * Convention plugin for analytics (Google Services, Crashlytics, Datadog). Segregates these plugins to only affect the
@ -68,38 +65,18 @@ class AnalyticsConventionPlugin : Plugin<Project> {
} }
} }
// Disable Datadog analytics/upload tasks for fdroid, but NOT the buildId
// inject/generate tasks. The Datadog plugin wires InjectBuildIdToAssetsTask via
// variant.artifacts.toTransform(SingleArtifact.ASSETS), which replaces the merged
// assets artifact for the entire variant. Disabling that task leaves its output
// directory empty, causing compressAssets to produce zero files and stripping ALL
// assets (including Compose Multiplatform .cvr resources) from the release APK.
plugins.withId("com.datadoghq.dd-sdk-android-gradle-plugin") { plugins.withId("com.datadoghq.dd-sdk-android-gradle-plugin") {
tasks.configureEach { tasks.configureEach {
if ( if (
( (
name.contains("datadog", ignoreCase = true) || name.contains("datadog", ignoreCase = true) ||
name.contains("uploadMapping", ignoreCase = true) name.contains("uploadMapping", ignoreCase = true) ||
name.contains("buildId", ignoreCase = true)
) && name.contains("fdroid", ignoreCase = true) ) && name.contains("fdroid", ignoreCase = true)
) { ) {
enabled = false enabled = false
} }
} }
// The inject task must stay enabled to maintain the AGP artifact pipeline,
// but we strip the datadog.buildId file from its output to preserve fdroid
// sterility — no analytics artifacts should ship in the open-source flavor.
tasks.withType<InjectBuildIdToAssetsTask>().configureEach {
if (name.contains("Fdroid", ignoreCase = true)) {
doLast {
// Constant: GenerateBuildIdTask.BUILD_ID_FILE_NAME
val buildIdFile = File(outputAssets.get().asFile, "datadog.buildId")
if (buildIdFile.exists()) {
buildIdFile.delete()
}
}
}
}
} }
// Configure variant-specific extensions. // Configure variant-specific extensions.
@ -110,7 +87,7 @@ class AnalyticsConventionPlugin : Plugin<Project> {
variants { variants {
register(variant.name) { register(variant.name) {
site = "US5" site = "US5"
composeInstrumentation = InstrumentationMode.AUTO
} }
} }
checkProjectDependencies = SdkCheckLevel.NONE checkProjectDependencies = SdkCheckLevel.NONE

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025-2026 Meshtastic LLC * Copyright (c) 2025 Meshtastic LLC
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -14,6 +14,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
@ -25,6 +26,7 @@ import org.meshtastic.buildlogic.configureTestOptions
class AndroidApplicationConventionPlugin : Plugin<Project> { class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
apply(plugin = "com.android.application") apply(plugin = "com.android.application")
apply(plugin = "org.gradle.test-retry") apply(plugin = "org.gradle.test-retry")
apply(plugin = "meshtastic.android.lint") apply(plugin = "meshtastic.android.lint")
@ -37,7 +39,12 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig { vectorDrawables.useSupportLibrary = true } defaultConfig {
testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
vectorDrawables.useSupportLibrary = true
}
testOptions.animationsDisabled = true
buildTypes { buildTypes {
getByName("release") { getByName("release") {
@ -45,8 +52,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
rootProject.file("config/proguard/shared-rules.pro"), "proguard-rules.pro"
"proguard-rules.pro",
) )
} }
getByName("debug") { getByName("debug") {
@ -58,7 +64,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
} }
} }
buildFeatures { buildConfig = true } buildFeatures {
buildConfig = true
}
} }
configureTestOptions() configureTestOptions()
} }

Some files were not shown because too many files have changed in this diff Show more