mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Compare commits
No commits in common. "main" and "v2.7.14-internal.35" have entirely different histories.
main
...
v2.7.14-in
1203 changed files with 25535 additions and 45184 deletions
|
|
@ -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/
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"context": {
|
||||
"fileName": ["AGENTS.md", "GEMINI.md"]
|
||||
}
|
||||
}
|
||||
40
.github/actions/gradle-setup/action.yml
vendored
40
.github/actions/gradle-setup/action.yml
vendored
|
|
@ -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
|
||||
52
.github/ci-gradle.properties
vendored
52
.github/ci-gradle.properties
vendored
|
|
@ -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
|
||||
27
.github/copilot-commit-message-instructions.md
vendored
27
.github/copilot-commit-message-instructions.md
vendored
|
|
@ -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>
|
||||
151
.github/copilot-instructions.md
vendored
151
.github/copilot-instructions.md
vendored
|
|
@ -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`.
|
||||
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
|
||||
For execution-focused recipes, see `docs/agent-playbooks/README.md`.
|
||||
|
||||
## 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`).
|
||||
18
.github/copilot-pull-request-instructions.md
vendored
18
.github/copilot-pull-request-instructions.md
vendored
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
10
.github/instructions/build-logic.instructions.md
vendored
10
.github/instructions/build-logic.instructions.md
vendored
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
20
.github/instructions/kmp-common.instructions.md
vendored
20
.github/instructions/kmp-common.instructions.md
vendored
|
|
@ -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
35
.github/labeler.yml
vendored
Normal 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
12
.github/lsp.json
vendored
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"lspServers": {
|
||||
"kotlin": {
|
||||
"command": "kotlin-language-server",
|
||||
"args": [],
|
||||
"fileExtensions": {
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
.github/renovate.json
vendored
223
.github/renovate.json
vendored
|
|
@ -49,31 +49,236 @@
|
|||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Meshtastic Protobufs changelog link",
|
||||
"matchPackageNames": [
|
||||
"https://github.com/meshtastic/protobufs.git"
|
||||
],
|
||||
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
|
||||
"groupName": "Meshtastic Protobufs",
|
||||
"groupSlug": "meshtastic-protobufs",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
|
||||
"groupName": "compose-multiplatform",
|
||||
"description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
|
||||
"groupName": "AndroidX (General)",
|
||||
"groupSlug": "androidx-general",
|
||||
"matchPackageNames": [
|
||||
"/^org\\.jetbrains\\.compose/",
|
||||
"androidx.compose.runtime:runtime-tracing",
|
||||
"androidx.compose.ui:ui-test-manifest"
|
||||
"/^androidx\\./",
|
||||
"!/^androidx\\.room/",
|
||||
"!/^androidx\\.lifecycle/",
|
||||
"!/^androidx\\.navigation/",
|
||||
"!/^androidx\\.datastore/",
|
||||
"!/^androidx\\.compose\\.material3\\.adaptive/",
|
||||
"!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
|
||||
"!/^androidx\\.test\\.espresso/",
|
||||
"!/^androidx\\.test\\.ext/",
|
||||
"!/^androidx\\.compose\\.ui:ui-test-junit4$/",
|
||||
"!/^androidx\\.hilt/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Restrict sensitive infrastructure to manual minor updates",
|
||||
"description": "Group Kotlin standard library, coroutines, and serialization",
|
||||
"groupName": "Kotlin Ecosystem",
|
||||
"groupSlug": "kotlin",
|
||||
"matchPackageNames": [
|
||||
"/^org\\.jetbrains\\.kotlin/",
|
||||
"/^org\\.jetbrains\\.kotlinx/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group Dagger and Hilt dependencies",
|
||||
"groupName": "Dagger & Hilt",
|
||||
"groupSlug": "hilt",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.google\\.dagger/",
|
||||
"/^androidx\\.hilt/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group Accompanist libraries",
|
||||
"groupName": "Accompanist",
|
||||
"groupSlug": "accompanist",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.google\\.accompanist/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
|
||||
"groupName": "JVM Testing Libraries",
|
||||
"groupSlug": "jvm-testing",
|
||||
"matchPackageNames": [
|
||||
"/^junit:junit$/",
|
||||
"/^org\\.mockito:/",
|
||||
"/^org\\.robolectric:robolectric$/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX Testing libraries",
|
||||
"groupName": "AndroidX Testing",
|
||||
"groupSlug": "androidx-testing",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.test\\.espresso/",
|
||||
"/^androidx\\.test\\.ext/",
|
||||
"/^androidx\\.compose\\.ui:ui-test-junit4$/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Static Analysis tools (Detekt, Spotless)",
|
||||
"groupName": "Static Analysis",
|
||||
"groupSlug": "static-analysis",
|
||||
"matchPackageNames": [
|
||||
"/^io\\.gitlab\\.arturbosch\\.detekt/",
|
||||
"/^io\\.nlopez\\.compose\\.rules/",
|
||||
"/^com\\.diffplug\\.spotless/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Square networking libraries (OkHttp, Retrofit)",
|
||||
"groupName": "Square Networking",
|
||||
"groupSlug": "square-network",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.squareup\\.okhttp3/",
|
||||
"/^com\\.squareup\\.retrofit2/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Coil image loading library",
|
||||
"groupName": "Coil",
|
||||
"groupSlug": "coil",
|
||||
"matchPackageNames": [
|
||||
"/^io\\.coil-kt\\.coil3/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group ZXing barcode scanning libraries",
|
||||
"groupName": "ZXing",
|
||||
"groupSlug": "zxing",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.journeyapps:zxing-android-embedded/",
|
||||
"/^com\\.google\\.zxing:core/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Eclipse Paho MQTT client libraries",
|
||||
"groupName": "MQTT Paho Client",
|
||||
"groupSlug": "mqtt-paho",
|
||||
"matchPackageNames": [
|
||||
"/^org\\.eclipse\\.paho/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Mike Penz Markdown renderer libraries",
|
||||
"groupName": "Markdown Renderer (Mike Penz)",
|
||||
"groupSlug": "markdown-renderer-mikepenz",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.mikepenz/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Firebase libraries",
|
||||
"groupName": "Firebase",
|
||||
"groupSlug": "firebase",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.google\\.firebase/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Datadog libraries",
|
||||
"groupName": "Datadog",
|
||||
"groupSlug": "datadog",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.datadoghq/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group OpenStreetMap (OSM) libraries",
|
||||
"groupName": "OSM Libraries",
|
||||
"groupSlug": "osm-libraries",
|
||||
"matchPackageNames": [
|
||||
"/^org\\.osmdroid/",
|
||||
"/^com\\.github\\.MKergall\\.osmbonuspack/",
|
||||
"/^mil\\.nga/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Google Maps Compose libraries",
|
||||
"groupName": "Google Maps Compose",
|
||||
"groupSlug": "google-maps-compose",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.google\\.android\\.gms:play-services-location/",
|
||||
"/^com\\.google\\.maps\\.android/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group Google Protobuf runtime libraries",
|
||||
"groupName": "Protobuf Runtime",
|
||||
"groupSlug": "protobuf-runtime",
|
||||
"matchPackageNames": [
|
||||
"/^com\\.google\\.protobuf/",
|
||||
"!https://github.com/meshtastic/protobufs.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX Room libraries",
|
||||
"groupName": "AndroidX Room",
|
||||
"groupSlug": "androidx-room",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.room/"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX Lifecycle libraries",
|
||||
"groupName": "AndroidX Lifecycle",
|
||||
"groupSlug": "androidx-lifecycle",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.lifecycle/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX Navigation libraries",
|
||||
"groupName": "AndroidX Navigation",
|
||||
"groupSlug": "androidx-navigation",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.navigation/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX DataStore libraries",
|
||||
"groupName": "AndroidX DataStore",
|
||||
"groupSlug": "androidx-datastore",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.datastore/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group AndroidX Adaptive UI libraries",
|
||||
"groupName": "AndroidX Adaptive UI",
|
||||
"groupSlug": "androidx-adaptive-ui",
|
||||
"matchPackageNames": [
|
||||
"/^androidx\\.compose\\.material3\\.adaptive/",
|
||||
"/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
|
||||
"matchUpdateTypes": [
|
||||
"minor"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"/^org\\.jetbrains\\.kotlin/",
|
||||
"/^org\\.jetbrains\\.kotlinx/",
|
||||
"/^org\\.jetbrains\\.compose/",
|
||||
"/^com\\.google\\.dagger/",
|
||||
"/^androidx\\.hilt/",
|
||||
"/^com\\.google\\.protobuf/",
|
||||
|
|
@ -93,4 +298,4 @@
|
|||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
108
.github/workflows/codeql.yml
vendored
Normal file
108
.github/workflows/codeql.yml
vendored
Normal 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}}"
|
||||
10
.github/workflows/create-or-promote-release.yml
vendored
10
.github/workflows/create-or-promote-release.yml
vendored
|
|
@ -20,11 +20,6 @@ on:
|
|||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
build_desktop:
|
||||
description: 'Whether to build the desktop distribution'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -34,7 +29,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
determine-tags:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
|
||||
release_name: ${{ steps.calculate_tags.outputs.release_name }}
|
||||
|
|
@ -129,7 +124,6 @@ jobs:
|
|||
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
|
||||
channel: ${{ inputs.channel }}
|
||||
base_version: ${{ inputs.base_version }}
|
||||
build_desktop: ${{ inputs.build_desktop }}
|
||||
secrets: inherit
|
||||
|
||||
call-promote-workflow:
|
||||
|
|
@ -148,7 +142,7 @@ jobs:
|
|||
cleanup-on-failure:
|
||||
needs: [determine-tags, call-release-workflow]
|
||||
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
|
|
|||
4
.github/workflows/dependency-submission.yml
vendored
4
.github/workflows/dependency-submission.yml
vendored
|
|
@ -10,7 +10,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
dependency-submission:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
|
||||
steps:
|
||||
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version: 17
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Generate and submit dependency graph
|
||||
|
|
|
|||
33
.github/workflows/docs.yml
vendored
33
.github/workflows/docs.yml
vendored
|
|
@ -6,16 +6,6 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
# Only rebuild docs when source code changes (Dokka generates from KDoc)
|
||||
- 'app/src/**'
|
||||
- 'core/**/src/**'
|
||||
- 'feature/**/src/**'
|
||||
- 'desktop/src/**'
|
||||
- 'build-logic/**'
|
||||
- 'build.gradle.kts'
|
||||
- 'settings.gradle.kts'
|
||||
- '.github/workflows/docs.yml'
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
|
@ -39,16 +29,16 @@ permissions:
|
|||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment; cancel queued runs since only the latest
|
||||
# main state matters for documentation.
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -57,16 +47,21 @@ jobs:
|
|||
submodules: 'recursive'
|
||||
ref: ${{ inputs.ref || '' }}
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
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
|
||||
run: ./gradlew dokkaGeneratePublicationHtml
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: build/dokka/html
|
||||
|
||||
|
|
@ -75,7 +70,7 @@ jobs:
|
|||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-docs
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
|
|
|
|||
5
.github/workflows/main-check.yml
vendored
5
.github/workflows/main-check.yml
vendored
|
|
@ -20,7 +20,8 @@ jobs:
|
|||
uses: ./.github/workflows/reusable-check.yml
|
||||
with:
|
||||
run_lint: true
|
||||
run_unit_tests: false
|
||||
run_desktop_builds: false
|
||||
run_unit_tests: true
|
||||
run_instrumented_tests: true
|
||||
api_levels: '[35]' # One API level is enough for post-merge sanity check
|
||||
upload_artifacts: true
|
||||
secrets: inherit
|
||||
|
|
|
|||
6
.github/workflows/main-push-changelog.yml
vendored
6
.github/workflows/main-push-changelog.yml
vendored
|
|
@ -16,7 +16,7 @@ concurrency:
|
|||
jobs:
|
||||
main-push-changelog:
|
||||
name: Generate main push changelog
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -39,10 +39,6 @@ jobs:
|
|||
fromTag: ${{ steps.last_prod_tag.outputs.tag }}
|
||||
toTag: ${{ github.sha }}
|
||||
outputFile: main-push-changelog.md
|
||||
fetchViaCommits: true
|
||||
fetchReviewers: false
|
||||
fetchReleaseInformation: false
|
||||
fetchReviews: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
4
.github/workflows/merge-queue.yml
vendored
4
.github/workflows/merge-queue.yml
vendored
|
|
@ -18,12 +18,14 @@ jobs:
|
|||
with:
|
||||
run_lint: true
|
||||
run_unit_tests: true
|
||||
run_instrumented_tests: true
|
||||
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
|
||||
upload_artifacts: false
|
||||
secrets: inherit
|
||||
|
||||
check-workflow-status:
|
||||
name: Check Workflow Status
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
needs:
|
||||
- android-check
|
||||
|
|
|
|||
36
.github/workflows/models_issue_triage.yml
vendored
36
.github/workflows/models_issue_triage.yml
vendored
|
|
@ -14,8 +14,8 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
triage:
|
||||
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
if: ${{ github.repository == 'meshtastic/firmware' && github.event.issue.user.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
|
||||
- name: Apply quality label if needed
|
||||
if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
|
||||
with:
|
||||
|
|
@ -80,7 +80,7 @@ jobs:
|
|||
# ─────────────────────────────────────────────────────────────────────────
|
||||
- name: Determine if completeness check should be skipped
|
||||
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
id: check-skip
|
||||
with:
|
||||
script: |
|
||||
|
|
@ -98,20 +98,20 @@ jobs:
|
|||
continue-on-error: true
|
||||
with:
|
||||
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:
|
||||
- Open the Meshtastic app, go to Settings > Debug > Save Logs
|
||||
- Reproduce the problem, then share/attach the exported log file
|
||||
Web Flasher logs:
|
||||
- Go to https://flasher.meshtastic.org
|
||||
- Connect the device via USB and click Connect
|
||||
- Open the device console/log output, reproduce the problem, then copy/download and attach/paste the logs
|
||||
|
||||
Android logcat (if app logs are insufficient):
|
||||
- Connect phone via USB with USB debugging enabled
|
||||
- Run: adb logcat -s Meshtastic:* *:E
|
||||
- Reproduce the problem, then copy/paste the relevant output
|
||||
Meshtastic CLI logs:
|
||||
- Run: meshtastic --port <serial-port> --noproto
|
||||
- Reproduce the problem, then copy/paste the terminal 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:
|
||||
{
|
||||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
"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 "none" if the issue is complete or is a feature request.
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ jobs:
|
|||
|
||||
- name: Process analysis result
|
||||
if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != ''
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
id: process
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.analysis.outputs.response }}
|
||||
|
|
@ -165,7 +165,7 @@ jobs:
|
|||
|
||||
- name: Apply triage label
|
||||
if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
LABEL_NAME: ${{ steps.process.outputs.label }}
|
||||
with:
|
||||
|
|
@ -191,7 +191,7 @@ jobs:
|
|||
|
||||
- name: Comment on issue
|
||||
if: steps.process.outputs.should_comment == 'true'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
COMMENT_BODY: ${{ steps.process.outputs.comment_body }}
|
||||
with:
|
||||
|
|
|
|||
40
.github/workflows/models_pr_triage.yml
vendored
40
.github/workflows/models_pr_triage.yml
vendored
|
|
@ -15,19 +15,19 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
triage:
|
||||
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
if: ${{ github.repository == 'meshtastic/firmware' && github.event.pull_request.user.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Check if PR already has automation/type labels (skip if so)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
- name: Check existing labels
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
id: check-labels
|
||||
with:
|
||||
script: |
|
||||
const skipLabels = new Set(['automation', 'release']);
|
||||
const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']);
|
||||
const skipLabels = new Set(['automation']);
|
||||
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 shouldSkipAll = prLabels.some(l => skipLabels.has(l));
|
||||
|
|
@ -44,16 +44,13 @@ jobs:
|
|||
uses: actions/ai-inference@v2
|
||||
id: quality
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
with:
|
||||
max-tokens: 20
|
||||
prompt: |
|
||||
Is this GitHub pull request spam, AI-generated slop, or low quality?
|
||||
|
||||
Title: ${{ env.PR_TITLE }}
|
||||
Body: ${{ env.PR_BODY }}
|
||||
Title: ${{ github.event.pull_request.title }}
|
||||
Body: ${{ github.event.pull_request.body }}
|
||||
|
||||
Respond with exactly one of: spam, ai-generated, needs-review, ok
|
||||
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
|
||||
|
|
@ -61,7 +58,7 @@ jobs:
|
|||
|
||||
- name: Apply quality label if needed
|
||||
if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
id: quality-label
|
||||
env:
|
||||
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
|
||||
|
|
@ -90,35 +87,32 @@ jobs:
|
|||
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
|
||||
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '')
|
||||
uses: actions/ai-inference@v2
|
||||
id: classify
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
with:
|
||||
max-tokens: 30
|
||||
prompt: |
|
||||
Classify this pull request 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 enhancement if it adds a new feature, improves performance, or adds new functionality.
|
||||
Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture.
|
||||
Use hardware-support if it adds or improves support for a specific hardware device/variant.
|
||||
Use enhancement if it adds a new feature, improves performance, or refactors code.
|
||||
|
||||
Title: ${{ env.PR_TITLE }}
|
||||
Body: ${{ env.PR_BODY }}
|
||||
Title: ${{ github.event.pull_request.title }}
|
||||
Body: ${{ github.event.pull_request.body }}
|
||||
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
|
||||
model: openai/gpt-4o-mini
|
||||
|
||||
- name: Apply type label
|
||||
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != ''
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TYPE_LABEL: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
|
|
@ -126,8 +120,8 @@ jobs:
|
|||
const label = (process.env.TYPE_LABEL || '').trim().toLowerCase();
|
||||
const labelMeta = {
|
||||
'bugfix': { color: 'd73a4a', description: 'Bug fix' },
|
||||
'hardware-support': { color: '0e8a16', description: 'Hardware support addition or improvement' },
|
||||
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
|
||||
'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
|
||||
};
|
||||
const meta = labelMeta[label];
|
||||
if (!meta) return;
|
||||
|
|
|
|||
3
.github/workflows/moderate.yml
vendored
3
.github/workflows/moderate.yml
vendored
|
|
@ -9,8 +9,7 @@ on:
|
|||
|
||||
jobs:
|
||||
spam-detection:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
|
|
|||
2
.github/workflows/post-release-cleanup.yml
vendored
2
.github/workflows/post-release-cleanup.yml
vendored
|
|
@ -18,7 +18,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
cleanup_prereleases:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
environment: Release
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
|
|||
29
.github/workflows/pr_enforce_labels.yml
vendored
29
.github/workflows/pr_enforce_labels.yml
vendored
|
|
@ -4,34 +4,29 @@ on:
|
|||
pull_request:
|
||||
types: [edited, labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-label:
|
||||
# Skip bot PRs — they already have labels from the workflows/bots that create them
|
||||
if: >-
|
||||
github.event.pull_request.user.login != 'renovate[bot]' &&
|
||||
github.event.pull_request.user.login != 'github-actions[bot]' &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]' &&
|
||||
github.event.pull_request.head.ref != 'scheduled-updates' &&
|
||||
github.event.pull_request.head.ref != 'l10n_main'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
check-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for PR labels
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
// Extract labels from the payload directly to avoid extra API calls
|
||||
const latestLabels = context.payload.pull_request.labels.map(label => label.name);
|
||||
// Always fetch the latest labels from the GitHub API to avoid stale context
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const latestLabels = pr.labels.map(label => label.name);
|
||||
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor'];
|
||||
console.log('Labels from payload:', latestLabels);
|
||||
const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label));
|
||||
console.log('Latest labels:', latestLabels);
|
||||
if (!hasRequiredLabel) {
|
||||
core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`);
|
||||
}
|
||||
|
|
|
|||
9
.github/workflows/promote.yml
vendored
9
.github/workflows/promote.yml
vendored
|
|
@ -65,9 +65,9 @@ permissions:
|
|||
|
||||
jobs:
|
||||
prepare-build-info:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -102,7 +102,7 @@ jobs:
|
|||
shell: bash
|
||||
|
||||
promote-release:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
environment: Release
|
||||
needs: [ prepare-build-info ]
|
||||
steps:
|
||||
|
|
@ -116,7 +116,7 @@ jobs:
|
|||
user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }}
|
||||
|
||||
update-github-release:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ prepare-build-info, promote-release ]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -139,7 +139,6 @@ jobs:
|
|||
gh release edit ${{ inputs.tag_name }} \
|
||||
--tag ${{ inputs.final_tag }} \
|
||||
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
|
||||
--draft=false \
|
||||
--prerelease=${{ inputs.channel != 'production' }}
|
||||
|
||||
- name: Notify Discord
|
||||
|
|
|
|||
17
.github/workflows/publish-core.yml
vendored
17
.github/workflows/publish-core.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
|||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
|
@ -23,10 +23,19 @@ jobs:
|
|||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
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
|
||||
id: version
|
||||
|
|
|
|||
62
.github/workflows/pull-request-target.yml
vendored
62
.github/workflows/pull-request-target.yml
vendored
|
|
@ -1,67 +1,15 @@
|
|||
name: "Pull Request Labeler"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
# Do not execute arbitrary code on this workflow.
|
||||
- pull_request_target
|
||||
# Do not execute arbitary code on this workflow.
|
||||
# See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const branch = context.payload.pull_request.head.ref;
|
||||
const labels = new Set();
|
||||
|
||||
// enhancement: branch contains feat
|
||||
if (/feat/i.test(branch)) labels.add('enhancement');
|
||||
|
||||
// bugfix: branch starts with fix or bug
|
||||
if (/^(fix|bug)/i.test(branch)) labels.add('bugfix');
|
||||
|
||||
// refactor: branch starts with refactor
|
||||
if (/^refactor/i.test(branch)) labels.add('refactor');
|
||||
|
||||
// repo: branch contains repo or ci
|
||||
if (/repo|ci/i.test(branch)) {
|
||||
labels.add('repo');
|
||||
} else {
|
||||
// Also label 'repo' if .github files were changed (needs one API call)
|
||||
try {
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 },
|
||||
(res) => res.data.map(f => f.filename)
|
||||
);
|
||||
if (files.some(f => f.startsWith('.github/'))) labels.add('repo');
|
||||
} catch (e) {
|
||||
core.warning(`Could not list PR files (rate limited?): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.size > 0) {
|
||||
const labelArray = [...labels];
|
||||
core.info(`Applying labels: ${labelArray.join(', ')}`);
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: labelArray,
|
||||
});
|
||||
} catch (e) {
|
||||
core.warning(`Could not apply labels (rate limited?): ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
core.info('No labels matched for this PR.');
|
||||
}
|
||||
- id: label-the-PR
|
||||
uses: actions/labeler@v6
|
||||
21
.github/workflows/pull-request.yml
vendored
21
.github/workflows/pull-request.yml
vendored
|
|
@ -3,6 +3,10 @@ name: Pull Request CI
|
|||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- '.gitignore'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -15,7 +19,7 @@ jobs:
|
|||
# 1. CHANGE DETECTION: Prevents unnecessary builds
|
||||
check-changes:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
|
|
@ -35,6 +39,7 @@ jobs:
|
|||
- 'desktop/**'
|
||||
- 'core/**'
|
||||
- 'feature/**'
|
||||
- 'mesh_service_example/**'
|
||||
# Shared build infrastructure
|
||||
- 'build-logic/**'
|
||||
- 'config/**'
|
||||
|
|
@ -52,7 +57,7 @@ jobs:
|
|||
# 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
|
||||
verify-check-changes-filter:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Verify module roots are represented in check-changes filter
|
||||
|
|
@ -94,25 +99,23 @@ jobs:
|
|||
PY
|
||||
|
||||
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
|
||||
# We disable coverage and desktop builds for PRs to keep feedback fast
|
||||
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
|
||||
# task in the shard-app test shard.
|
||||
# We disable instrumented tests for PRs to keep feedback fast (< 10 mins).
|
||||
validate-and-build:
|
||||
needs: check-changes
|
||||
needs: [check-changes, verify-check-changes-filter]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
uses: ./.github/workflows/reusable-check.yml
|
||||
with:
|
||||
run_lint: true
|
||||
run_unit_tests: true
|
||||
run_coverage: false
|
||||
run_desktop_builds: false
|
||||
run_instrumented_tests: false
|
||||
api_levels: '[35]'
|
||||
upload_artifacts: true
|
||||
secrets: inherit
|
||||
|
||||
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
|
||||
check-workflow-status:
|
||||
name: Check Workflow Status
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
needs: [check-changes, verify-check-changes-filter, validate-and-build]
|
||||
if: always()
|
||||
|
|
|
|||
78
.github/workflows/release.yml
vendored
78
.github/workflows/release.yml
vendored
|
|
@ -19,11 +19,6 @@ on:
|
|||
description: 'The channel to create a release for or promote to'
|
||||
required: true
|
||||
type: string
|
||||
build_desktop:
|
||||
description: 'Whether to build the desktop distribution'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
GSERVICES:
|
||||
required: true
|
||||
|
|
@ -66,9 +61,9 @@ permissions:
|
|||
|
||||
jobs:
|
||||
prepare-build-info:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
env:
|
||||
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
|
||||
|
|
@ -106,7 +101,7 @@ jobs:
|
|||
shell: bash
|
||||
|
||||
release-google:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare-build-info]
|
||||
environment: Release
|
||||
env:
|
||||
|
|
@ -120,12 +115,19 @@ jobs:
|
|||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: 'false'
|
||||
java-version: '17'
|
||||
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
|
||||
env:
|
||||
|
|
@ -191,7 +193,7 @@ jobs:
|
|||
subject-path: app/build/outputs/apk/google/release/*.apk
|
||||
|
||||
release-fdroid:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare-build-info]
|
||||
environment: Release
|
||||
env:
|
||||
|
|
@ -205,12 +207,19 @@ jobs:
|
|||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: 'false'
|
||||
java-version: '17'
|
||||
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
|
||||
env:
|
||||
|
|
@ -251,14 +260,13 @@ jobs:
|
|||
subject-path: app/build/outputs/apk/fdroid/release/*.apk
|
||||
|
||||
release-desktop:
|
||||
if: ${{ inputs.build_desktop }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [prepare-build-info]
|
||||
environment: Release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
|
||||
os: [macos-latest, windows-latest, ubuntu-22.04, ubuntu-22.04-arm]
|
||||
env:
|
||||
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
|
||||
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
|
||||
|
|
@ -270,12 +278,21 @@ jobs:
|
|||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: 'false'
|
||||
java-version: '17'
|
||||
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
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -285,7 +302,7 @@ jobs:
|
|||
env:
|
||||
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon
|
||||
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS --no-daemon
|
||||
|
||||
- name: List Desktop Binaries
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -307,8 +324,7 @@ jobs:
|
|||
if-no-files-found: ignore
|
||||
|
||||
github-release:
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
|
||||
env:
|
||||
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
|
||||
|
|
@ -328,7 +344,7 @@ jobs:
|
|||
path: ./artifacts
|
||||
|
||||
- name: Create or Update GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.tag_name }}
|
||||
target_commitish: ${{ inputs.commit_sha || github.sha }}
|
||||
|
|
@ -341,7 +357,7 @@ jobs:
|
|||
- name: Create or Update internal GitHub Release
|
||||
continue-on-error: true
|
||||
if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
|
||||
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
|
||||
|
|
|
|||
368
.github/workflows/reusable-check.yml
vendored
368
.github/workflows/reusable-check.yml
vendored
|
|
@ -9,12 +9,12 @@ on:
|
|||
run_unit_tests:
|
||||
type: boolean
|
||||
default: true
|
||||
run_coverage:
|
||||
type: boolean
|
||||
default: true
|
||||
run_desktop_builds:
|
||||
run_instrumented_tests:
|
||||
type: boolean
|
||||
default: true
|
||||
api_levels:
|
||||
type: string
|
||||
default: '[35]'
|
||||
upload_artifacts:
|
||||
type: boolean
|
||||
default: true
|
||||
|
|
@ -44,272 +44,218 @@ env:
|
|||
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
|
||||
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
|
||||
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
|
||||
# Fallback VERSION_CODE for the lint-check job itself (which computes the real
|
||||
# value from git). Downstream jobs override this with the git-derived value.
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
|
||||
jobs:
|
||||
# ── Lint & Static Analysis ──────────────────────────────────────────
|
||||
lint-check:
|
||||
runs-on: ubuntu-24.04
|
||||
host-check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
|
||||
version_code: ${{ steps.version_code.outputs.version_code }}
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: 'blob:none'
|
||||
submodules: true
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Determine cache read-only setting
|
||||
id: cache_config
|
||||
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: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v6
|
||||
|
||||
- name: Calculate version code from git commit count
|
||||
id: version_code
|
||||
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
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
|
||||
java-version: '17'
|
||||
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
|
||||
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)
|
||||
if: inputs.run_lint == false
|
||||
run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan
|
||||
- name: Android Lint
|
||||
if: inputs.run_lint == true
|
||||
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 ──────────────────────────────────────────────
|
||||
# Tests are split into 3 shards that run in parallel:
|
||||
# shard-core: core:* KMP module tests (allTests)
|
||||
# shard-feature: feature:* KMP module tests (allTests)
|
||||
# shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
|
||||
test-shards:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 45
|
||||
needs: lint-check
|
||||
if: inputs.run_unit_tests == true
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- name: shard-core
|
||||
tasks: >-
|
||||
:core:ble:allTests
|
||||
:core:common:allTests
|
||||
:core:data:allTests
|
||||
:core:database:allTests
|
||||
:core:domain:allTests
|
||||
:core:model:allTests
|
||||
:core:navigation:allTests
|
||||
:core:network:allTests
|
||||
:core:prefs:allTests
|
||||
:core:repository:allTests
|
||||
:core:service:allTests
|
||||
:core:takserver:allTests
|
||||
:core:testing:allTests
|
||||
:core:ui:allTests
|
||||
kover: >-
|
||||
:core:ble:koverXmlReport
|
||||
:core:common:koverXmlReport
|
||||
:core:data:koverXmlReport
|
||||
:core:database:koverXmlReport
|
||||
:core:domain:koverXmlReport
|
||||
:core:model:koverXmlReport
|
||||
:core:navigation:koverXmlReport
|
||||
:core:network:koverXmlReport
|
||||
:core:prefs:koverXmlReport
|
||||
:core:repository:koverXmlReport
|
||||
:core:service:koverXmlReport
|
||||
:core:takserver:koverXmlReport
|
||||
:core:testing:koverXmlReport
|
||||
:core:ui:koverXmlReport
|
||||
- name: shard-feature
|
||||
tasks: >-
|
||||
:feature:connections:allTests
|
||||
:feature:firmware:allTests
|
||||
:feature:intro:allTests
|
||||
:feature:map:allTests
|
||||
:feature:messaging:allTests
|
||||
:feature:node:allTests
|
||||
:feature:settings:allTests
|
||||
kover: >-
|
||||
:feature:connections:koverXmlReport
|
||||
:feature:firmware:koverXmlReport
|
||||
:feature:intro:koverXmlReport
|
||||
:feature:map:koverXmlReport
|
||||
:feature:messaging:koverXmlReport
|
||||
:feature:node:koverXmlReport
|
||||
:feature:settings:koverXmlReport
|
||||
- name: shard-app
|
||||
tasks: >-
|
||||
:app:testFdroidDebugUnitTest
|
||||
:app:testGoogleDebugUnitTest
|
||||
:desktop:test
|
||||
:core:barcode:testFdroidDebugUnitTest
|
||||
:core:barcode:testGoogleDebugUnitTest
|
||||
kover: >-
|
||||
:app:koverXmlReportFdroidDebug
|
||||
:app:koverXmlReportGoogleDebug
|
||||
:core:barcode:koverXmlReportFdroidDebug
|
||||
:core:barcode:koverXmlReportGoogleDebug
|
||||
:desktop:koverXmlReport
|
||||
- name: Shared Unit Tests & Coverage
|
||||
if: inputs.run_unit_tests == true
|
||||
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
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
- name: KMP Smoke Compile
|
||||
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
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
||||
|
||||
- name: Run Tests & Coverage (${{ matrix.shard.name }})
|
||||
run: |
|
||||
kover_tasks=""
|
||||
if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
|
||||
kover_tasks="${{ matrix.shard.kover }}"
|
||||
fi
|
||||
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v6
|
||||
- name: Upload coverage results to Codecov
|
||||
if: ${{ !cancelled() && inputs.run_unit_tests }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
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
|
||||
report_type: test_results
|
||||
files: "**/build/test-results/**/*.xml"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ !cancelled() && inputs.run_coverage }}
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: meshtastic/Meshtastic-Android
|
||||
flags: ${{ matrix.shard.name }}
|
||||
fail_ci_if_error: false
|
||||
files: "**/build/reports/kover/report*.xml"
|
||||
|
||||
- name: Upload shard reports
|
||||
- name: Upload host reports
|
||||
if: ${{ always() && inputs.upload_artifacts }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: reports-${{ matrix.shard.name }}
|
||||
name: reports-host
|
||||
path: |
|
||||
**/build/reports
|
||||
**/build/test-results
|
||||
retention-days: 7
|
||||
|
||||
# ── Android Build ────────────────────────────────────────────────────
|
||||
android-check:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
needs: lint-check
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
api_level: ${{ fromJson(inputs.api_levels) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Build Android APKs
|
||||
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
|
||||
- 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: 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
|
||||
if: ${{ inputs.upload_artifacts }}
|
||||
if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: app-debug-apks
|
||||
path: app/build/outputs/apk/*/debug/*.apk
|
||||
retention-days: 7
|
||||
retention-days: 14
|
||||
|
||||
- name: Report App Size
|
||||
if: always()
|
||||
if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
|
||||
run: |
|
||||
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
|
||||
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Desktop Build ───────────────────────────────────────────────────
|
||||
build-desktop:
|
||||
name: Build Desktop Debug (${{ matrix.os }})
|
||||
if: inputs.run_desktop_builds == true
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
needs: lint-check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
||||
|
||||
- name: Build Desktop
|
||||
run: ./gradlew :desktop:createDistributable -Pci=true --scan
|
||||
|
||||
- name: Upload Desktop artifact
|
||||
if: ${{ inputs.upload_artifacts }}
|
||||
- name: Upload Android reports
|
||||
if: ${{ always() && inputs.upload_artifacts }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: desktop-app-${{ runner.os }}-${{ runner.arch }}
|
||||
path: desktop/build/compose/binaries/main/app/
|
||||
name: reports-android-api-${{ matrix.api_level }}
|
||||
path: |
|
||||
**/build/outputs/androidTest-results
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
|
|
|||
26
.github/workflows/scheduled-updates.yml
vendored
26
.github/workflows/scheduled-updates.yml
vendored
|
|
@ -2,12 +2,12 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
|
|||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
- cron: '0 * * * *' # Run every hour
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
update_assets:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
permissions:
|
||||
contents: write # To commit files and push branches
|
||||
|
|
@ -81,11 +81,21 @@ jobs:
|
|||
- name: Fix file permissions
|
||||
run: sudo chown -R $USER:$USER .
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache_read_only: 'false'
|
||||
java-version: '17'
|
||||
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
|
||||
run: ./gradlew graphUpdate
|
||||
|
|
@ -132,7 +142,7 @@ jobs:
|
|||
|
||||
check-workflow-status:
|
||||
name: Check Workflow Status
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
needs:
|
||||
- update_assets
|
||||
|
|
|
|||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
|
|
@ -12,7 +12,7 @@ permissions:
|
|||
jobs:
|
||||
stale_issues:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
|
||||
steps:
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
uses: actions/stale@v10.2.0
|
||||
with:
|
||||
days-before-stale: 30
|
||||
stale-issue-message: This issue 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-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
|
||||
operations-per-run: 100
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -53,6 +53,3 @@ wireless-install.sh
|
|||
.worktrees/
|
||||
/firebase-debug.log.jdk/
|
||||
firebase-debug.log
|
||||
.agent_plans/
|
||||
.agent_refs/
|
||||
.agent_artifacts/
|
||||
|
|
|
|||
295
.pr5167.diff
295
.pr5167.diff
|
|
@ -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}" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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/`
|
||||
|
|
@ -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`
|
||||
|
|
@ -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>`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
239
AGENTS.md
|
|
@ -1,108 +1,151 @@
|
|||
# Meshtastic Android - Unified Agent & Developer Guide
|
||||
# Meshtastic Android - Agent Guide
|
||||
|
||||
<role>
|
||||
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>
|
||||
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.
|
||||
|
||||
<context_and_memory>
|
||||
- **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>
|
||||
For execution-focused recipes, see `docs/agent-playbooks/README.md`.
|
||||
|
||||
<process>
|
||||
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
|
||||
1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
|
||||
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
|
||||
3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
|
||||
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
|
||||
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
|
||||
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
|
||||
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
|
||||
```
|
||||
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
|
||||
```
|
||||
> **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
|
||||
> For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
|
||||
</process>
|
||||
## 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.
|
||||
|
||||
<agent_tools>
|
||||
- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search.
|
||||
- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt.
|
||||
- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11.
|
||||
- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended:
|
||||
- `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs)
|
||||
- `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x)
|
||||
- `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI)
|
||||
- `https://github.com/JuulLabs/kable` (BLE)
|
||||
- `https://github.com/coil-kt/coil` (Coil 3 KMP)
|
||||
- `https://github.com/ktorio/ktor` (Ktor Networking)
|
||||
- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing.
|
||||
</agent_tools>
|
||||
- **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.
|
||||
|
||||
<documentation_sync>
|
||||
`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here:
|
||||
- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`.
|
||||
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
|
||||
- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
|
||||
## 2. Codebase Map
|
||||
|
||||
Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
|
||||
</documentation_sync>
|
||||
| Directory | Description |
|
||||
| :--- | :--- |
|
||||
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
|
||||
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
|
||||
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
|
||||
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
|
||||
| `core/model` | Domain models and common data structures. |
|
||||
| `core:proto` | Protobuf definitions (Git submodule). |
|
||||
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
|
||||
| `core:database` | Room KMP database implementation. |
|
||||
| `core:datastore` | Multiplatform DataStore for preferences. |
|
||||
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
|
||||
| `core:domain` | Pure KMP business logic and UseCases. |
|
||||
| `core:data` | Core manager implementations and data orchestration. |
|
||||
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
|
||||
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `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>
|
||||
- **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>
|
||||
## 3. Development Guidelines & Coding Standards
|
||||
|
||||
<copilot_cli_workflow>
|
||||
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
|
||||
section.
|
||||
### A. UI Development (Jetpack Compose)
|
||||
- **Material 3:** The app uses Material 3.
|
||||
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
||||
- **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"
|
||||
prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*,
|
||||
*"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub
|
||||
cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive
|
||||
session on work that can run unattended.
|
||||
- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"*
|
||||
on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep
|
||||
research across GitHub and the web with better source grounding than an ad-hoc prompt.
|
||||
- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation
|
||||
plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to
|
||||
plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent
|
||||
from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to
|
||||
`.agent_plans/` (git-ignored) for multi-module refactors.
|
||||
- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle
|
||||
quality passes, offer `/share` to export the findings to a gist or markdown file. These
|
||||
reports are valuable artifacts — don't let them die in session history.
|
||||
- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts
|
||||
file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration.
|
||||
Avoid re-issuing the same prompt verbatim.
|
||||
- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main"
|
||||
or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe.
|
||||
</copilot_cli_workflow>
|
||||
### 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`.
|
||||
- **`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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
<git_and_prs>
|
||||
- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
|
||||
- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
|
||||
- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
|
||||
</git_and_prs>
|
||||
### 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`).
|
||||
|
|
@ -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.
|
||||
|
|
@ -48,7 +48,7 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
|
|||
|
||||
- **Unit tests** are located in the `src/test/` directory of each module.
|
||||
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
|
||||
- Note: If using Java 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).
|
||||
|
||||
#### Guidelines for Testing
|
||||
|
|
|
|||
149
GEMINI.md
149
GEMINI.md
|
|
@ -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`.
|
||||
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
|
||||
For execution-focused recipes, see `docs/agent-playbooks/README.md`.
|
||||
|
||||
## 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`).
|
||||
46
Gemfile.lock
46
Gemfile.lock
|
|
@ -3,13 +3,13 @@ GEM
|
|||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1240.0)
|
||||
aws-sdk-core (3.245.0)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
|
@ -17,11 +17,11 @@ GEM
|
|||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.219.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
|
|
@ -29,7 +29,7 @@ GEM
|
|||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
|
|
@ -68,11 +68,11 @@ GEM
|
|||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.233.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
|
|
@ -92,7 +92,7 @@ GEM
|
|||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
|
|
@ -122,9 +122,10 @@ GEM
|
|||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.99.0)
|
||||
google-apis-androidpublisher_v3 (0.95.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
|
|
@ -138,15 +139,15 @@ GEM
|
|||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-storage_v1 (0.59.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
|
|
@ -168,13 +169,13 @@ GEM
|
|||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.4)
|
||||
json (2.18.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.20.1)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
|
|
@ -184,13 +185,13 @@ GEM
|
|||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
|
|
@ -204,6 +205,7 @@ GEM
|
|||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
|
|
|||
|
|
@ -51,10 +51,10 @@ You can generate the documentation locally to preview your changes.
|
|||
|
||||
1. **Run the Dokka task:**
|
||||
```bash
|
||||
./gradlew dokkaGeneratePublicationHtml
|
||||
./gradlew :app:dokkaHtml
|
||||
```
|
||||
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
|
||||
|
||||
|
|
@ -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).
|
||||
|
||||
Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
|
||||
|
||||
## Building the Android App
|
||||
> [!WARNING]
|
||||
> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use.
|
||||
|
|
|
|||
31
SOUL.md
Normal file
31
SOUL.md
Normal 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`.
|
||||
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ The `:app` module is the entry point for the Meshtastic Android application. It
|
|||
## Key Components
|
||||
|
||||
### 1. `MainActivity` & `Main.kt`
|
||||
The single Activity of the application. It hosts the 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`
|
||||
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:ui
|
||||
:app -.-> :core:barcode
|
||||
:app -.-> :core:takserver
|
||||
:app -.-> :feature:intro
|
||||
:app -.-> :feature:messaging
|
||||
:app -.-> :feature:connections
|
||||
|
|
@ -50,7 +49,6 @@ graph TB
|
|||
:app -.-> :feature:node
|
||||
:app -.-> :feature:settings
|
||||
:app -.-> :feature:firmware
|
||||
:app -.-> :feature:wifi-provision
|
||||
:app -.-> :feature:widget
|
||||
|
||||
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ configure<ApplicationExtension> {
|
|||
includeInBundle = false
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner = "org.meshtastic.app.TestRunner"
|
||||
}
|
||||
|
||||
// Configure existing product flavors (defined by convention plugin)
|
||||
|
|
@ -171,6 +171,8 @@ configure<ApplicationExtension> {
|
|||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isDebuggable = false
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +219,6 @@ dependencies {
|
|||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.nfc)
|
||||
implementation(projects.core.prefs)
|
||||
|
|
@ -226,7 +227,6 @@ dependencies {
|
|||
implementation(projects.core.resources)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.barcode)
|
||||
implementation(projects.core.takserver)
|
||||
implementation(projects.feature.intro)
|
||||
implementation(projects.feature.messaging)
|
||||
implementation(projects.feature.connections)
|
||||
|
|
@ -234,17 +234,16 @@ dependencies {
|
|||
implementation(projects.feature.node)
|
||||
implementation(projects.feature.settings)
|
||||
implementation(projects.feature.firmware)
|
||||
implementation(projects.feature.wifiProvision)
|
||||
implementation(projects.feature.widget)
|
||||
|
||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||
implementation(libs.material)
|
||||
implementation(libs.compose.multiplatform.animation)
|
||||
implementation(libs.compose.multiplatform.material3)
|
||||
implementation(libs.compose.multiplatform.ui.tooling.preview)
|
||||
implementation(libs.compose.multiplatform.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.appwidget.preview)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
|
@ -252,6 +251,7 @@ dependencies {
|
|||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||
implementation(libs.jetbrains.lifecycle.runtime.compose)
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
|
@ -264,6 +264,7 @@ dependencies {
|
|||
implementation(libs.usb.serial.android)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.androidx.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.koin.androidx.workmanager)
|
||||
implementation(libs.koin.annotations)
|
||||
|
|
@ -279,10 +280,9 @@ dependencies {
|
|||
googleImplementation(libs.maps.compose)
|
||||
googleImplementation(libs.maps.compose.utils)
|
||||
googleImplementation(libs.maps.compose.widgets)
|
||||
googleImplementation(libs.dd.sdk.android.compose)
|
||||
googleImplementation(libs.dd.sdk.android.logs)
|
||||
googleImplementation(libs.dd.sdk.android.rum)
|
||||
googleImplementation(libs.dd.sdk.android.session.replay)
|
||||
googleImplementation(libs.dd.sdk.android.session.replay.material)
|
||||
googleImplementation(libs.dd.sdk.android.timber)
|
||||
googleImplementation(libs.dd.sdk.android.trace)
|
||||
googleImplementation(libs.dd.sdk.android.trace.otel)
|
||||
|
|
@ -294,29 +294,34 @@ dependencies {
|
|||
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
|
||||
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.koin.test)
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.multiplatform.ui.test)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(libs.androidx.glance.appwidget)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// Run offline by default to avoid burning GitHub API calls on every build.
|
||||
// Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info.
|
||||
val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false)
|
||||
// Fetch full license text + funding info from GitHub API when on CI with a token
|
||||
val isCi =
|
||||
providers
|
||||
.gradleProperty("ci")
|
||||
.map { it.toBoolean() }
|
||||
.getOrElse(providers.environmentVariable("CI").map { it.toBoolean() }.getOrElse(false))
|
||||
val ghToken = providers.environmentVariable("GITHUB_TOKEN")
|
||||
|
||||
offlineMode = !isReleaseBuild
|
||||
|
||||
collect {
|
||||
fetchRemoteLicense = isReleaseBuild && ghToken.isPresent
|
||||
fetchRemoteFunding = isReleaseBuild && ghToken.isPresent
|
||||
fetchRemoteLicense = isCi && ghToken.isPresent
|
||||
fetchRemoteFunding = isCi && ghToken.isPresent
|
||||
if (ghToken.isPresent) {
|
||||
gitHubApiToken = ghToken.get()
|
||||
}
|
||||
|
|
|
|||
72
app/proguard-rules.pro
vendored
72
app/proguard-rules.pro
vendored
|
|
@ -1,45 +1,49 @@
|
|||
# ============================================================================
|
||||
# Meshtastic Android — ProGuard / R8 rules for release minification
|
||||
# ============================================================================
|
||||
# Open-source project: obfuscation and optimization are disabled. We rely on
|
||||
# tree-shaking (unused code removal) for APK size reduction.
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room,
|
||||
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3,
|
||||
# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
|
||||
# config/proguard/shared-rules.pro and are wired in by the
|
||||
# AndroidApplicationConventionPlugin. This file holds only Android-specific
|
||||
# rules and R8-only directives.
|
||||
# ============================================================================
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# ---- General ----------------------------------------------------------------
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Open-source — no need to obfuscate
|
||||
-dontobfuscate
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Disable R8 optimization passes. Tree-shaking (unused code removal) still
|
||||
# runs — only method-body rewrites and call-site transformations are suppressed.
|
||||
#
|
||||
# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on
|
||||
# Composer.<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
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Dump the full merged R8 configuration (app rules + all library consumer rules)
|
||||
# for auditing. Inspect this file after a release build to see what libraries inject.
|
||||
-printconfiguration build/outputs/mapping/r8-merged-config.txt
|
||||
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
|
||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||
|
||||
# ---- Networking (transitive references from Ktor on Android) ----------------
|
||||
# Needed for protobufs
|
||||
-keep class com.google.protobuf.** { *; }
|
||||
-keep class org.meshtastic.proto.** { *; }
|
||||
|
||||
# Networking
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
|
||||
# Compose runtime/ui/animation/foundation/material3 keep rules now live in
|
||||
# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
|
||||
# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.
|
||||
# ?
|
||||
-dontwarn java.lang.reflect.**
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
# Our app is opensource no need to obsfucate
|
||||
-dontobfuscate
|
||||
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
|
||||
|
||||
# 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.** { *; }
|
||||
|
|
|
|||
22
app/src/androidTest/kotlin/org/meshtastic/app/TestRunner.kt
Normal file
22
app/src/androidTest/kotlin/org/meshtastic/app/TestRunner.kt
Normal 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()
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -23,17 +23,32 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
/** OSMDroid implementation of [MapViewProvider]. */
|
||||
@Single
|
||||
class FdroidMapViewProvider : MapViewProvider {
|
||||
@Composable
|
||||
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
|
||||
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()
|
||||
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
org.meshtastic.app.map.MapView(
|
||||
modifier = modifier,
|
||||
mapViewModel = mapViewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
focusedNodeNum = focusedNodeNum,
|
||||
nodeTracks = nodeTracks as? List<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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
package org.meshtastic.app.map
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Paint
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
|
|
@ -30,17 +32,24 @@ import androidx.compose.foundation.layout.wrapContentHeight
|
|||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.MyLocation
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Lens
|
||||
import androidx.compose.material.icons.rounded.LocationDisabled
|
||||
import androidx.compose.material.icons.rounded.PinDrop
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -49,7 +58,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -57,6 +65,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -77,6 +87,7 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
|
|||
import org.meshtastic.app.map.component.CacheLayout
|
||||
import org.meshtastic.app.map.component.DownloadButton
|
||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||
import org.meshtastic.app.map.component.MapButton
|
||||
import org.meshtastic.app.map.model.CustomTileSource
|
||||
import org.meshtastic.app.map.model.MarkerWithLabel
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
|
|
@ -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.expires
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.last_heard_filter_label
|
||||
import org.meshtastic.core.resources.location_disabled
|
||||
import org.meshtastic.core.resources.map_cache_info
|
||||
import org.meshtastic.core.resources.map_cache_manager
|
||||
|
|
@ -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_errors
|
||||
import org.meshtastic.core.resources.map_download_region
|
||||
import org.meshtastic.core.resources.map_filter
|
||||
import org.meshtastic.core.resources.map_node_popup_details
|
||||
import org.meshtastic.core.resources.map_offline_manager
|
||||
import org.meshtastic.core.resources.map_purge_fail
|
||||
|
|
@ -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_tile_source
|
||||
import org.meshtastic.core.resources.only_favorites
|
||||
import org.meshtastic.core.resources.position
|
||||
import org.meshtastic.core.resources.show_precision_circle
|
||||
import org.meshtastic.core.resources.show_waypoints
|
||||
import org.meshtastic.core.resources.toggle_my_position
|
||||
import org.meshtastic.core.resources.waypoint_delete
|
||||
import org.meshtastic.core.resources.you
|
||||
import org.meshtastic.core.ui.component.BasicListItem
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.icon.Check
|
||||
import org.meshtastic.core.ui.icon.Favorite
|
||||
import org.meshtastic.core.ui.icon.Layers
|
||||
import org.meshtastic.core.ui.icon.Lens
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PinDrop
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.formatAgo
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.map.tracerouteNodeSelection
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
|
||||
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.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
|
||||
import java.io.File
|
||||
import kotlin.math.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(
|
||||
nodeMarkers: List<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
trackMarkers: List<Marker>,
|
||||
trackPolylines: List<Polyline>,
|
||||
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 ->
|
||||
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(trackPolylines)
|
||||
overlays.addAll(trackMarkers)
|
||||
|
||||
nodeClusterer.items.clear()
|
||||
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.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
|
||||
@Composable
|
||||
fun MapView(
|
||||
modifier: Modifier = Modifier,
|
||||
mapViewModel: MapViewModel = koinViewModel(),
|
||||
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) }
|
||||
|
||||
|
|
@ -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 waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
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) }
|
||||
|
||||
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
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
|
||||
return nodesWithPosition.mapNotNull { node ->
|
||||
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (
|
||||
mapFilterStateValue.lastHeardFilter.seconds != 0L &&
|
||||
(nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
|
||||
node.num != ourNode?.num
|
||||
mapFilterStateValue.onlyFavorites &&
|
||||
!node.isFavorite &&
|
||||
!overlayNodeNums.contains(node.num) &&
|
||||
!node.equals(ourNode)
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
|
@ -507,6 +580,53 @@ fun MapView(
|
|||
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() {
|
||||
overlays.removeAll { it is Polygon }
|
||||
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(
|
||||
modifier = modifier,
|
||||
floatingActionButton = {
|
||||
|
|
@ -585,10 +748,14 @@ fun MapView(
|
|||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { mapView ->
|
||||
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
|
||||
val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
|
||||
with(mapView) {
|
||||
updateMarkers(
|
||||
onNodesChanged(nodes),
|
||||
onNodesChanged(nodesForMarkers),
|
||||
onWaypointChanged(waypoints.values, selectedWaypointId),
|
||||
trackMarkers,
|
||||
trackPolylines,
|
||||
nodeClusterer,
|
||||
)
|
||||
}
|
||||
|
|
@ -607,34 +774,122 @@ fun MapView(
|
|||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
onToggleFilterMenu = { mapFilterExpanded = true },
|
||||
filterDropdownContent = {
|
||||
FdroidMainMapFilterDropdown(
|
||||
@Suppress("MagicNumber")
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
MapButton(
|
||||
onClick = { showMapStyleDialog = true },
|
||||
icon = Icons.Outlined.Layers,
|
||||
contentDescription = Res.string.map_style_selection,
|
||||
)
|
||||
Box(modifier = Modifier) {
|
||||
MapButton(
|
||||
onClick = { mapFilterExpanded = true },
|
||||
icon = Icons.Outlined.Tune,
|
||||
contentDescription = stringResource(Res.string.map_filter),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = mapFilterExpanded,
|
||||
onDismissRequest = { mapFilterExpanded = false },
|
||||
mapFilterState = mapFilterState,
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
},
|
||||
mapTypeContent = {
|
||||
MapButton(
|
||||
icon = MeshtasticIcons.Layers,
|
||||
contentDescription = stringResource(Res.string.map_style_selection),
|
||||
onClick = { showMapStyleDialog = true },
|
||||
)
|
||||
},
|
||||
isLocationTrackingEnabled = myLocationOverlay != null,
|
||||
onToggleLocationTracking = {
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.only_favorites),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PinDrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.show_waypoints),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Lens,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.show_precision_circle),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@Suppress("MagicNumber")
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
}
|
||||
}
|
||||
MapButton(
|
||||
icon =
|
||||
if (myLocationOverlay == null) {
|
||||
Icons.Outlined.MyLocation
|
||||
} else {
|
||||
Icons.Rounded.LocationDisabled
|
||||
},
|
||||
contentDescription = stringResource(Res.string.toggle_my_position),
|
||||
) {
|
||||
if (locationPermissionsState.allPermissionsGranted) {
|
||||
map.toggleMyLocation()
|
||||
} else {
|
||||
triggerLocationToggleAfterPermission = true
|
||||
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
|
||||
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
|
||||
val selected = remember { mutableStateOf(selectedMapStyle) }
|
||||
|
|
@ -818,7 +976,7 @@ private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelec
|
|||
CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
|
||||
ListItem(
|
||||
text = style,
|
||||
trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null,
|
||||
trailingIcon = if (index == selected.value) Icons.Rounded.Check else null,
|
||||
onClick = {
|
||||
selected.value = index
|
||||
onSelectMapStyle(index)
|
||||
|
|
@ -861,9 +1019,15 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
|
|||
onDismiss = onDismiss,
|
||||
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
|
||||
) {
|
||||
val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
|
||||
val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
|
||||
Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text =
|
||||
stringResource(
|
||||
Res.string.map_cache_info,
|
||||
cacheCapacity / (1024.0 * 1024.0),
|
||||
currentCacheUsage / (1024.0 * 1024.0),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,21 +124,20 @@ fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: ()
|
|||
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 markers =
|
||||
positions.map { pos ->
|
||||
Marker(this).apply {
|
||||
icon = navIcon
|
||||
rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onClick(pos.time)
|
||||
true
|
||||
}
|
||||
val markers = positions.map {
|
||||
Marker(this).apply {
|
||||
icon = navIcon
|
||||
rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7)
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
overlays.addAll(markers)
|
||||
|
||||
return markers
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -29,6 +32,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
|
|
@ -37,6 +41,29 @@ import org.osmdroid.util.GeoPoint
|
|||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
private fun PowerManager.WakeLock.safeAcquire() {
|
||||
if (!isHeld) {
|
||||
try {
|
||||
acquire()
|
||||
} catch (e: SecurityException) {
|
||||
Logger.e { "WakeLock permission exception: ${e.message}" }
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e { "WakeLock acquire() exception: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PowerManager.WakeLock.safeRelease() {
|
||||
if (isHeld) {
|
||||
try {
|
||||
release()
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e { "WakeLock release() exception: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_ZOOM_LEVEL = 1.5
|
||||
private const val MAX_ZOOM_LEVEL = 20.0
|
||||
private const val DEFAULT_ZOOM_LEVEL = 15.0
|
||||
|
|
@ -109,13 +136,22 @@ internal fun rememberMapViewWithLifecycle(
|
|||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
|
||||
|
||||
wakeLock.safeAcquire()
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
wakeLock.safeRelease()
|
||||
mapView.onPause()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
wakeLock.safeAcquire()
|
||||
mapView.onResume()
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +166,10 @@ internal fun rememberMapViewWithLifecycle(
|
|||
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
wakeLock.safeRelease()
|
||||
}
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -30,8 +32,6 @@ import androidx.compose.ui.draw.scale
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.map_download_region
|
||||
import org.meshtastic.core.ui.icon.Download
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
@Composable
|
||||
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
|
|
@ -50,7 +50,7 @@ fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
|
|||
) {
|
||||
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Download,
|
||||
imageVector = Icons.Rounded.Download,
|
||||
contentDescription = stringResource(Res.string.map_download_region),
|
||||
modifier = Modifier.scale(1.25f),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -78,9 +81,6 @@ import org.meshtastic.core.resources.waypoint_edit
|
|||
import org.meshtastic.core.resources.waypoint_new
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.icon.CalendarMonth
|
||||
import org.meshtastic.core.ui.icon.Lock
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
|
@ -198,10 +198,7 @@ fun EditWaypointDialog(
|
|||
modifier = Modifier.fillMaxWidth().size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.Lock,
|
||||
contentDescription = stringResource(Res.string.locked),
|
||||
)
|
||||
Image(imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.locked))
|
||||
Text(stringResource(Res.string.locked))
|
||||
Switch(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
|
||||
|
|
@ -258,7 +255,7 @@ fun EditWaypointDialog(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.CalendarMonth,
|
||||
imageVector = Icons.Rounded.CalendarMonth,
|
||||
contentDescription = stringResource(Res.string.expires),
|
||||
)
|
||||
Text(stringResource(Res.string.expires))
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -17,38 +17,48 @@
|
|||
package org.meshtastic.app.map.node
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.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.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
private const val DEG_D = 1e-7
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
|
||||
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = node?.user?.long_name ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
NodeTrackOsmMap(
|
||||
positions = positions,
|
||||
val density = LocalDensity.current
|
||||
val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
|
||||
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
|
||||
val mapView =
|
||||
rememberMapViewWithLifecycle(
|
||||
applicationId = nodeMapViewModel.applicationId,
|
||||
mapStyleId = nodeMapViewModel.mapStyleId,
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
box = cameraView,
|
||||
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) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -33,11 +33,7 @@ import com.datadog.android.log.LogsConfiguration
|
|||
import com.datadog.android.privacy.TrackingConsent
|
||||
import com.datadog.android.rum.GlobalRumMonitor
|
||||
import com.datadog.android.rum.Rum
|
||||
import com.datadog.android.rum.RumActionType
|
||||
import com.datadog.android.rum.RumConfiguration
|
||||
import com.datadog.android.sessionreplay.SessionReplay
|
||||
import com.datadog.android.sessionreplay.SessionReplayConfiguration
|
||||
import com.datadog.android.sessionreplay.TextAndInputPrivacy
|
||||
import com.datadog.android.trace.Trace
|
||||
import com.datadog.android.trace.TraceConfiguration
|
||||
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
|
||||
|
|
@ -72,7 +68,7 @@ import co.touchlab.kermit.Logger as KermitLogger
|
|||
class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) :
|
||||
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 isFirebaseInitialized = false
|
||||
|
|
@ -141,7 +137,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
|||
val configuration =
|
||||
Configuration.Builder(
|
||||
clientToken = BuildConfig.datadogClientToken,
|
||||
env = if (BuildConfig.DEBUG) "Local" else "Production",
|
||||
env = if (BuildConfig.DEBUG) "debug" else "release",
|
||||
variant = BuildConfig.FLAVOR,
|
||||
)
|
||||
.useSite(DatadogSite.US5)
|
||||
|
|
@ -155,7 +151,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
|||
val rumConfiguration =
|
||||
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
|
||||
.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
|
||||
.trackLongTasks()
|
||||
.trackNonFatalAnrs(true)
|
||||
|
|
@ -166,19 +162,9 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
|||
val logsConfig = LogsConfiguration.Builder().build()
|
||||
Logs.enable(logsConfig)
|
||||
|
||||
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build()
|
||||
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
@ -247,24 +233,6 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
|||
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
|
||||
get() =
|
||||
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {
|
||||
|
|
|
|||
|
|
@ -23,17 +23,31 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
/** Google Maps implementation of [MapViewProvider]. */
|
||||
@Single
|
||||
class GoogleMapViewProvider : MapViewProvider {
|
||||
@Composable
|
||||
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
|
||||
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()
|
||||
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
|
||||
org.meshtastic.app.map.MapView(
|
||||
modifier = modifier,
|
||||
mapViewModel = mapViewModel,
|
||||
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
|
|
@ -28,11 +28,7 @@ import com.google.android.gms.maps.model.TileProvider
|
|||
import com.google.android.gms.maps.model.UrlTileProvider
|
||||
import com.google.maps.android.compose.CameraPositionState
|
||||
import com.google.maps.android.compose.MapType
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
|
@ -49,7 +45,6 @@ import org.koin.core.annotation.KoinViewModel
|
|||
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
||||
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
|
||||
import org.meshtastic.app.map.repository.CustomTileProviderRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
|
|
@ -82,8 +77,6 @@ data class MapCameraPosition(
|
|||
@KoinViewModel
|
||||
class MapViewModel(
|
||||
private val application: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val httpClient: HttpClient,
|
||||
mapPrefs: MapPrefs,
|
||||
private val googleMapsPrefs: GoogleMapsPrefs,
|
||||
nodeRepository: NodeRepository,
|
||||
|
|
@ -411,7 +404,7 @@ class MapViewModel(
|
|||
}
|
||||
|
||||
private fun loadPersistedLayers() {
|
||||
viewModelScope.launch(dispatchers.io) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val layersDir = File(application.filesDir, "map_layers")
|
||||
if (layersDir.exists() && layersDir.isDirectory) {
|
||||
|
|
@ -419,33 +412,32 @@ class MapViewModel(
|
|||
|
||||
if (persistedLayerFiles != null) {
|
||||
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
||||
val loadedItems =
|
||||
persistedLayerFiles.mapNotNull { file ->
|
||||
if (file.isFile) {
|
||||
val layerType =
|
||||
when (file.extension.lowercase()) {
|
||||
"kml",
|
||||
"kmz",
|
||||
-> LayerType.KML
|
||||
"geojson",
|
||||
"json",
|
||||
-> LayerType.GEOJSON
|
||||
else -> null
|
||||
}
|
||||
|
||||
layerType?.let {
|
||||
val uri = Uri.fromFile(file)
|
||||
MapLayerItem(
|
||||
name = file.nameWithoutExtension,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = it,
|
||||
)
|
||||
val loadedItems = persistedLayerFiles.mapNotNull { file ->
|
||||
if (file.isFile) {
|
||||
val layerType =
|
||||
when (file.extension.lowercase()) {
|
||||
"kml",
|
||||
"kmz",
|
||||
-> LayerType.KML
|
||||
"geojson",
|
||||
"json",
|
||||
-> LayerType.GEOJSON
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
||||
layerType?.let {
|
||||
val uri = Uri.fromFile(file)
|
||||
MapLayerItem(
|
||||
name = file.nameWithoutExtension,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = it,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val networkItems =
|
||||
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
||||
|
|
@ -558,7 +550,7 @@ class MapViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
|
||||
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = application.contentResolver.openInputStream(uri)
|
||||
val directory = File(application.filesDir, "map_layers")
|
||||
|
|
@ -629,7 +621,7 @@ class MapViewModel(
|
|||
}
|
||||
|
||||
private suspend fun deleteFileToInternalStorage(uri: Uri) {
|
||||
withContext(dispatchers.io) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = uri.toFile()
|
||||
if (file.exists()) {
|
||||
|
|
@ -644,15 +636,11 @@ class MapViewModel(
|
|||
@Suppress("Recycle")
|
||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
||||
val uriToLoad = layerItem.uri ?: return null
|
||||
return withContext(dispatchers.io) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
|
||||
val response = httpClient.get(uriToLoad.toString())
|
||||
if (!response.status.isSuccess()) {
|
||||
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
|
||||
return@withContext null
|
||||
}
|
||||
response.bodyAsChannel().toInputStream()
|
||||
val url = java.net.URL(uriToLoad.toString())
|
||||
java.io.BufferedInputStream(url.openStream())
|
||||
} else {
|
||||
application.contentResolver.openInputStream(uriToLoad)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,17 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
|
|
@ -62,11 +66,6 @@ import org.meshtastic.core.resources.save
|
|||
import org.meshtastic.core.resources.show_layer
|
||||
import org.meshtastic.core.resources.url
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.Visibility
|
||||
import org.meshtastic.core.ui.icon.VisibilityOff
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
|
@ -120,22 +119,19 @@ fun CustomMapLayersSheet(
|
|||
} else {
|
||||
IconButton(onClick = { onRefreshLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Refresh,
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(Res.string.refresh),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconToggleButton(
|
||||
checked = layer.isVisible,
|
||||
onCheckedChange = { onToggleVisibility(layer.id) },
|
||||
) {
|
||||
IconButton(onClick = { onToggleVisibility(layer.id) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (layer.isVisible) {
|
||||
MeshtasticIcons.Visibility
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
MeshtasticIcons.VisibilityOff
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
|
|
@ -149,7 +145,7 @@ fun CustomMapLayersSheet(
|
|||
}
|
||||
IconButton(onClick = { onRemoveLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(Res.string.remove_layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -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_hint
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.Edit
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -155,13 +155,13 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
|
|||
},
|
||||
) {
|
||||
Icon(
|
||||
MeshtasticIcons.Edit,
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(Res.string.edit_custom_tile_source),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
|
||||
Icon(
|
||||
MeshtasticIcons.Delete,
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(Res.string.delete_custom_tile_source),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -57,6 +60,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.atTime
|
||||
|
|
@ -78,9 +82,6 @@ import org.meshtastic.core.resources.time
|
|||
import org.meshtastic.core.resources.waypoint_edit
|
||||
import org.meshtastic.core.resources.waypoint_new
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.icon.CalendarMonth
|
||||
import org.meshtastic.core.ui.icon.Lock
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
|
|
@ -119,12 +120,12 @@ fun EditWaypointDialog(
|
|||
val expireValue = waypointInput.expire ?: 0
|
||||
if (isExpiryEnabled) {
|
||||
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())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
selectedTimeString = timeFormat.format(date)
|
||||
} 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())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
selectedTimeString = timeFormat.format(date)
|
||||
|
|
@ -189,7 +190,7 @@ fun EditWaypointDialog(
|
|||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.Lock,
|
||||
imageVector = Icons.Rounded.Lock,
|
||||
contentDescription = stringResource(Res.string.locked),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
|
@ -208,7 +209,7 @@ fun EditWaypointDialog(
|
|||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.CalendarMonth,
|
||||
imageVector = Icons.Rounded.CalendarMonth,
|
||||
contentDescription = stringResource(Res.string.expires),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
|
@ -222,7 +223,7 @@ fun EditWaypointDialog(
|
|||
val expireValue = waypointInput.expire ?: 0
|
||||
// Default to 8 hours from now if not already set
|
||||
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
|
||||
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
|
||||
val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
|
||||
}
|
||||
} else {
|
||||
|
|
@ -236,9 +237,9 @@ fun EditWaypointDialog(
|
|||
val currentInstant =
|
||||
(waypointInput.expire ?: 0).let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
kotlin.time.Instant.fromEpochSeconds(it.toLong())
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlin.time.Clock.System.now() + 8.hours
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
val ldt = currentInstant.toLocalDateTime(tz)
|
||||
|
|
@ -251,9 +252,9 @@ fun EditWaypointDialog(
|
|||
(waypointInput.expire ?: 0)
|
||||
.let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
kotlin.time.Instant.fromEpochSeconds(it.toLong())
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlin.time.Clock.System.now() + 8.hours
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
.toLocalDateTime(tz)
|
||||
|
|
@ -286,9 +287,9 @@ fun EditWaypointDialog(
|
|||
(waypointInput.expire ?: 0)
|
||||
.let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
kotlin.time.Instant.fromEpochSeconds(it.toLong())
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlin.time.Clock.System.now() + 8.hours
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
.toLocalDateTime(tz)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* 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.map.component
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
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.vector.ImageVector
|
||||
|
||||
/**
|
||||
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
|
||||
* across both Google and F-Droid flavors.
|
||||
*/
|
||||
@Composable
|
||||
fun MapButton(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector,
|
||||
iconTint: Color? = null,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color? = null,
|
||||
) {
|
||||
FilledIconButton(onClick = onClick, modifier = modifier) {
|
||||
Icon(
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,10 @@ package org.meshtastic.app.map.component
|
|||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
|
|
@ -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.show_precision_circle
|
||||
import org.meshtastic.core.resources.show_waypoints
|
||||
import org.meshtastic.core.ui.icon.Favorite
|
||||
import org.meshtastic.core.ui.icon.Lens
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PinDrop
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -56,10 +56,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
|
|||
text = { Text(stringResource(Res.string.only_favorites)) },
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = stringResource(Res.string.only_favorites),
|
||||
)
|
||||
Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(Res.string.only_favorites))
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
|
|
@ -72,10 +69,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
|
|||
text = { Text(stringResource(Res.string.show_waypoints)) },
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.PinDrop,
|
||||
contentDescription = stringResource(Res.string.show_waypoints),
|
||||
)
|
||||
Icon(imageVector = Icons.Filled.Place, contentDescription = stringResource(Res.string.show_waypoints))
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
|
|
@ -89,7 +83,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
|
|||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Lens,
|
||||
imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
|
||||
contentDescription = stringResource(Res.string.show_precision_circle),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
|
|
@ -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_terrain
|
||||
import org.meshtastic.core.resources.selected_map_type
|
||||
import org.meshtastic.core.ui.icon.Check
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
|
@ -67,12 +67,7 @@ internal fun MapTypeDropdown(
|
|||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
|
||||
{
|
||||
Icon(
|
||||
MeshtasticIcons.Check,
|
||||
contentDescription = stringResource(Res.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
{ Icon(Icons.Filled.Check, contentDescription = stringResource(Res.string.selected_map_type)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
|
@ -92,7 +87,7 @@ internal fun MapTypeDropdown(
|
|||
if (selectedCustomUrl == config.urlTemplate) {
|
||||
{
|
||||
Icon(
|
||||
MeshtasticIcons.Check,
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(Res.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,36 +16,30 @@
|
|||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.Marker
|
||||
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.app.map.convertIntToEmoji
|
||||
import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
private const val DEG_D = 1e-7
|
||||
|
||||
@Composable
|
||||
fun WaypointMarkers(
|
||||
displayableWaypoints: List<Waypoint>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
myNodeNum: Int,
|
||||
isConnected: Boolean,
|
||||
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
|
||||
onEditWaypointRequest: (Waypoint) -> Unit,
|
||||
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(
|
||||
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', ' '),
|
||||
snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '),
|
||||
visible = true,
|
||||
|
|
|
|||
|
|
@ -16,14 +16,13 @@
|
|||
*/
|
||||
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.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.app.map.GoogleMapMode
|
||||
import org.meshtastic.app.map.MapView
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
|
|
@ -32,6 +31,7 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
|
|||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
|
||||
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
val destNum = node?.num
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -46,9 +46,8 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
|
|||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
MapView(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
|
||||
)
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.app.map")
|
||||
|
|
@ -36,10 +36,9 @@ class GoogleMapsKoinModule {
|
|||
|
||||
@Single
|
||||
@Named("GoogleMapsDataStore")
|
||||
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
|
||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
||||
)
|
||||
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -44,14 +44,11 @@
|
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 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_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:remove="android:maxSdkVersion" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:remove="android:maxSdkVersion" />
|
||||
|
||||
<!-- This permission is required for analytics - and soon the MQTT gateway -->
|
||||
<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" />
|
||||
|
||||
<!--
|
||||
|
|
@ -288,7 +285,7 @@
|
|||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_local_stats_info" />
|
||||
android:resource="@xml/local_stats_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- allow for plugin discovery -->
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,7 @@
|
|||
"Heltec"
|
||||
],
|
||||
"requiresDfu": true,
|
||||
"hasMui": true,
|
||||
"hasMui": false,
|
||||
"partitionScheme": "16MB",
|
||||
"images": [
|
||||
"heltec_v4.svg"
|
||||
|
|
@ -1236,28 +1236,12 @@
|
|||
"rak_3312.svg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hwModel": 112,
|
||||
"hwModelSlug": "M5STACK_CARDPUTER_ADV",
|
||||
"platformioTarget": "m5stack-cardputer-adv",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": false,
|
||||
"supportLevel": 1,
|
||||
"displayName": "Cardputer Mesh Kit",
|
||||
"tags": [
|
||||
"M5Stack"
|
||||
],
|
||||
"images": [
|
||||
"m5stack_cardputer.svg"
|
||||
],
|
||||
"partitionScheme": "8MB"
|
||||
},
|
||||
{
|
||||
"hwModel": 113,
|
||||
"hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2",
|
||||
"platformioTarget": "heltec-wireless-tracker-v2",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": true,
|
||||
"architecture": "esp32s3",
|
||||
"activelySupported": false,
|
||||
"supportLevel": 1,
|
||||
"displayName": "Heltec Wireless Tracker V2",
|
||||
"tags": [
|
||||
|
|
@ -1322,7 +1306,7 @@
|
|||
"hwModelSlug": "THINKNODE_M4",
|
||||
"platformioTarget": "thinknode_m4",
|
||||
"architecture": "nrf52840",
|
||||
"activelySupported": true,
|
||||
"activelySupported": false,
|
||||
"supportLevel": 1,
|
||||
"displayName": "ThinkNode M4",
|
||||
"tags": [
|
||||
|
|
@ -1338,7 +1322,7 @@
|
|||
"hwModelSlug": "THINKNODE_M6",
|
||||
"platformioTarget": "thinknode_m6",
|
||||
"architecture": "nrf52840",
|
||||
"activelySupported": true,
|
||||
"activelySupported": false,
|
||||
"supportLevel": 1,
|
||||
"displayName": "ThinkNode M6",
|
||||
"tags": [
|
||||
|
|
@ -1380,7 +1364,7 @@
|
|||
"hasMui": false,
|
||||
"partitionScheme": "8MB",
|
||||
"images": [
|
||||
"t5s3_epaper.svg"
|
||||
"t5s3-epaper-pro.svg"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -45,12 +45,11 @@ import androidx.lifecycle.lifecycleScope
|
|||
import co.touchlab.kermit.Logger
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import com.eygraber.uri.toKmpUri
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.meshtastic.app.intro.AnalyticsIntro
|
||||
import org.meshtastic.app.map.getMapViewProvider
|
||||
|
|
@ -58,8 +57,8 @@ import org.meshtastic.app.node.component.InlineMap
|
|||
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
|
||||
import org.meshtastic.app.ui.MainScreen
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.common.util.toMeshtasticUri
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
|
|
@ -70,30 +69,18 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
|
|||
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
|
||||
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
|
||||
import org.meshtastic.core.ui.util.LocalInlineMapProvider
|
||||
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
|
||||
import org.meshtastic.core.ui.util.LocalMapViewProvider
|
||||
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
|
||||
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
|
||||
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
|
||||
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
|
||||
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
|
||||
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
|
||||
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.intro.AppIntroductionScreen
|
||||
import org.meshtastic.feature.intro.IntroViewModel
|
||||
import org.meshtastic.feature.map.MapScreen
|
||||
import org.meshtastic.feature.map.SharedMapViewModel
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val model: UIViewModel by viewModel()
|
||||
|
||||
private val usbRepository: UsbRepository by inject()
|
||||
|
||||
/**
|
||||
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
|
||||
* itself as a LifecycleObserver in its init block.
|
||||
|
|
@ -127,8 +114,6 @@ class MainActivity : ComponentActivity() {
|
|||
setSingletonImageLoaderFactory { get<ImageLoader>() }
|
||||
|
||||
val theme by model.theme.collectAsStateWithLifecycle()
|
||||
val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
|
||||
val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
|
||||
val dynamic = theme == MODE_DYNAMIC
|
||||
val dark =
|
||||
when (theme) {
|
||||
|
|
@ -146,7 +131,7 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
|
||||
AppCompositionLocals {
|
||||
AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
|
||||
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
||||
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
||||
|
||||
// Signal to the system that the initial UI is "fully drawn"
|
||||
|
|
@ -154,7 +139,7 @@ class MainActivity : ComponentActivity() {
|
|||
ReportDrawnWhen { true }
|
||||
|
||||
if (appIntroCompleted) {
|
||||
MainScreen()
|
||||
MainScreen(uIViewModel = model)
|
||||
} else {
|
||||
val introViewModel = koinViewModel<IntroViewModel>()
|
||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
|
||||
|
|
@ -169,16 +154,6 @@ class MainActivity : ComponentActivity() {
|
|||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
|
||||
// resumed while a USB device is already attached (e.g. process restart, returning
|
||||
// from another app), the manifest-declared attach intent may have already fired
|
||||
// before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
|
||||
// reality without requiring the user to physically replug.
|
||||
usbRepository.refreshState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppCompositionLocals(content: @Composable () -> Unit) {
|
||||
CompositionLocalProvider(
|
||||
|
|
@ -189,48 +164,32 @@ class MainActivity : ComponentActivity() {
|
|||
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
|
||||
LocalMapViewProvider provides getMapViewProvider(),
|
||||
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
|
||||
LocalNodeTrackMapProvider provides
|
||||
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
|
||||
org.meshtastic.app.map.node.NodeTrackMap(
|
||||
destNum,
|
||||
positions,
|
||||
modifier,
|
||||
selectedPositionTime,
|
||||
onPositionSelected,
|
||||
)
|
||||
},
|
||||
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
|
||||
LocalTracerouteMapProvider provides
|
||||
{ overlay, nodePositions, onMappableCountChanged, modifier ->
|
||||
org.meshtastic.app.map.traceroute.TracerouteMap(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = nodePositions,
|
||||
onMappableCountChanged = onMappableCountChanged,
|
||||
modifier = modifier,
|
||||
)
|
||||
},
|
||||
LocalNodeMapScreenProvider provides
|
||||
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
|
||||
{ destNum, onNavigateUp ->
|
||||
val vm = koinViewModel<NodeMapViewModel>()
|
||||
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
|
||||
},
|
||||
LocalTracerouteMapScreenProvider provides
|
||||
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
|
||||
{ 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)
|
||||
|
||||
TracerouteMapScreen(
|
||||
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
|
||||
metricsViewModel = metricsViewModel,
|
||||
requestId = requestId,
|
||||
logUuid = logUuid,
|
||||
onNavigateUp = onNavigateUp,
|
||||
)
|
||||
},
|
||||
LocalMapMainScreenProvider provides
|
||||
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
|
||||
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
|
||||
val viewModel = koinViewModel<SharedMapViewModel>()
|
||||
MapScreen(
|
||||
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
|
||||
org.meshtastic.feature.map.MapScreen(
|
||||
viewModel = viewModel,
|
||||
onClickNodeChip = onClickNodeChip,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
|
|
@ -270,11 +229,6 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
Logger.d { "USB device attached" }
|
||||
// Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
|
||||
// receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
|
||||
// never sees this event. Forward it explicitly so the serialDevices StateFlow
|
||||
// refreshes and the device shows up in the Connect → Serial tab.
|
||||
usbRepository.refreshState()
|
||||
showSettingsPage()
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +250,7 @@ class MainActivity : ComponentActivity() {
|
|||
private fun handleMeshtasticUri(uri: Uri) {
|
||||
Logger.d { "Handling Meshtastic URI: $uri" }
|
||||
|
||||
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
||||
model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
||||
}
|
||||
|
||||
private fun createShareIntent(message: String): PendingIntent {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.work.WorkManager
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -37,8 +36,9 @@ import kotlinx.coroutines.withTimeout
|
|||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.workmanager.koin.workManagerFactory
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import org.meshtastic.app.di.AndroidKoinApp
|
||||
import org.koin.core.context.startKoin
|
||||
import org.meshtastic.app.di.AppKoinModule
|
||||
import org.meshtastic.app.di.module
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
|
|
@ -57,15 +57,16 @@ open class MeshUtilApplication :
|
|||
Application(),
|
||||
Configuration.Provider {
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val applicationScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextServices.app = this
|
||||
|
||||
startKoin<AndroidKoinApp> {
|
||||
startKoin {
|
||||
androidContext(this@MeshUtilApplication)
|
||||
workManagerFactory()
|
||||
modules(AppKoinModule().module())
|
||||
}
|
||||
|
||||
// Schedule periodic MeshLog cleanup
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
|
|||
import org.meshtastic.core.prefs.di.CorePrefsModule
|
||||
import org.meshtastic.core.service.di.CoreServiceAndroidModule
|
||||
import org.meshtastic.core.service.di.CoreServiceModule
|
||||
import org.meshtastic.core.takserver.di.CoreTakServerModule
|
||||
import org.meshtastic.core.ui.di.CoreUiModule
|
||||
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
|
||||
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.settings.di.FeatureSettingsModule
|
||||
import org.meshtastic.feature.widget.di.FeatureWidgetModule
|
||||
import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
|
||||
|
||||
@Module(
|
||||
includes =
|
||||
|
|
@ -78,7 +76,6 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
|
|||
CoreServiceAndroidModule::class,
|
||||
CoreNetworkModule::class,
|
||||
CoreNetworkAndroidModule::class,
|
||||
CoreTakServerModule::class,
|
||||
CoreUiModule::class,
|
||||
FeatureNodeModule::class,
|
||||
FeatureMessagingModule::class,
|
||||
|
|
@ -88,7 +85,6 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
|
|||
FeatureFirmwareModule::class,
|
||||
FeatureIntroModule::class,
|
||||
FeatureWidgetModule::class,
|
||||
FeatureWifiProvisionModule::class,
|
||||
NetworkModule::class,
|
||||
FlavorModule::class,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ import coil3.ImageLoader
|
|||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.memoryCacheMaxSizePercentWhileInBackground
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.svg.SvgDecoder
|
||||
|
|
@ -33,25 +31,18 @@ import coil3.util.DebugLogger
|
|||
import coil3.util.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.network.HttpClientDefaults
|
||||
import org.meshtastic.core.network.KermitHttpLogger
|
||||
|
||||
private const val DISK_CACHE_PERCENT = 0.02
|
||||
private const val MEMORY_CACHE_PERCENT = 0.25
|
||||
private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
|
||||
|
||||
@Module
|
||||
class NetworkModule {
|
||||
|
|
@ -72,12 +63,7 @@ class NetworkModule {
|
|||
buildConfigProvider: BuildConfigProvider,
|
||||
): ImageLoader = ImageLoader.Builder(context = application)
|
||||
.components {
|
||||
add(
|
||||
KtorNetworkFetcherFactory(
|
||||
httpClient = httpClient,
|
||||
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
|
||||
),
|
||||
)
|
||||
add(KtorNetworkFetcherFactory(httpClient = httpClient))
|
||||
add(SvgDecoder.Factory(scaleToDensity = true))
|
||||
}
|
||||
.memoryCache {
|
||||
|
|
@ -90,29 +76,21 @@ class NetworkModule {
|
|||
.build()
|
||||
}
|
||||
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
|
||||
.memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
|
||||
.crossfade(enable = true)
|
||||
.build()
|
||||
|
||||
@Single
|
||||
fun provideJson(): Json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
@Single
|
||||
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
|
||||
HttpClient(engineFactory = Android) {
|
||||
install(plugin = ContentNegotiation) { json(json) }
|
||||
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
|
||||
install(plugin = HttpTimeout) {
|
||||
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
}
|
||||
install(plugin = HttpRequestRetry) {
|
||||
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
|
||||
exponentialDelay()
|
||||
}
|
||||
if (buildConfigProvider.isDebug) {
|
||||
install(plugin = Logging) {
|
||||
logger = KermitHttpLogger
|
||||
level = LogLevel.BODY
|
||||
}
|
||||
install(plugin = Logging) { level = LogLevel.BODY }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,27 +19,30 @@
|
|||
package org.meshtastic.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.recalculateWindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.app.BuildConfig
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.navigation.NodesRoute
|
||||
import org.meshtastic.core.navigation.rememberMultiBackstack
|
||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_too_old
|
||||
import org.meshtastic.core.resources.must_update
|
||||
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.feature.connections.navigation.connectionsGraph
|
||||
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.settings.navigation.settingsGraph
|
||||
import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val viewModel: UIViewModel = koinViewModel()
|
||||
val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph)
|
||||
val backStack = multiBackstack.activeBackStack
|
||||
fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
|
||||
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
|
||||
|
||||
AndroidAppVersionCheck(viewModel)
|
||||
AndroidAppVersionCheck(uIViewModel)
|
||||
|
||||
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
|
||||
MeshtasticNavigationSuite(
|
||||
multiBackstack = multiBackstack,
|
||||
uiViewModel = viewModel,
|
||||
MeshtasticAppShell(
|
||||
backStack = backStack,
|
||||
uiViewModel = uIViewModel,
|
||||
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
|
||||
) {
|
||||
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
|
||||
backStack = backStack,
|
||||
uiViewModel = uIViewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val provider =
|
||||
entryProvider<NavKey> {
|
||||
contactsGraph(backStack, viewModel.scrollToTopEventFlow)
|
||||
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
|
||||
nodesGraph(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = viewModel.scrollToTopEventFlow,
|
||||
onHandleDeepLink = viewModel::handleDeepLink,
|
||||
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
|
||||
onHandleDeepLink = uIViewModel::handleDeepLink,
|
||||
)
|
||||
mapGraph(backStack)
|
||||
channelsGraph(backStack)
|
||||
connectionsGraph(backStack)
|
||||
settingsGraph(backStack)
|
||||
firmwareGraph(backStack)
|
||||
wifiProvisionGraph(backStack)
|
||||
}
|
||||
MeshtasticNavDisplay(
|
||||
multiBackstack = multiBackstack,
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
||||
)
|
||||
|
|
@ -94,6 +99,7 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
|
|||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
// Check if the device is running an old app version
|
||||
LaunchedEffect(connectionState, myNodeInfo) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
myNodeInfo?.let { info ->
|
||||
|
|
|
|||
|
|
@ -25,14 +25,13 @@ import androidx.work.WorkerParameters
|
|||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import org.koin.plugin.module.dsl.koinApplication
|
||||
import org.junit.Test
|
||||
import org.koin.test.verify.definition
|
||||
import org.koin.test.verify.injectedParameters
|
||||
import org.koin.test.verify.verify
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.core.model.util.NodeIdLookup
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import kotlin.test.Test
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.app.service
|
||||
|
||||
import android.app.Notification
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import org.meshtastic.core.model.Node
|
||||
|
|
@ -36,7 +37,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
|||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {}
|
||||
): Notification = mock(MockMode.autofill)
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@
|
|||
*/
|
||||
package org.meshtastic.app.ui
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.navigation.NodesRoute
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
||||
import org.meshtastic.feature.firmware.navigation.firmwareGraph
|
||||
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.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class NavigationAssemblyTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest {
|
||||
setContent {
|
||||
val backStack = rememberNavBackStack(NodesRoute.NodesGraph)
|
||||
fun verifyNavigationGraphsAssembleWithoutCrashing() {
|
||||
composeTestRule.setContent {
|
||||
val backStack = rememberNavBackStack(NodesRoutes.NodesGraph)
|
||||
entryProvider<NavKey> {
|
||||
contactsGraph(backStack, emptyFlow())
|
||||
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.model.util.getInitials
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class UIUnitTest {
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -16,12 +16,11 @@
|
|||
*/
|
||||
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.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EnvironmentMetricsTest {
|
||||
|
||||
|
|
@ -66,12 +65,11 @@ class EnvironmentMetricsTest {
|
|||
|
||||
val resultTelemetry = processedTelemetries.first()
|
||||
|
||||
assertTrue(
|
||||
abs(expectedTemperatureFahrenheit - (resultTelemetry.environment_metrics?.temperature ?: 0f)) < 0.01f,
|
||||
)
|
||||
assertTrue(
|
||||
abs(expectedSoilTemperatureFahrenheit - (resultTelemetry.environment_metrics?.soil_temperature ?: 0f)) <
|
||||
0.01f,
|
||||
assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environment_metrics?.temperature ?: 0f, 0.01f)
|
||||
assertEquals(
|
||||
expectedSoilTemperatureFahrenheit,
|
||||
resultTelemetry.environment_metrics?.soil_temperature ?: 0f,
|
||||
0.01f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ plugins {
|
|||
|
||||
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.
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } }
|
||||
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
|
||||
|
||||
dependencies {
|
||||
// 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.ksp.gradlePlugin)
|
||||
compileOnly(libs.androidx.room.gradlePlugin)
|
||||
compileOnly(libs.secrets.gradlePlugin)
|
||||
compileOnly(libs.spotless.gradlePlugin)
|
||||
compileOnly(libs.test.retry.gradlePlugin)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,18 +17,15 @@
|
|||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
||||
import com.datadog.gradle.plugin.DdExtension
|
||||
import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask
|
||||
|
||||
import com.datadog.gradle.plugin.InstrumentationMode
|
||||
import com.datadog.gradle.plugin.SdkCheckLevel
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.apply
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.findByType
|
||||
import org.gradle.kotlin.dsl.withType
|
||||
import org.meshtastic.buildlogic.libs
|
||||
import org.meshtastic.buildlogic.plugin
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Convention plugin for analytics (Google Services, Crashlytics, Datadog). Segregates these plugins to only affect the
|
||||
|
|
@ -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") {
|
||||
tasks.configureEach {
|
||||
if (
|
||||
(
|
||||
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)
|
||||
) {
|
||||
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.
|
||||
|
|
@ -110,7 +87,7 @@ class AnalyticsConventionPlugin : Plugin<Project> {
|
|||
variants {
|
||||
register(variant.name) {
|
||||
site = "US5"
|
||||
|
||||
composeInstrumentation = InstrumentationMode.AUTO
|
||||
}
|
||||
}
|
||||
checkProjectDependencies = SdkCheckLevel.NONE
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
|
|
@ -25,6 +26,7 @@ import org.meshtastic.buildlogic.configureTestOptions
|
|||
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
|
||||
apply(plugin = "com.android.application")
|
||||
apply(plugin = "org.gradle.test-retry")
|
||||
apply(plugin = "meshtastic.android.lint")
|
||||
|
|
@ -36,8 +38,13 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
|||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
configureKotlinAndroid(this)
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
defaultConfig { vectorDrawables.useSupportLibrary = true }
|
||||
testOptions.animationsDisabled = true
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
|
|
@ -45,8 +52,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
|||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
rootProject.file("config/proguard/shared-rules.pro"),
|
||||
"proguard-rules.pro",
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
getByName("debug") {
|
||||
|
|
@ -58,7 +64,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
|||
}
|
||||
}
|
||||
|
||||
buildFeatures { buildConfig = true }
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
configureTestOptions()
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue