Compare commits

..

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

1828 changed files with 46040 additions and 95047 deletions

View file

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

View file

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

View file

@ -0,0 +1,19 @@
name: 'Calculate Version Code'
description: 'Calculates the Android versionCode based on the Git commit count plus an offset.'
outputs:
versionCode:
description: "The calculated version code"
value: ${{ steps.calculate_version.outputs.VERSION_CODE }}
runs:
using: 'composite'
steps:
- name: Calculate Version Code
id: calculate_version
shell: bash
run: |
# This action assumes that the repo has been checked out with `fetch-depth: 0`
GIT_COMMIT_COUNT=$(git rev-list --count HEAD)
OFFSET=30630
VERSION_CODE=$((GIT_COMMIT_COUNT + OFFSET))
echo "Calculated versionCode: $VERSION_CODE (from $GIT_COMMIT_COUNT commits + $OFFSET offset)"
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_OUTPUT

View file

@ -1,40 +0,0 @@
name: Gradle Setup
description: Setup Java and Gradle for KMP builds
inputs:
cache_read_only:
description: 'Whether Gradle cache is read-only'
default: 'true'
jdk_distribution:
description: 'JDK distribution (temurin or jetbrains)'
default: 'temurin'
gradle_encryption_key:
description: 'Encryption key for Gradle remote cache'
required: false
runs:
using: composite
steps:
- name: Copy CI Gradle properties
shell: bash
run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: ${{ inputs.jdk_distribution }}
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ inputs.cache_read_only }}
cache-encryption-key: ${{ inputs.gradle_encryption_key }}
cache-cleanup: on-success
add-job-summary: always
gradle-home-cache-includes: |
caches
notifications
~/.m2/repository/org/robolectric

View file

@ -1,52 +0,0 @@
#
# CI-specific Gradle properties.
#
# This file is copied to ~/.gradle/gradle.properties by the gradle-setup
# composite action, overriding the dev-oriented values in the repo-root
# gradle.properties. Inspired by the nowinandroid & sqldelight patterns.
#
# ── Daemon ────────────────────────────────────────────────────────────
# Single-use CI runners never reuse a daemon, so the startup cost is pure
# overhead. Disabling it also avoids "daemon disappeared" warnings.
org.gradle.daemon=false
# ── Memory ────────────────────────────────────────────────────────────
# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon
# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom).
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC
# ── Parallelism ───────────────────────────────────────────────────────
org.gradle.parallel=true
org.gradle.workers.max=4
# ── Caching & Configuration ──────────────────────────────────────────
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configureondemand=false
org.gradle.vfs.watch=false
org.gradle.isolated-projects=true
# ── Kotlin ────────────────────────────────────────────────────────────
# Incremental compilation is wasted on fresh CI checkouts (no prior build
# state to diff against). Disabling avoids the overhead of maintaining
# incremental state that will never be reused.
kotlin.incremental=false
kotlin.code.style=official
kotlin.parallel.tasks.in.project=true
# ── KSP ──────────────────────────────────────────────────────────────
# In CI, KSP incremental processing adds overhead without benefit (fresh
# checkouts). Keep intermodule incremental off (no prior state).
ksp.incremental=false
ksp.run.in.process=true
# ── Android ──────────────────────────────────────────────────────────
android.experimental.lint.analysisPerComponent=true
# Disable unused build features to reduce build time
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
# ── Misc ─────────────────────────────────────────────────────────────
org.gradle.welcome=never

View file

@ -1,27 +0,0 @@
# GitHub Copilot Commit Message Instructions
<role>
You are an expert Git maintainer enforcing Conventional Commits.
</role>
<instructions>
1. **Format:** Use the Conventional Commits format: `<type>(<scope>): <subject>` (Replace angle brackets with actual text, do NOT output angle brackets).
2. **Types allowed:**
- `feat` (new feature for the user, not a new feature for build script)
- `fix` (bug fix for the user, not a fix to a build script)
- `docs` (changes to the documentation)
- `style` (formatting, missing semi colons, etc; no production code change)
- `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain)
- `test` (adding missing tests, refactoring tests; no production code change)
- `chore` (updating grunt tasks etc; no production code change)
3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`).
4. **Subject line:**
- Use the imperative, present tense: "change" not "changed" nor "changes".
- Do not capitalize the first letter.
- Do not use a period (.) at the end.
- Keep it under 50 characters if possible.
5. **Body (Optional but recommended for large diffs):**
- Leave one blank line after the subject.
- Explain *why* the change was made, not just *what* changed.
- If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework".
</instructions>

View file

@ -1,6 +1,183 @@
# Meshtastic Android - GitHub Copilot Guide
# Copilot Instructions for Meshtastic-Android
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
## Repository Summary
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.
Meshtastic-Android is a native Android client application for the Meshtastic mesh networking project. It enables users to communicate via off-grid, decentralized mesh networks using LoRa radios. The app is written in Kotlin and follows modern Android development practices.
**Key Repository Details:**
- **Language:** Kotlin (primary), with some Java and AIDL files
- **Build System:** Gradle with Kotlin DSL
- **Size:** ~3MB source code across 3 modules
- **Target Platform:** Android API 26+ (Android 8.0+), targeting API 36
- **Architecture:** Modern Android with Jetpack Compose, Hilt DI, Room database
- **Product Flavors:** `fdroid` (F-Droid) and `google` (Google Play Store)
- **Build Types:** `debug` and `release`
## Essential Build & Test Commands
**ALWAYS run these commands in the exact order specified to avoid build failures:**
### Prerequisites Setup
1. **JDK Requirement:** JDK 17 is required (compatible with most developer environments)
2. **Secrets Configuration:** Copy `secrets.defaults.properties` to `local.properties` and update:
```properties
MAPS_API_KEY=your_google_maps_api_key_here
datadogApplicationId=your_datadog_app_id
datadogClientToken=your_datadog_client_token
```
3. **Clean Environment:** Always start with `./gradlew clean` for fresh builds
### Build Commands (Validated Working Order)
```bash
# 1. ALWAYS clean first for reliable builds
./gradlew clean
# 2. Check code formatting (run before making changes)
./gradlew spotlessCheck
# 3. Apply automatic code formatting fixes
./gradlew spotlessApply
# 4. Run static code analysis/linting
./gradlew detekt
# 5. Build debug APKs for both flavors (takes 3-5 minutes)
./gradlew assembleDebug
# 6. Build specific flavor variants
./gradlew assembleFdroidDebug # F-Droid debug build
./gradlew assembleGoogleDebug # Google debug build
./gradlew assembleFdroidRelease # F-Droid release build
./gradlew assembleGoogleRelease # Google release build
# 7. Run local unit tests (takes 2-3 minutes)
./gradlew test
# 8. Run specific flavor unit tests
./gradlew testFdroidDebug
./gradlew testGoogleDebug
# 9. Run instrumented tests (requires Android device/emulator, takes 5-10 minutes)
./gradlew connectedAndroidTest
# 10. Run lint checks for both flavors
./gradlew lintFdroidDebug lintGoogleDebug
```
### Time Requirements
- Clean build: 3-5 minutes
- Unit tests: 2-3 minutes
- Instrumented tests: 5-10 minutes
- Detekt analysis: 1-2 minutes
- Spotless formatting: 30 seconds
### Environment Setup
**Required Tools:**
- Android SDK API 36 (compile target)
- JDK 17 (Preferred for consistency across project and plugins)
- Gradle 9.0+ (downloaded automatically by wrapper)
**Optional but Recommended:**
- Install pre-push Git hook: `./gradlew spotlessInstallGitPrePushHook --no-configuration-cache`
## Project Architecture & Layout
### Module Structure
```
├── app/ # Main Android application
│ ├── src/main/ # Main source code
│ ├── src/test/ # Unit tests
│ ├── src/androidTest/ # Instrumented tests
│ ├── src/fdroid/ # F-Droid specific code
│ └── src/google/ # Google Play specific code
├── core/ # Core library modules
├── network/ # HTTP API networking library
├── mesh_service_example/ # AIDL service usage example
├── build-logic/ # Build configuration convention plugins
└── config/ # Linting and formatting configs
├── detekt/ # Detekt static analysis rules
└── spotless/ # Code formatting configuration
```
### Key Configuration Files
- `config.properties` - Version constants and build config
- `app/build.gradle.kts` - Main app build configuration
- `config/detekt/detekt.yml` - Static analysis rules
- `config/spotless/.editorconfig` - Code formatting rules
- `gradle.properties` - Gradle build settings
- `secrets.defaults.properties` - Template for secrets (copy to `local.properties`)
### Architecture Components
- **UI Framework:** Jetpack Compose with Material 3
- **State Management:** Unidirectional Data Flow with ViewModels
- **Dependency Injection:** Hilt
- **Navigation:** Jetpack Navigation Compose
- **Local Data:** Room database + DataStore preferences
- **Remote Data:** Custom Bluetooth/WiFi protocol + HTTP API (network module)
- **Background Work:** WorkManager
- **Communication:** AIDL service interface (`IMeshService.aidl`)
## Continuous Integration
### GitHub Workflows (.github/workflows/)
- **pull-request.yml** - Runs on every PR: build, detekt, tests
- **reusable-android-build.yml** - Shared build logic: spotless, detekt, lint, assemble, test
- **reusable-android-test.yml** - Instrumented tests on Android emulators (API 26, 35)
### CI Commands (Must Pass)
```bash
# Exact commands run in CI that must pass:
./gradlew :app:spotlessCheck :app:detekt :app:lintFdroidDebug :app:lintGoogleDebug :app:assembleDebug :app:testFdroidDebug :app:testGoogleDebug --configuration-cache --scan
./gradlew :app:connectedFdroidDebugAndroidTest :app:connectedGoogleDebugAndroidTest --configuration-cache --scan
```
### Validation Steps
1. **Code Style:** Spotless check (auto-fixable with `spotlessApply`)
2. **Static Analysis:** Detekt with custom rules in `config/detekt/detekt.yml`
3. **Lint Checks:** Android lint for both flavors
4. **Unit Tests:** JUnit tests in `app/src/test/`
5. **UI Tests:** Compose UI tests in `app/src/androidTest/`
## Common Issues & Solutions
### Build Failures
- **Gradle version error:** Ensure JDK 17 (Compatible version)
- **Missing secrets:** Copy `secrets.defaults.properties``local.properties`
- **Configuration cache:** Add `--no-configuration-cache` flag if issues persist
- **Clean state:** Always run `./gradlew clean` before debugging build issues
### Testing Issues
- **Instrumented tests:** Require Android device/emulator with API 26+
- **UI tests:** Use `ComposeTestRule` for Compose UI testing
- **Coroutine tests:** Use `kotlinx.coroutines.test` library
### Code Style Issues
- **Formatting:** Run `./gradlew spotlessApply` to auto-fix
- **Detekt warnings:** Check `config/detekt/detekt.yml` for rules
- **Localization:** Use `stringResource(Res.string.key)` instead of hardcoded strings
## File Organization
### Source Code Locations
- **Main Activity:** `app/src/main/java/com/geeksville/mesh/MainActivity.kt`
- **Service Interface:** `core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl`
- **UI Screens:** `feature/*/src/main/kotlin/org/meshtastic/feature/*/`
- **Data Layer:** `core/data/src/main/kotlin/org/meshtastic/core/data/`
- **Database:** `core/database/src/main/kotlin/org/meshtastic/core/database/`
- **Models:** `core/model/src/main/kotlin/org/meshtastic/core/model/`
### Dependencies
- **Non-obvious deps:** Protobuf for device communication, DataDog for analytics (Google flavor)
- **Flavor-specific:** Google Services (google flavor), no analytics (fdroid flavor)
- **Version catalog:** Dependencies defined in `gradle/libs.versions.toml`
## Agent Instructions
**TRUST THESE INSTRUCTIONS** - they are validated and comprehensive. Only search for additional information if:
1. Commands fail with unexpected errors
2. Information appears outdated
3. Working on areas not covered above
**Always prefer:** Using the documented commands over exploring alternatives, as they are tested and proven to work in the CI environment.
**For code changes:** Follow the architecture patterns established in existing code, maintain the modular structure, and ensure all validation steps pass before submitting changes.

View file

@ -1,18 +0,0 @@
# GitHub Copilot Pull Request Instructions
<role>
You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs.
</role>
<instructions>
1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text.
2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`.
3. **Structured Changes:** Break down the code changes into bullet points categorized by:
- 🌟 **New Features** (UI, modules, logic)
- 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates)
- 🐛 **Bug Fixes**
- 🧹 **Chores** (Dependencies, formatting, docs)
4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone".
5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified.
6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots.
</instructions>

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,35 @@
# Auto Labeler rulse using https://github.com/actions/labeler
#
# 'fix' in title/branch -> bug
# 'feat' in title/branch -> enhancement
# 'repo' in title/branch OR changes to ~/.github/ -> repo
# 'bug_fallthrough' for everything else except auto
#
# - [ ] need to look at title. waiting on https://github.com/actions/labeler/pull/866
# Add 'enhancement' label to any PR where the head branch name contains `feat`
enhancement:
- head-branch: [feat, Feat, FEAT]
# Add 'repo' label to any PR where the head branch name contains `repo`
# or files in the .github dir
repo:
- any:
- head-branch: [repo, Repo, REPO, ci, CI]
- changed-files:
- any-glob-to-any-file: .github
# Add 'bug' label to any PR where the head branch name contains `fix` or `bug` as the prefix.
bugfix:
- head-branch: [^fix, ^bug, ^Fix, ^FIX, ^Bug, ^BUG]
# Add `refactor` label to any PR where the head branch name contains `refactor` or `Refactor` as the prefix.
refactor:
- head-branch: [^refactor, ^Refactor]
# our fallback - bug except repo, feat, or automated pipelines
# bug_fallthrough:
# - all:
# - head-branch: ['^((?!feat).)*$', '^((?!repo).)*$', '^((?!renovate).)*$', '^((?!scheduled).)*$']

12
.github/lsp.json vendored
View file

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

223
.github/renovate.json vendored
View file

@ -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
}
]
}
}

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

@ -0,0 +1,107 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
# push:
# branches: [ "main" ]
# pull_request:
# branches: [ "main" ]
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: java-kotlin
build-mode: autobuild
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
- name: Java Setup
uses: actions/setup-java@v5
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View file

@ -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 }}
@ -111,6 +106,112 @@ jobs:
fi
shell: bash
- name: Update External Assets (Firmware, Hardware, Protos)
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
run: |
# Update Submodules (Protobufs)
echo "Updating core/proto submodule..."
git submodule update --init --remote core/proto
# Update Firmware List
firmware_file_path="app/src/main/assets/firmware_releases.json"
temp_firmware_file="/tmp/new_firmware_releases.json"
echo "Fetching latest firmware releases..."
curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file"
if ! jq empty "$temp_firmware_file" 2>/dev/null; then
echo "::error::Firmware API returned invalid JSON data. Aborting."
exit 1
else
if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then
echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path."
cp "$temp_firmware_file" "$firmware_file_path"
else
echo "No changes detected in firmware list."
fi
fi
# Update Hardware List
hardware_file_path="app/src/main/assets/device_hardware.json"
temp_hardware_file="/tmp/new_device_hardware.json"
echo "Fetching latest device hardware data..."
curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file"
if ! jq empty "$temp_hardware_file" 2>/dev/null; then
echo "::error::Hardware API returned invalid JSON data. Aborting."
exit 1
else
if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then
echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path."
cp "$temp_hardware_file" "$hardware_file_path"
else
echo "No changes detected in hardware list."
fi
fi
- name: Sync with Crowdin
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
uses: crowdin/github-action@v2
with:
base_url: 'https://meshtastic.crowdin.com/api/v2'
config: 'crowdin.yml'
crowdin_branch_name: 'main'
upload_sources: true
upload_sources_args: '--preserve-hierarchy'
upload_translations: false
download_translations: true
download_translations_args: '--preserve-hierarchy'
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Commit Release Assets (Translations, Data, Config)
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }}
run: |
# Calculate Version Code
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
COMMIT_COUNT=$(git rev-list --count HEAD)
# +1 because we are about to add a commit
VERSION_CODE=$((COMMIT_COUNT + OFFSET + 1))
echo "Calculated Version Code: $VERSION_CODE"
# Update VERSION_NAME_BASE in config.properties
sed -i "s/^VERSION_NAME_BASE=.*/VERSION_NAME_BASE=${{ inputs.base_version }}/" config.properties
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Add updated data files
git add config.properties
git add app/src/main/assets/firmware_releases.json || true
git add app/src/main/assets/device_hardware.json || true
git add core/proto || true
# Add updated translations (fastlane metadata and strings)
git add fastlane/metadata/android || true
git add "**/strings.xml" || true
# Only commit if there are changes
if ! git diff --cached --quiet; then
git commit -m "chore(release): prepare $FINAL_TAG [skip ci]
- Bump base version to ${{ inputs.base_version }}
- Sync translations and assets"
git push origin HEAD:${{ github.ref_name }}
else
echo "No changes to commit."
fi
shell: bash
- name: Create and Push Release Tag
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
@ -129,7 +230,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:
@ -144,23 +244,3 @@ jobs:
base_version: ${{ inputs.base_version }}
from_channel: ${{ needs.determine-tags.outputs.from_channel }}
secrets: inherit
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Delete Failed or Cancelled Tag
env:
FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
run: |
if [ -n "$FINAL_TAG" ]; then
echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
else
echo "No tag was created to delete."
fi

View file

@ -10,20 +10,19 @@ permissions:
jobs:
dependency-submission:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
token: ${{ github.token }}
distribution: jetbrains
java-version: 17
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v6
uses: gradle/actions/dependency-submission@v5
with:
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use"
build-scan-terms-of-use-agree: "yes"

View file

@ -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,20 @@ 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: 'jetbrains'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- 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,9 +69,9 @@ 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
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4

View file

@ -1,26 +0,0 @@
name: Main CI (Verify & Build)
on:
push:
branches: [ main ]
paths-ignore:
- '**/*.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: main-${{ github.ref }}
cancel-in-progress: true
jobs:
validate-and-build:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: false
run_desktop_builds: false
upload_artifacts: true
secrets: inherit

View file

@ -5,10 +5,6 @@ on:
branches:
- main
permissions:
contents: write
pull-requests: read
concurrency:
group: main-push-${{ github.ref }}
cancel-in-progress: true
@ -16,7 +12,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 +35,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 }}

View file

@ -4,9 +4,6 @@ on:
merge_group:
types: [checks_requested]
permissions:
contents: read
concurrency:
group: build-mq-${{ github.ref }}
cancel-in-progress: true
@ -16,15 +13,14 @@ jobs:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: true
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
flavors: '["google", "fdroid"]'
upload_artifacts: false
secrets: inherit
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
runs-on: ubuntu-latest
needs:
- android-check
if: always()

View file

@ -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:

View file

@ -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;

View file

@ -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

View file

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

View file

@ -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(', ')}.`);
}

View file

@ -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
@ -77,14 +77,9 @@ jobs:
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
echo "Parsed Version: $VERSION_NAME"
- name: Determine Version Name from Tag
id: get_version_name
run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@ -102,7 +97,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 +111,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 +134,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

View file

@ -12,7 +12,7 @@ on:
jobs:
publish:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
@ -23,25 +23,25 @@ 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'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Configure Version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
echo "VERSION_NAME=$RELEASE_TAG" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "VERSION_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
else
# Use a timestamp-based version for manual/branch builds to avoid collisions
# or use the base version + suffix
BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2)
echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV
echo "VERSION_NAME=${BASE_VERSION}${{ inputs.version_suffix }}" >> $GITHUB_ENV
fi
- name: Publish to GitHub Packages

View file

@ -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

View file

@ -1,134 +1,72 @@
name: Pull Request CI
on:
pull_request:
branches: [ main ]
permissions:
contents: read
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: build-pr-${{ github.ref }}
cancel-in-progress: true
jobs:
# 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
outputs:
android: ${{ steps.filter.outputs.android }}
code_changed: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
- uses: dorny/paths-filter@v3
id: filter
with:
token: ''
filters: |
android:
# CI/workflow implementation
- '.github/workflows/**'
- '.github/actions/**'
# Product modules validated by reusable-check
- 'app/**'
- 'baselineprofile/**'
- 'desktop/**'
- 'core/**'
- 'feature/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
code:
- '**/*.kt'
- '**/*.java'
- '**/*.xml'
- '**/*.kts'
- '**/*.properties'
- 'gradle/**'
# Root build entrypoints/config that can alter task graph or outputs
- 'build.gradle.kts'
- 'config.properties'
- 'compose_compiler_config.conf'
- 'gradle.properties'
- 'gradlew'
- 'gradlew.bat'
- 'settings.gradle.kts'
- 'test.gradle.kts'
- '**/src/**'
- '.github/workflows/**'
# 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
verify-check-changes-filter:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- name: Verify module roots are represented in check-changes filter
run: |
python3 - <<'PY'
import re
from pathlib import Path
settings = Path('settings.gradle.kts').read_text()
workflow = Path('.github/workflows/pull-request.yml').read_text()
module_roots = {
module.split(':')[0]
for module in re.findall(r'":([^"]+)"', settings)
}
allowed_extra_roots = {'baselineprofile'}
expected_roots = module_roots | allowed_extra_roots
filter_paths = {
path.split('/')[0]
for path in re.findall(r"-\s*'([^']+/\*\*)'", workflow)
}
actual_module_roots = filter_paths & expected_roots
missing = sorted(expected_roots - actual_module_roots)
unexpected = sorted(actual_module_roots - expected_roots)
if missing or unexpected:
print('check-changes filter drift detected:')
if missing:
print(' Missing roots:', ', '.join(missing))
if unexpected:
print(' Unexpected roots:', ', '.join(unexpected))
raise SystemExit(1)
print('check-changes filter is aligned with settings.gradle module roots.')
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# We disable coverage and desktop builds for PRs to keep feedback fast
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
# task in the shard-app test shard.
validate-and-build:
android-check:
needs: check-changes
if: needs.check-changes.outputs.android == 'true'
if: needs.check-changes.outputs.code_changed == 'true'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: true
run_coverage: false
run_desktop_builds: false
upload_artifacts: true
api_levels: '[35]' # Only test latest API on PRs for speed
flavors: '["google","fdroid"]'
secrets: inherit
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
skip-notice:
needs: check-changes
if: needs.check-changes.outputs.code_changed != 'true'
runs-on: ubuntu-latest
steps:
- name: Skip CI for non-code changes
run: echo "Skipping CI - no code changes detected (docs/config only)"
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
needs: [check-changes, verify-check-changes-filter, validate-and-build]
runs-on: ubuntu-latest
needs:
- check-changes
- android-check
if: always()
steps:
- name: Check Workflow Status
run: |
if [[ "${{ needs.verify-check-changes-filter.result }}" == "failure" || "${{ needs.verify-check-changes-filter.result }}" == "cancelled" ]]; then
echo "::error::check-changes filter verification failed"
exit 1
if [[ "${{ needs.check-changes.outputs.code_changed }}" != "true" ]]; then
echo "No code changes - CI jobs skipped as expected"
exit 0
fi
# If changes were detected but build failed, fail the status check
if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then
if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
echo "::error::Android Check failed"
exit 1
fi
# If no changes were detected, this still succeeds to satisfy required status check
echo "Workflow status satisfied."
echo "All jobs passed successfully"

View file

@ -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
@ -49,10 +44,6 @@ on:
required: false
GRADLE_CACHE_PASSWORD:
required: false
INTERNAL_BUILDS_HOST:
required: false
INTERNAL_BUILDS_HOST_PAT:
required: false
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag_name }}
@ -65,12 +56,23 @@ permissions:
attestations: write
jobs:
run-lint:
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: false
run_instrumented_tests: false
flavors: '["google"]'
upload_artifacts: false
secrets: inherit
prepare-build-info:
runs-on: ubuntu-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_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@ -81,14 +83,22 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
echo "Parsed Version: $VERSION_NAME"
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Determine Version Name from Tag
id: get_version_name
run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@ -106,10 +116,11 @@ jobs:
shell: bash
release-google:
runs-on: ubuntu-24.04
needs: [prepare-build-info]
runs-on: ubuntu-latest
needs: [prepare-build-info, run-lint]
environment: Release
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@ -120,12 +131,18 @@ 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'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Load secrets
env:
@ -150,7 +167,7 @@ jobs:
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4.9'
ruby-version: '3.4.8'
bundler-cache: true
- name: Build and Deploy Google Play to Internal Track with Fastlane
@ -175,26 +192,27 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: google-apk
path: app/build/outputs/apk/google/release/*.apk
path: app/build/outputs/apk/**/*.apk
retention-days: 1
- name: Attest Google AAB provenance
if: success()
if: always()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab
- name: Attest Google APK provenance
if: success()
if: always()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/google/release/*.apk
subject-path: app/build/outputs/apk/**/*.apk
release-fdroid:
runs-on: ubuntu-24.04
needs: [prepare-build-info]
runs-on: ubuntu-latest
needs: [prepare-build-info, run-lint]
environment: Release
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@ -205,12 +223,18 @@ 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'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Load secrets
env:
@ -224,7 +248,7 @@ jobs:
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4.9'
ruby-version: '3.4.8'
bundler-cache: true
- name: Build F-Droid with Fastlane
@ -241,86 +265,24 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
path: app/build/outputs/apk/fdroid/release/*.apk
path: app/build/outputs/apk/**/*.apk
retention-days: 1
- name: Attest F-Droid APK provenance
if: success()
if: always()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/fdroid/release/*.apk
subject-path: app/build/outputs/apk/**/*.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]
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
github-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, release-google, release-fdroid]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'false'
- name: Install dependencies for AppImage
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libfuse2
- name: Package Native Distributions
env:
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon
- name: List Desktop Binaries
if: runner.os == 'Linux'
run: ls -R desktop/build/compose/binaries/main-release
- name: Upload Desktop Artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-${{ runner.os }}-${{ runner.arch }}
path: |
desktop/build/compose/binaries/main-release/*/*.dmg
desktop/build/compose/binaries/main-release/*/*.msi
desktop/build/compose/binaries/main-release/*/*.exe
desktop/build/compose/binaries/main-release/*/*.deb
desktop/build/compose/binaries/main-release/*/*.rpm
desktop/build/compose/binaries/main-release/*/*.AppImage
retention-days: 1
if-no-files-found: ignore
github-release:
if: ${{ !cancelled() && !failure() }}
runs-on: ubuntu-24.04-arm
needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
env:
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
- name: Download all artifacts
uses: actions/download-artifact@v8
@ -328,26 +290,23 @@ 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 }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: true
files: ./artifacts/**/*
files: ./artifacts/*/*
draft: true
prerelease: true
- name: Create or Update internal GitHub Release
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 }}
tag_name: ${{ inputs.tag_name }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: false
files: ./artifacts/**/*
generate_release_notes: true
files: ./artifacts/*/*
draft: false
prerelease: true
prerelease: true

View file

@ -9,12 +9,18 @@ on:
run_unit_tests:
type: boolean
default: true
run_coverage:
type: boolean
default: true
run_desktop_builds:
run_instrumented_tests:
type: boolean
default: true
flavors:
type: string
default: '["google"]'
api_levels:
type: string
default: '[35]'
num_shards:
type: number
default: 1
upload_artifacts:
type: boolean
default: true
@ -36,280 +42,164 @@ on:
GRADLE_CACHE_PASSWORD:
required: false
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
# Fallback VERSION_CODE for the lint-check job itself (which computes the real
# value from git). Downstream jobs override this with the git-derived value.
VERSION_CODE: ${{ github.run_number }}
jobs:
# ── Lint & Static Analysis ──────────────────────────────────────────
lint-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 30
outputs:
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
version_code: ${{ steps.version_code.outputs.version_code }}
check:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
api_level: ${{ fromJson(inputs.api_levels) }}
flavor: ${{ fromJson(inputs.flavors) }}
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
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
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: Calculate Version Code
id: calculate_version_code
uses: ./.github/actions/calculate-version-code
- name: Determine Tasks
id: tasks
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"
TASKS=""
# Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources
IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
[ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt "
[ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest "
fi
- 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
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
- name: Lint, Analysis & KMP Smoke Compile
if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false
run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan
# ── Sharded Unit Tests ──────────────────────────────────────────────
# Tests are split into 3 shards that run in parallel:
# shard-core: core:* KMP module tests (allTests)
# shard-feature: feature:* KMP module tests (allTests)
# shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
test-shards:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 45
needs: lint-check
if: inputs.run_unit_tests == true
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
strategy:
fail-fast: false
matrix:
shard:
- name: shard-core
tasks: >-
:core:ble:allTests
:core:common:allTests
:core:data:allTests
:core:database:allTests
:core:domain:allTests
:core:model:allTests
:core:navigation:allTests
:core:network:allTests
:core:prefs:allTests
:core:repository:allTests
:core:service:allTests
:core:takserver:allTests
:core:testing:allTests
:core:ui:allTests
kover: >-
:core:ble:koverXmlReport
:core:common:koverXmlReport
:core:data:koverXmlReport
:core:database:koverXmlReport
:core:domain:koverXmlReport
:core:model:koverXmlReport
:core:navigation:koverXmlReport
:core:network:koverXmlReport
:core:prefs:koverXmlReport
:core:repository:koverXmlReport
:core:service:koverXmlReport
:core:takserver:koverXmlReport
:core:testing:koverXmlReport
:core:ui:koverXmlReport
- name: shard-feature
tasks: >-
:feature:connections:allTests
:feature:firmware:allTests
:feature:intro:allTests
:feature:map:allTests
:feature:messaging:allTests
:feature:node:allTests
:feature:settings:allTests
kover: >-
:feature:connections:koverXmlReport
:feature:firmware:koverXmlReport
:feature:intro:koverXmlReport
:feature:map:koverXmlReport
:feature:messaging:koverXmlReport
:feature:node:koverXmlReport
:feature:settings:koverXmlReport
- name: shard-app
tasks: >-
:app:testFdroidDebugUnitTest
:app:testGoogleDebugUnitTest
:desktop:test
:core:barcode:testFdroidDebugUnitTest
:core:barcode:testGoogleDebugUnitTest
kover: >-
:app:koverXmlReportFdroidDebug
:app:koverXmlReportGoogleDebug
:core:barcode:koverXmlReportFdroidDebug
:core:barcode:koverXmlReportGoogleDebug
:desktop:koverXmlReport
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Run Tests & Coverage (${{ matrix.shard.name }})
run: |
kover_tasks=""
if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
kover_tasks="${{ matrix.shard.kover }}"
FLAVOR="${{ matrix.flavor }}"
if [ "$IS_FIRST_API" = "true" ]; then
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS assembleGoogleDebug "
[ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest "
elif [ "$FLAVOR" = "fdroid" ]; then
TASKS="$TASKS assembleFdroidDebug "
[ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest "
fi
fi
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
[ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest "
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS connectedGoogleDebugAndroidTest "
elif [ "$FLAVOR" = "fdroid" ]; then
TASKS="$TASKS connectedFdroidDebugAndroidTest "
fi
fi
# Run coverage report if unit tests were executed
if [ "${{ inputs.run_unit_tests }}" = "true" ] && [ "$IS_FIRST_API" = "true" ]; then
if [ "$IS_FIRST_FLAVOR" = "true" ]; then
TASKS="$TASKS koverXmlReportDebug "
fi
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS koverXmlReportGoogleDebug "
elif [ "$FLAVOR" = "fdroid" ]; then
TASKS="$TASKS koverXmlReportFdroidDebug "
fi
fi
echo "tasks=$TASKS" >> $GITHUB_OUTPUT
echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true
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 Check (with Emulator)
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- name: Run Check (no Emulator)
if: inputs.run_instrumented_tests == false
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- name: Upload coverage results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
files: "**/build/reports/kover/report*.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
report_type: test_results
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
files: "**/build/reports/kover/report*.xml"
- name: Upload shard reports
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: reports-${{ matrix.shard.name }}
path: |
**/build/reports
**/build/test-results
retention-days: 7
# ── Android Build ────────────────────────────────────────────────────
android-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 60
needs: lint-check
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Android APKs
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml"
- name: Upload debug artifact
if: ${{ inputs.upload_artifacts }}
if: ${{ steps.tasks.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
name: ${{ matrix.flavor }}Debug
path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk
retention-days: 14
- name: Report App Size
if: always()
if: always() && steps.tasks.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 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-${{ matrix.flavor }}-api-${{ matrix.api_level }}
path: |
**/build/reports
**/build/test-results
**/build/outputs/androidTest-results
retention-days: 7

View file

@ -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,22 @@ 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: 'jetbrains'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: Update Graphs
run: ./gradlew graphUpdate
@ -132,8 +143,7 @@ jobs:
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
runs-on: ubuntu-latest
needs:
- update_assets
if: always()

View file

@ -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

8
.gitignore vendored
View file

@ -37,9 +37,6 @@ keystore.properties
/fastlane/play-store-credentials.json
**/google-services.json
# Generated library definitions
**/src/main/resources/aboutlibraries.json
/fastlane/report.xml
/build-logic/convention/build/*
@ -51,8 +48,3 @@ wireless-install.sh
# Git worktrees
.worktrees/
/firebase-debug.log.jdk/
firebase-debug.log
.agent_plans/
.agent_refs/
.agent_artifacts/

1
.jdk
View file

@ -1 +0,0 @@
/home/james/.jdks/ms-17.0.18

View file

@ -1,295 +0,0 @@
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..2a27b96906
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.common.di
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
+
+/**
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
+ *
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
+ *
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
+ * and should be used sparingly.
+ */
+interface ApplicationCoroutineScope : CoroutineScope
+
+@Single(binds = [ApplicationCoroutineScope::class])
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
+ override val coroutineContext = SupervisorJob() + ioDispatcher
+}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 231c84d401..5365ab95e2 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import com.eygraber.uri.toKmpUri
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.net.URLEncoder
@Composable
@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
val context = LocalContext.current
return remember(context) {
{ uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val androidUri = uri.toAndroidUri()
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 031e1fe35d..a938f92ea6 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
/** JVM — Reads text from a file URI. */
@Composable
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val file = File(URI(uri.toString()))
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index dc1c459716..f8ff9fcac8 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
+ private val applicationScope: ApplicationCoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
override fun onCleared() {
super.onCleared()
- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
- // is cancelled concurrently.
- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
+ // running even if something tries to cancel it mid-flight.
+ applicationScope.launch(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 4c48a1ced5..030d84effd 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index 7032ed4088..a8eddff838 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..3ef5c44ef4
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+
+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
+ ApplicationCoroutineScope,
+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index acb1545bdd..23a0d03ab2 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
// -----------------------------------------------------------------------
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index c251b4d5ef..315ad1da85 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_export_failed
import org.meshtastic.core.resources.debug_export_success
@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
try {
if (logs.isEmpty()) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9afde85e5f..a28a576788 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
return { fileName -> exportLauncher.launch(fileName) }
}
-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
try {
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
Logger.i { "TAK data package exported successfully to $targetUri" }
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index 5b63cc90a3..a9a7285593 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
return@launch
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
// Run file dialog to ask user where to save
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
fileDialog.file = fileName
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9fb71379fc..bfbb85bc0d 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
if (directory != null && file != null) {
val targetFile = File(directory, file)
val data = dataPackageProvider()
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
}
}

View file

@ -1 +1 @@
3.4.9
3.4.8

View file

@ -1,66 +0,0 @@
# Skill: Code Review
## Description
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
## Code Review Checklist
When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
### 1. KMP Architecture & Source Set Boundaries
- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets.
- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries:
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
- `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
### 3. Navigation & State
- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets.
- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves.
- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry<T>` blocks to correctly tie to the backstack lifetime.
### 4. Dependency Injection (Koin Annotations)
- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`).
- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`).
### 5. Networking, DB & I/O
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
### 6. Dependency Catalog Aliases
- [ ] **JetBrains vs. AndroidX:**
- In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`).
- In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`.
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
### 7. Testing
- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
### 8. ProGuard / R8 Rules
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
## Review Output Guidelines
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous.
4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions.

View file

@ -1,61 +0,0 @@
# Skill: Compose Multiplatform (CMP) UI
## Description
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
## 1. UI Components & Layouts
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
## 2. Strings & Resources
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
```kotlin
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
stringResource(Res.string.battery_percent, formatted) // uses %1$s
```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
### String Formatting Decision Tree
Choose the right tool for the job:
| Scenario | Tool | Example |
|----------|------|---------|
| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)``"77.0°F"` |
| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
**Rules:**
1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
3. Validate UI presentation.
## 3. Tooling & Capabilities
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
## 4. Compose Previews
- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
## 5. Dialog & State Patterns
- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`

View file

@ -1,41 +0,0 @@
# Skill: Implement a Feature
## Description
A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture.
## Workflow
### 1. Update Dependencies & Aliases
- Check `gradle/libs.versions.toml` before adding libraries.
- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`.
- Use `compose-multiplatform-*` aliases for CMP dependencies.
### 2. Define the State & ViewModels
- Follow MVI/UDF patterns.
- Extend shared ViewModel logic in `feature/<name>/src/commonMain/kotlin/org/meshtastic/feature/<name>/<Name>ViewModel.kt`.
- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows.
- Keep the ViewModel free of Android framework dependencies.
### 3. Build the UI
- Use Jetpack Compose Multiplatform (CMP).
- Define strings in `core:resources` (see the `compose-ui` skill).
- Support adaptive layouts (Large/XL breakpoints).
### 4. Wire Navigation & DI
- Define typed route objects in `core:navigation`.
- Export the navigation graph as an extension function on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.myFeatureGraph()`).
- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`.
- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell.
### 5. Validate Platform Separation
- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin.
### 6. Verify Locally
- Run the baseline checks (see `testing-ci` skill):
```bash
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
```bash
./gradlew assembleFdroidRelease :desktop:runRelease
```

View file

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

View file

@ -1,56 +0,0 @@
# Skill: DI and Navigation 3 Architecture
## Description
This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
## Dependency Injection (Koin)
### Guidelines
1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
### Anti-Patterns
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
### Koin Startup Pattern (K2 Compiler Plugin)
The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin<T>()` stub, which the plugin transforms at compile time via IR:
```kotlin
// Bootstrap class — separate from @Module, references the root module graph
@KoinApplication(modules = [AppKoinModule::class])
object AndroidKoinApp
// In Application.onCreate()
startKoin<AndroidKoinApp> {
androidContext(this@MeshUtilApplication)
workManagerFactory()
}
```
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
## Navigation 3
### Guidelines
1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`).
2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings.
3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack)`).
4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules.
5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`.
6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths.
### Anti-Patterns
- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`).
- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction.
- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack<NavKey>` directly with `add(...)` and `removeLastOrNull()`.
## Reference Anchors
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`

View file

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

View file

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

View file

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

228
AGENTS.md
View file

@ -1,108 +1,140 @@
# 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 workflows.
<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>
## 1. Project Overview
<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>
- **Type:** Native Android Application (Kotlin).
- **Purpose:** Client interface for Meshtastic mesh radios.
- **Architecture:** Modern Android Development (MAD) principles.
- **UI:** Jetpack Compose (Material 3).
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Hilt.
- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
<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>
## 2. Codebase Map
<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.
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, `AppNavigation`, and Hilt entry points. Uses package `com.geeksville.mesh`. |
| `core/` | Shared library modules. Most code here uses package `org.meshtastic.core.*`. |
| `core/ble/` | **New:** Bluetooth Low Energy stack using Nordic libraries. |
| `core/strings/` | **Crucial:** Centralized string resources using Compose Multiplatform Resources. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). Each is a standalone Gradle module. Uses package `org.meshtastic.feature.*`. |
| `build-logic/` | Custom Gradle convention plugins. Defines build logic for the entire project. |
| `gradle/libs.versions.toml` | **Version Catalog.** All dependencies and versions are defined here. |
| `core/proto/` | Protobuf definitions for communicating with the mesh radio. |
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>
## 3. Development Guidelines
<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>
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3. Look for ways to use **Material 3 Expressive** components where appropriate.
- **Strings:**
- Do **not** use `app/src/main/res/values/strings.xml` for UI strings.
- Use the **Compose Multiplatform Resource** library in `core:resources`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
<copilot_cli_workflow>
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
section.
Text(text = stringResource(Res.string.your_string_key))
```
- **Dialogs:**
- Use the centralized `MeshtasticDialog` for all alerts and confirmation boxes.
- **Specialized Overloads:** Use `MeshtasticResourceDialog` (for resource-only content) or `MeshtasticTextDialog` (for mixed resource/text content) to reduce boilerplate.
- **Location:** Defined in `core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt`.
- **Previews:** Create `@Preview` functions for your Composables to ensure they render correctly.
- **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. Architecture & State
- **ViewModels:** Must be annotated with `@HiltViewModel`.
- **Injection:** Use `@Inject constructor(...)`.
- **Scopes:** Use `viewModelScope` for coroutines. Avoid `GlobalScope`.
- **Data Flow:** Expose UI state as `StateFlow<UiState>` or `Flow<UiState>`.
<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. Navigation
- The project uses **Type-Safe Navigation** (Kotlin Serialization).
- Routes are defined in `core/navigation` (e.g., `ContactsRoutes`, `SettingsRoutes`).
- The main `NavHost` is located in `app/src/main/java/com/geeksville/mesh/ui/Main.kt`.
### D. Bluetooth (BLE)
- **Library:** Uses **Nordic Semiconductor's Kotlin BLE Library** and **Android Common Libraries**.
- **Location:** Core logic resides in `core/ble`.
- **Key Classes:** `BluetoothRepository`, `NordicBleInterface`, `BleConnection`.
- **Usage:** Use `BluetoothRepository` for scanning and bonding. Use `BleConnection` for managing connections. Avoid legacy `BluetoothAdapter` APIs directly.
- **Environment Mocking:** Use `LocalEnvironmentOwner` and `MockAndroidEnvironment` to test UI hardware reactions without a real device.
### E. Dependency Management
- **Never** hardcode versions in `build.gradle.kts` files.
- **Action:** Add the library and version to `gradle/libs.versions.toml`.
- **Action:** Apply plugins using the alias from the catalog (e.g., `alias(libs.plugins.meshtastic.android.library)`).
- **Alpha Libraries:** Do not be shy about using alpha libraries from Google if they provide necessary features.
### F. Build Variants (Flavors)
- **`google`**: Includes Google Play Services (Maps, Firebase, Crashlytics).
- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
- **Task Example:** `./gradlew assembleFdroidDebug`
### G. Kotlin Multiplatform (KMP) & Decoupling
- **Goal:** We are actively moving logic and models from Android-specific modules to KMP modules (`core:common`, `core:model`, `core:proto`) to support future cross-platform expansion.
- **Domain Models:** Always place domain models (Data Classes, Enums) in `commonMain` of the respective module.
- **Parceling:**
- Use the platform-agnostic `CommonParcelable` and `CommonParcelize` from `core:common`.
- Avoid direct imports of `android.os.Parcelable` or `kotlinx.parcelize.Parcelize` in `commonMain`.
- **Platform Abstractions:** Use `expect`/`actual` for platform-specific logic (e.g., `DateFormatter`, `RandomUtils`, `BuildUtils`).
- **AIDL Compatibility:** AIDL parcelable declarations for models moved to `commonMain` should be relocated to `:core:api` to ensure proper export to consumer modules.
## 4. Quality Assurance
### A. Code Style (Spotless)
- The project uses **Spotless** to enforce formatting.
- **Command:** `./gradlew spotlessApply`
- **Rule:** You **must** run this before submitting any code.
### B. Linting (Detekt)
- The project uses **Detekt** for static analysis.
- **Command:** `./gradlew detekt`
- **Rule:** Ensure zero regressions.
### C. Testing
- **Unit Tests:** JUnit 4/5 in `src/test/java`. Run with `./gradlew test`.
- **Compose UI Tests (JVM):** Preferred for component testing. Use **Robolectric** in `src/test/java`.
- **Important:** Annotate with `@Config(sdk = [34])` if using Java 17 to avoid SDK 35 compatibility issues.
- **Best Practice:** Pass mocked ViewModels to Composables instead of using Hilt in Robolectric tests.
- **Instrumented Tests:** For full E2E or Hilt integration tests, use `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
- **Feature Test:** `./gradlew feature:settings:testGoogleDebug`
## 5. Agent Workflow
1. **Explore First:** Before making changes, read `gradle/libs.versions.toml` and the relevant `build.gradle.kts` to understand the environment.
2. **Plan:** Identify which modules (`core` or `feature`) need modification.
3. **Implement:**
- If adding a string, modify `core:resources`.
- If adding a dependency, modify `libs.versions.toml` first.
4. **Verify:**
- Run `./gradlew spotlessApply` (Essential!).
- Run `./gradlew detekt`.
- Run relevant tests (e.g., `./gradlew :feature:settings:testDebugUnitTest`).
## 6. Important Context
- **Protobuf:** Communication with the device uses Protobufs. The definitions are in `core/proto`. This is a Git submodule, but the build system handles it.
- **Legacy:** Some code in `app/` uses the `com.geeksville.mesh` package. Newer code in `core/` and `feature/` uses `org.meshtastic.*`. Respect the existing package structure of the file you are editing.
- **Versioning:** Do not manually edit `versionCode` or `versionName`. These are managed by the build system and CI/CD.
- **Database Safety:** When modifying critical database logic (e.g., `NodeInfoDao`), always ensure you have explicit test coverage for security edge cases (like PKC conflicts or key wiping). Refer to `core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt` for examples.
## 7. Troubleshooting
- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.resources.Res` and the specific string property, and that you have run a build to generate the resources.
- **Build Errors:** Check `gradle/libs.versions.toml` for version conflicts. Use `build-logic` conventions to ensure plugins are applied correctly.
---
*Refer to `CONTRIBUTING.md` for human-centric processes like Code of Conduct and Pull Request etiquette.*
### E. Resources and Assets
- **Centralization:** All global app resources (Strings, Drawables, Fonts, raw files) should be placed in `:core:resources`.
- **Module Path:** `core/resources/src/commonMain/composeResources/`
- **Decentralization:** Feature-specific strings and assets can (and should) be housed in their respective feature module's `composeResources` directory to maintain modular boundaries and clean architectural dependency graphs. Crowdin localization handles globbing `/**/composeResources/values/strings.xml` perfectly.
- **Drawables:** Use `painterResource(Res.drawable.your_icon)` to access cross-platform drawables. Name them consistently (`ic_` for icons, `img_` for artwork). Avoid putting standard Drawables or Vectors in legacy Android `res/drawable` folders unless strictly required by a legacy library (like `OsmDroid` map markers) or the OS layer (like `app_icon.xml`).

View file

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

View file

@ -48,7 +48,7 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
- **Unit tests** are located in the `src/test/` directory of each module.
- **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

View file

@ -1,6 +0,0 @@
# Meshtastic Android - Google Gemini Guide
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.

View file

@ -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)

View file

@ -11,7 +11,7 @@
[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/)
[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
This is a tool for using Android (and Compose Desktop) with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware).
This is a tool for using Android with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware).
This project is currently beta testing across various providers. If you have questions or feedback please [Join our discussion forum](https://github.com/orgs/meshtastic/discussions) or the [Discord Group](https://discord.gg/meshtastic) . We would love to hear from you!
@ -51,24 +51,23 @@ 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
### Modern Android Development (MAD)
The app follows modern Android development practices, built on top of a shared Kotlin Multiplatform (KMP) Core:
- **KMP Modules:** Business logic (`core:domain`), data sources (`core:data`, `core:database`, `core:datastore`), and communications (`core:network`, `core:ble`) are entirely platform-agnostic, targeting Android and Compose Desktop.
- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
The app follows modern Android development practices:
- **UI:** Jetpack Compose (Material 3).
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
- **Dependency Injection:** Hilt.
- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
The BLE stack has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication. See [core/ble/README.md](core/ble/README.md) for details.
## Translations
@ -80,8 +79,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.

View file

@ -1,12 +0,0 @@
# Security Policy
## Supported Versions
| App Version | Supported |
| ---------------- | ------------------ |
| 2.7.x | :white_check_mark: |
| <= 2.6.x | :x: |
## Reporting a Vulnerability
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.

View file

@ -6,13 +6,13 @@ The `:app` module is the entry point for the Meshtastic Android application. It
## Key Components
### 1. `MainActivity` & `Main.kt`
The single Activity of the application. It hosts the 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.
The core background service that manages long-running communication with the mesh radio. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
### 3. Koin Application
`MeshUtilApplication` is the Koin entry point, providing the global dependency injection container.
### 3. Hilt Application
`MeshUtilApplication` is the Hilt entry point, providing the global dependency injection container.
## Architecture
The module primarily serves as a "glue" layer, connecting:
@ -25,13 +25,13 @@ The module primarily serves as a "glue" layer, connecting:
```mermaid
graph TB
:app[app]:::android-application
:app -.-> :core:analytics
:app -.-> :core:ble
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:database
:app -.-> :core:datastore
:app -.-> :core:di
:app -.-> :core:domain
:app -.-> :core:model
:app -.-> :core:navigation
:app -.-> :core:network
@ -42,27 +42,20 @@ graph TB
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :core:takserver
:app -.-> :feature:intro
:app -.-> :feature:messaging
:app -.-> :feature:connections
:app -.-> :feature:map
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
:app -.-> :feature:wifi-provision
:app -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -29,11 +29,11 @@ plugins {
alias(libs.plugins.meshtastic.android.application)
alias(libs.plugins.meshtastic.android.application.flavors)
alias(libs.plugins.meshtastic.android.application.compose)
id("meshtastic.koin")
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.secrets)
alias(libs.plugins.aboutlibraries)
id("dev.mokkery")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@ -44,7 +44,7 @@ if (keystorePropertiesFile.exists()) {
}
configure<ApplicationExtension> {
namespace = "org.meshtastic.app"
namespace = configProperties.getProperty("APPLICATION_ID")
signingConfigs {
create("release") {
@ -150,7 +150,7 @@ configure<ApplicationExtension> {
includeInBundle = false
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.geeksville.mesh.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
}
}
@ -208,16 +210,15 @@ project.afterEvaluate {
}
dependencies {
implementation(projects.core.analytics)
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network)
implementation(projects.core.nfc)
implementation(projects.core.prefs)
@ -226,113 +227,83 @@ dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
implementation(projects.core.takserver)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.navigationSuite)
implementation(libs.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)
implementation(libs.androidx.lifecycle.process)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.coil)
implementation(libs.coil.network.ktor3)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
implementation(libs.org.eclipse.paho.client.mqttv3)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
implementation(libs.nordic.client.android)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
implementation(libs.nordic.common.permissions.notification)
implementation(libs.nordic.common.scanner.ble)
implementation(libs.nordic.common.ui)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)
googleImplementation(libs.dd.sdk.android.session.replay.material)
googleImplementation(libs.dd.sdk.android.timber)
googleImplementation(libs.dd.sdk.android.trace)
googleImplementation(libs.dd.sdk.android.trace.otel)
googleImplementation(platform(libs.firebase.bom))
googleImplementation(libs.firebase.analytics)
googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)
testRuntimeOnly(libs.junit.vintage.engine)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.nordic.client.android.mock)
androidTestImplementation(libs.nordic.core.mock)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.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)
val ghToken = providers.environmentVariable("GITHUB_TOKEN")
offlineMode = !isReleaseBuild
collect {
fetchRemoteLicense = isReleaseBuild && ghToken.isPresent
fetchRemoteFunding = isReleaseBuild && ghToken.isPresent
if (ghToken.isPresent) {
gitHubApiToken = ghToken.get()
}
}
export {
excludeFields = listOf("generated")
outputFile = file("src/main/resources/aboutlibraries.json")
}
export { excludeFields = listOf("generated") }
library {
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.SIMPLE
}
}
// Ensure aboutlibraries.json is always up-to-date during the build.
// This is required since AboutLibraries v11+ no longer auto-exports.
tasks
.matching { it.name.startsWith("process") && it.name.endsWith("Resources") }
.configureEach { dependsOn("exportLibraryDefinitions") }

View file

@ -0,0 +1,415 @@
androidx.activity:activity-compose:1.12.3
androidx.activity:activity-ktx:1.12.3
androidx.activity:activity:1.12.3
androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm:1.9.1
androidx.annotation:annotation:1.9.1
androidx.appcompat:appcompat-resources:1.7.1
androidx.appcompat:appcompat:1.7.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.cardview:cardview:1.0.0
androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.11.0-alpha04
androidx.compose.animation:animation-core-android:1.11.0-alpha04
androidx.compose.animation:animation-core:1.11.0-alpha04
androidx.compose.animation:animation:1.11.0-alpha04
androidx.compose.foundation:foundation-android:1.11.0-alpha04
androidx.compose.foundation:foundation-layout-android:1.11.0-alpha04
androidx.compose.foundation:foundation-layout:1.11.0-alpha04
androidx.compose.foundation:foundation:1.11.0-alpha04
androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive:1.3.0-alpha07
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha13
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha13
androidx.compose.material3:material3-android:1.5.0-alpha13
androidx.compose.material3:material3:1.5.0-alpha13
androidx.compose.material:material-android:1.11.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.11.0-alpha04
androidx.compose.material:material-ripple:1.11.0-alpha04
androidx.compose.material:material:1.11.0-alpha04
androidx.compose.runtime:runtime-android:1.11.0-alpha04
androidx.compose.runtime:runtime-annotation-android:1.11.0-alpha04
androidx.compose.runtime:runtime-annotation:1.11.0-alpha04
androidx.compose.runtime:runtime-livedata:1.11.0-alpha04
androidx.compose.runtime:runtime-retain-android:1.11.0-alpha04
androidx.compose.runtime:runtime-retain:1.11.0-alpha04
androidx.compose.runtime:runtime-saveable-android:1.11.0-alpha04
androidx.compose.runtime:runtime-saveable:1.11.0-alpha04
androidx.compose.runtime:runtime-tracing:1.11.0-alpha04
androidx.compose.runtime:runtime:1.11.0-alpha04
androidx.compose.ui:ui-android:1.11.0-alpha04
androidx.compose.ui:ui-geometry-android:1.11.0-alpha04
androidx.compose.ui:ui-geometry:1.11.0-alpha04
androidx.compose.ui:ui-graphics-android:1.11.0-alpha04
androidx.compose.ui:ui-graphics:1.11.0-alpha04
androidx.compose.ui:ui-text-android:1.11.0-alpha04
androidx.compose.ui:ui-text:1.11.0-alpha04
androidx.compose.ui:ui-tooling-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-data-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-data:1.11.0-alpha04
androidx.compose.ui:ui-tooling-preview-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-preview:1.11.0-alpha04
androidx.compose.ui:ui-tooling:1.11.0-alpha04
androidx.compose.ui:ui-unit-android:1.11.0-alpha04
androidx.compose.ui:ui-unit:1.11.0-alpha04
androidx.compose.ui:ui-util-android:1.11.0-alpha04
androidx.compose.ui:ui-util:1.11.0-alpha04
androidx.compose.ui:ui:1.11.0-alpha04
androidx.compose:compose-bom-alpha:2026.01.01
androidx.compose:compose-bom:2026.01.00
androidx.concurrent:concurrent-futures-ktx:1.1.0
androidx.concurrent:concurrent-futures:1.1.0
androidx.constraintlayout:constraintlayout-core:1.0.0
androidx.constraintlayout:constraintlayout:2.1.0
androidx.coordinatorlayout:coordinatorlayout:1.1.0
androidx.core:core-ktx:1.17.0
androidx.core:core-location-altitude-external-protobuf:1.0.0-beta01
androidx.core:core-location-altitude-proto:1.0.0-beta01
androidx.core:core-location-altitude:1.0.0-beta01
androidx.core:core-splashscreen:1.2.0
androidx.core:core-viewtree:1.0.0
androidx.core:core:1.17.0
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.1.0
androidx.databinding:viewbinding:8.13.2
androidx.datastore:datastore-android:1.2.0
androidx.datastore:datastore-core-android:1.2.0
androidx.datastore:datastore-core-okio-jvm:1.2.0
androidx.datastore:datastore-core-okio:1.2.0
androidx.datastore:datastore-core:1.2.0
androidx.datastore:datastore-preferences-android:1.2.0
androidx.datastore:datastore-preferences-core-android:1.2.0
androidx.datastore:datastore-preferences-core:1.2.0
androidx.datastore:datastore-preferences-external-protobuf:1.2.0
androidx.datastore:datastore-preferences-proto:1.2.0
androidx.datastore:datastore-preferences:1.2.0
androidx.datastore:datastore:1.2.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.1.1
androidx.dynamicanimation:dynamicanimation:1.1.0
androidx.emoji2:emoji2-emojipicker:1.6.0
androidx.emoji2:emoji2-views-helper:1.6.0
androidx.emoji2:emoji2:1.6.0
androidx.exifinterface:exifinterface:1.4.1
androidx.fragment:fragment-ktx:1.6.2
androidx.fragment:fragment:1.6.2
androidx.graphics:graphics-path:1.0.1
androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1
androidx.hilt:hilt-common:1.3.0
androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0
androidx.hilt:hilt-lifecycle-viewmodel:1.3.0
androidx.hilt:hilt-work:1.3.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.10.0
androidx.lifecycle:lifecycle-common-jvm:2.10.0
androidx.lifecycle:lifecycle-common:2.10.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata-core:2.10.0
androidx.lifecycle:lifecycle-livedata-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata:2.10.0
androidx.lifecycle:lifecycle-process:2.10.0
androidx.lifecycle:lifecycle-runtime-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
androidx.lifecycle:lifecycle-runtime:2.10.0
androidx.lifecycle:lifecycle-service:2.10.0
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
androidx.lifecycle:lifecycle-viewmodel:2.10.0
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
androidx.metrics:metrics-performance:1.0.0-beta03
androidx.navigation3:navigation3-runtime-android:1.0.0
androidx.navigation3:navigation3-runtime:1.0.0
androidx.navigation3:navigation3-ui-android:1.0.0
androidx.navigation3:navigation3-ui:1.0.0
androidx.navigation:navigation-common-android:2.9.7
androidx.navigation:navigation-common:2.9.7
androidx.navigation:navigation-compose-android:2.9.7
androidx.navigation:navigation-compose:2.9.7
androidx.navigation:navigation-fragment:2.9.7
androidx.navigation:navigation-runtime-android:2.9.7
androidx.navigation:navigation-runtime:2.9.7
androidx.navigationevent:navigationevent-android:1.0.2
androidx.navigationevent:navigationevent-compose-android:1.0.2
androidx.navigationevent:navigationevent-compose:1.0.2
androidx.navigationevent:navigationevent:1.0.2
androidx.paging:paging-common-android:3.4.0
androidx.paging:paging-common:3.4.0
androidx.paging:paging-compose-android:3.4.0
androidx.paging:paging-compose:3.4.0
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11
androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11
androidx.profileinstaller:profileinstaller:1.4.1
androidx.recyclerview:recyclerview:1.3.2
androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common-jvm:2.8.4
androidx.room:room-common:2.8.4
androidx.room:room-paging-android:2.8.4
androidx.room:room-paging:2.8.4
androidx.room:room-runtime-android:2.8.4
androidx.room:room-runtime:2.8.4
androidx.savedstate:savedstate-android:1.4.0
androidx.savedstate:savedstate-compose-android:1.4.0
androidx.savedstate:savedstate-compose:1.4.0
androidx.savedstate:savedstate-ktx:1.4.0
androidx.savedstate:savedstate:1.4.0
androidx.slidingpanelayout:slidingpanelayout:1.2.0
androidx.sqlite:sqlite-android:2.6.2
androidx.sqlite:sqlite-framework-android:2.6.2
androidx.sqlite:sqlite-framework:2.6.2
androidx.sqlite:sqlite:2.6.2
androidx.startup:startup-runtime:1.2.0
androidx.tracing:tracing-ktx:1.2.0
androidx.tracing:tracing-perfetto:1.0.1
androidx.tracing:tracing:1.2.0
androidx.transition:transition:1.6.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager2:viewpager2:1.1.0-beta02
androidx.viewpager:viewpager:1.0.0
androidx.window:window-core-android:1.5.0
androidx.window:window-core:1.5.0
androidx.window:window:1.5.0
androidx.work:work-runtime-ktx:2.11.1
androidx.work:work-runtime:2.11.1
co.touchlab:kermit-android:2.0.8
co.touchlab:kermit-core-android:2.0.8
co.touchlab:kermit-core:2.0.8
co.touchlab:kermit:2.0.8
com.caverock:androidsvg-aar:1.4
com.datadoghq:dd-sdk-android-compose:3.6.0
com.datadoghq:dd-sdk-android-core:3.6.0
com.datadoghq:dd-sdk-android-internal:3.6.0
com.datadoghq:dd-sdk-android-logs:3.6.0
com.datadoghq:dd-sdk-android-okhttp:3.6.0
com.datadoghq:dd-sdk-android-rum:3.6.0
com.datadoghq:dd-sdk-android-session-replay-compose:3.6.0
com.datadoghq:dd-sdk-android-session-replay:3.6.0
com.datadoghq:dd-sdk-android-timber:3.6.0
com.datadoghq:dd-sdk-android-trace-api:3.6.0
com.datadoghq:dd-sdk-android-trace-internal:3.6.0
com.datadoghq:dd-sdk-android-trace-otel:3.6.0
com.datadoghq:dd-sdk-android-trace:3.6.0
com.github.mik3y:usb-serial-for-android:3.10.0
com.google.accompanist:accompanist-drawablepainter:0.37.3
com.google.accompanist:accompanist-permissions:0.37.3
com.google.android.datatransport:transport-api:3.2.0
com.google.android.datatransport:transport-backend-cct:3.3.0
com.google.android.datatransport:transport-runtime:3.3.0
com.google.android.gms:play-services-ads-identifier:18.0.0
com.google.android.gms:play-services-base:18.5.0
com.google.android.gms:play-services-basement:18.9.0
com.google.android.gms:play-services-location:21.3.0
com.google.android.gms:play-services-maps:20.0.0
com.google.android.gms:play-services-measurement-api:23.0.0
com.google.android.gms:play-services-measurement-base:23.0.0
com.google.android.gms:play-services-measurement-impl:23.0.0
com.google.android.gms:play-services-measurement-sdk-api:23.0.0
com.google.android.gms:play-services-measurement-sdk:23.0.0
com.google.android.gms:play-services-measurement:23.0.0
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.4.0
com.google.android.material:material:1.13.0
com.google.auto.value:auto-value-annotations:1.6.3
com.google.code.findbugs:jsr305:3.0.2
com.google.code.gson:gson:2.13.2
com.google.dagger:dagger-lint-aar:2.59
com.google.dagger:dagger:2.59
com.google.dagger:hilt-android:2.59
com.google.dagger:hilt-core:2.59
com.google.errorprone:error_prone_annotations:2.41.0
com.google.firebase:firebase-analytics:23.0.0
com.google.firebase:firebase-annotations:17.0.0
com.google.firebase:firebase-bom:34.8.0
com.google.firebase:firebase-common:22.0.1
com.google.firebase:firebase-components:19.0.0
com.google.firebase:firebase-config-interop:16.0.1
com.google.firebase:firebase-crashlytics:20.0.4
com.google.firebase:firebase-datatransport:19.0.0
com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-installations-interop:17.2.0
com.google.firebase:firebase-installations:19.0.1
com.google.firebase:firebase-measurement-connector:20.0.1
com.google.firebase:firebase-sessions:3.0.4
com.google.guava:failureaccess:1.0.3
com.google.guava:guava:33.5.0-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:3.1
com.google.maps.android:android-maps-utils:4.0.0
com.google.maps.android:maps-compose-utils:8.0.0
com.google.maps.android:maps-compose-widgets:8.0.0
com.google.maps.android:maps-compose:8.0.0
com.google.maps.android:maps-ktx:6.0.0
com.google.maps.android:maps-utils-ktx:6.0.0
com.google.re2j:re2j:1.7
com.google.zxing:core:3.5.4
com.jakewharton.timber:timber:5.0.1
com.journeyapps:zxing-android-embedded:4.3.0
com.lyft.kronos:kronos-android:0.0.1-alpha11
com.lyft.kronos:kronos-java:0.0.1-alpha11
com.mikepenz:aboutlibraries-compose-core-android:13.2.1
com.mikepenz:aboutlibraries-compose-core:13.2.1
com.mikepenz:aboutlibraries-compose-m3-android:13.2.1
com.mikepenz:aboutlibraries-compose-m3:13.2.1
com.mikepenz:aboutlibraries-core-android:13.2.1
com.mikepenz:aboutlibraries-core:13.2.1
com.mikepenz:multiplatform-markdown-renderer-android:0.39.2
com.mikepenz:multiplatform-markdown-renderer-m3-android:0.39.2
com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2
com.mikepenz:multiplatform-markdown-renderer:0.39.2
com.patrykandpatrick.vico:compose-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m2-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m2:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m3-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m3:3.0.0-beta.3
com.patrykandpatrick.vico:compose:3.0.0-beta.3
com.squareup.okhttp3:logging-interceptor:5.3.2
com.squareup.okhttp3:okhttp-android:5.3.2
com.squareup.okhttp3:okhttp:5.3.2
com.squareup.okio:okio-jvm:3.16.4
com.squareup.okio:okio:3.16.4
com.squareup.wire:wire-runtime-jvm:5.2.1
com.squareup.wire:wire-runtime:5.2.1
io.coil-kt.coil3:coil-android:3.3.0
io.coil-kt.coil3:coil-compose-android:3.3.0
io.coil-kt.coil3:coil-compose-core-android:3.3.0
io.coil-kt.coil3:coil-compose-core:3.3.0
io.coil-kt.coil3:coil-compose:3.3.0
io.coil-kt.coil3:coil-core-android:3.3.0
io.coil-kt.coil3:coil-core:3.3.0
io.coil-kt.coil3:coil-network-core-android:3.3.0
io.coil-kt.coil3:coil-network-core:3.3.0
io.coil-kt.coil3:coil-network-okhttp-jvm:3.3.0
io.coil-kt.coil3:coil-network-okhttp:3.3.0
io.coil-kt.coil3:coil-svg-android:3.3.0
io.coil-kt.coil3:coil-svg:3.3.0
io.coil-kt.coil3:coil:3.3.0
io.ktor:ktor-client-content-negotiation-jvm:3.4.0
io.ktor:ktor-client-content-negotiation:3.4.0
io.ktor:ktor-client-core-jvm:3.4.0
io.ktor:ktor-client-core:3.4.0
io.ktor:ktor-client-okhttp-jvm:3.4.0
io.ktor:ktor-client-okhttp:3.4.0
io.ktor:ktor-events-jvm:3.4.0
io.ktor:ktor-events:3.4.0
io.ktor:ktor-http-cio-jvm:3.4.0
io.ktor:ktor-http-cio:3.4.0
io.ktor:ktor-http-jvm:3.4.0
io.ktor:ktor-http:3.4.0
io.ktor:ktor-io-jvm:3.4.0
io.ktor:ktor-io:3.4.0
io.ktor:ktor-network-jvm:3.4.0
io.ktor:ktor-network:3.4.0
io.ktor:ktor-serialization-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx-json-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx-json:3.4.0
io.ktor:ktor-serialization-kotlinx-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx:3.4.0
io.ktor:ktor-serialization:3.4.0
io.ktor:ktor-sse-jvm:3.4.0
io.ktor:ktor-sse:3.4.0
io.ktor:ktor-utils-jvm:3.4.0
io.ktor:ktor-utils:3.4.0
io.ktor:ktor-websocket-serialization-jvm:3.4.0
io.ktor:ktor-websocket-serialization:3.4.0
io.ktor:ktor-websockets-jvm:3.4.0
io.ktor:ktor-websockets:3.4.0
io.opentelemetry:opentelemetry-api:1.40.0
io.opentelemetry:opentelemetry-context:1.40.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1
no.nordicsemi.android:dfu:2.10.1
no.nordicsemi.kotlin.ble:client-android:2.0.0-alpha12
no.nordicsemi.kotlin.ble:client-core-android:2.0.0-alpha12
no.nordicsemi.kotlin.ble:client-core:2.0.0-alpha12
no.nordicsemi.kotlin.ble:core:2.0.0-alpha12
org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5
org.jctools:jctools-core:3.3.0
org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6
org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6
org.jetbrains.androidx.savedstate:savedstate:1.3.6
org.jetbrains.compose.animation:animation-core:1.10.0
org.jetbrains.compose.animation:animation:1.10.0
org.jetbrains.compose.annotation-internal:annotation:1.10.0
org.jetbrains.compose.collection-internal:collection:1.10.0
org.jetbrains.compose.components:components-resources-android:1.10.0
org.jetbrains.compose.components:components-resources:1.10.0
org.jetbrains.compose.foundation:foundation-layout:1.10.0
org.jetbrains.compose.foundation:foundation:1.10.0
org.jetbrains.compose.material3:material3:1.9.0
org.jetbrains.compose.material:material-ripple:1.10.0
org.jetbrains.compose.material:material:1.10.0
org.jetbrains.compose.runtime:runtime-saveable:1.10.0
org.jetbrains.compose.runtime:runtime:1.10.0
org.jetbrains.compose.ui:ui-backhandler-android:1.9.1
org.jetbrains.compose.ui:ui-backhandler:1.9.1
org.jetbrains.compose.ui:ui-geometry:1.10.0
org.jetbrains.compose.ui:ui-graphics:1.10.0
org.jetbrains.compose.ui:ui-text:1.10.0
org.jetbrains.compose.ui:ui-tooling-preview:1.10.0-rc02
org.jetbrains.compose.ui:ui-unit:1.10.0
org.jetbrains.compose.ui:ui-util:1.10.0
org.jetbrains.compose.ui:ui:1.10.0
org.jetbrains.kotlin:kotlin-bom:1.8.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21
org.jetbrains.kotlin:kotlin-stdlib:2.3.0
org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0
org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat
org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat
org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.2
org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.2
org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.2
org.jetbrains.kotlinx:kotlinx-io-core:0.8.2
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0
org.jetbrains:annotations:23.0.0
org.jetbrains:markdown-jvm:0.7.3
org.jetbrains:markdown:0.7.3
org.jspecify:jspecify:1.0.0
org.slf4j:slf4j-api:2.0.17

View file

@ -1,5 +1,92 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues/>
<CurrentIssues>
<ID>CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib</ID>
<ID>CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError</ID>
<ID>CyclomaticComplexMethod:MeshMessageProcessor.kt$MeshMessageProcessor$private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?)</ID>
<ID>CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController)</ID>
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
<ID>FinalNewline:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt</ID>
<ID>FinalNewline:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt</ID>
<ID>FinalNewline:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt</ID>
<ID>FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
<ID>MagicNumber:Contacts.kt$7</ID>
<ID>MagicNumber:Contacts.kt$8</ID>
<ID>MagicNumber:MQTTRepository.kt$MQTTRepository$512</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:ServiceClient.kt$ServiceClient$500</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"</ID>
<ID>NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
<ID>NewLineAtEndOfFile:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt</ID>
<ID>NewLineAtEndOfFile:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt</ID>
<ID>NewLineAtEndOfFile:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$ </ID>
<ID>NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$ </ID>
<ID>NoConsecutiveBlankLines:DebugLogFile.kt$ </ID>
<ID>NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>NoSemicolons:DateUtils.kt$DateUtils$;</ID>
<ID>OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract</ID>
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>ReturnCount:MeshDataHandler.kt$MeshDataHandler$@Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket)</ID>
<ID>ReturnCount:MeshDataHandler.kt$MeshDataHandler$private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean</ID>
<ID>SwallowedException:Exceptions.kt$ex: Throwable</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshDataHandler.kt$MeshDataHandler$e: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable</ID>
<ID>TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable</ID>
<ID>TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Haven't called connect")</ID>
<ID>TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface</ID>
<ID>TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService</ID>
<ID>TooManyFunctions:UIState.kt$UIViewModel : ViewModel</ID>
<ID>UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,45 +1,50 @@
# ============================================================================
# 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
# Needed for protobufs
-keep class com.google.protobuf.** { *; }
-keep class org.meshtastic.proto.** { *; }
# ---- Networking (transitive references from Ktor on Android) ----------------
# eclipse.paho.client
-keep class org.eclipse.paho.client.mqttv3.logging.JSR47Logger { *; }
# OkHttp
-dontwarn okhttp3.internal.platform.**
-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.** { *; }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 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,18 +14,17 @@
* 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.util
import kotlin.test.Test
import kotlin.test.assertEquals
package com.geeksville.mesh
class UrlUtilsTest {
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
@Test
fun testEncode() {
assertEquals("Hello%20World", UrlUtils.encode("Hello World"))
assertEquals("abc-123._~", UrlUtils.encode("abc-123._~"))
assertEquals("%21%40%23%24%25", UrlUtils.encode("!@#$%"))
assertEquals("%C3%A1%C3%A9%C3%AD", UrlUtils.encode("áéí"))
@Suppress("unused")
class TestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.filter
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.service.filter.MessageFilterService
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MessageFilterIntegrationTest {
@get:Rule var hiltRule = HiltAndroidRule(this)
@Inject lateinit var filterPrefs: FilterPrefs
@Inject lateinit var filterService: MessageFilterService
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.filterEnabled = true
filterPrefs.filterWords = setOf("test", "spam")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))
assertTrue(filterService.shouldFilter("spam content"))
}
}

View file

@ -1,39 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
)
}
}

View file

@ -1,50 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
* ([NodeTrackOsmMap]).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}

View file

@ -1,162 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.roundToInt
/**
* A focused OSMDroid map composable that renders **only** a node's position track a dashed polyline with directional
* markers for each historical position.
*
* Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
* from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
* minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider
* so users can adjust the time range directly from the map.
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*
* Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
* location tracking. It is designed to be embedded inside the position-log adaptive layout.
*/
@Composable
fun NodeTrackOsmMap(
positions: List<Position>,
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val filteredPositions =
remember(positions, lastHeardTrackFilter) {
positions.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
}
val geoPoints =
remember(filteredPositions) {
filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
}
val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(mapStyleId),
)
var filterMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
AndroidView(
modifier = Modifier.matchParentSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
// Center on selected position
if (selectedPositionTime != null) {
val selected = filteredPositions.find { it.time == selectedPositionTime }
if (selected != null) {
val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D)
map.controller.animateTo(point)
}
}
},
)
// Track filter controls overlay
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { filterMenuExpanded = true },
filterDropdownContent = {
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
},
)
}
}

View file

@ -1,41 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
* ([TracerouteOsmMap]).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View file

@ -1,288 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.app.map.traceroute
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
/**
* A focused OSMDroid map composable that renders **only** traceroute visualization node markers for each hop and
* forward/return offset polylines with auto-centering camera.
*
* Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
* map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
*/
@Composable
fun TracerouteOsmMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val context = LocalContext.current
val density = LocalDensity.current
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
// Resolve which nodes to display for the traceroute
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val displayNodes = tracerouteSelection.nodesForMarkers
val nodeLookup = tracerouteSelection.nodeLookup
// Report mappable count
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
}
}
// Compute polyline GeoPoints from node positions
val forwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val returnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
// Compute offset polylines for visual separation
val headingReferencePoints =
remember(forwardPoints, returnPoints) {
when {
forwardPoints.size >= 2 -> forwardPoints
returnPoints.size >= 2 -> returnPoints
else -> emptyList()
}
}
val forwardOffsetPoints =
remember(forwardPoints, headingReferencePoints) {
offsetPolyline(
points = forwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = 1.0,
)
}
val returnOffsetPoints =
remember(returnPoints, headingReferencePoints) {
offsetPolyline(
points = returnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = -1.0,
)
}
// Camera auto-center
var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
// Build initial camera from all traceroute points
val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
val initialCameraView =
remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = mapViewModel.applicationId,
box = initialCameraView ?: BoundingBox(),
tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
)
// Center camera on traceroute bounds
LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
mapView.controller.setCenter(allPoints.first())
mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
mapView.zoomToBoundingBox(
BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
true,
)
}
hasCentered = true
}
}
AndroidView(
modifier = modifier,
factory = { mapView.apply { setDestroyMode(false) } },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
// Render traceroute polylines
buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
// Render simple node markers
displayNodes.forEach { node ->
val position = GeoPoint(node.latitude, node.longitude)
val marker =
MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
.apply {
id = node.user.id
title = node.user.long_name
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
this.position = position
icon = markerIcon
setNodeColors(node.colors)
}
map.overlays.add(marker)
}
map.invalidate()
},
)
}
private fun buildTraceroutePolylines(
forwardPoints: List<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
}
return polylines
}
// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
private fun Double.toRad(): Double = this * PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node.component
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.meshtastic.core.model.Node
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
val map = remember {
MapView(context).apply {
layoutParams =
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
// Default osmdroid tile source.
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
controller.setZoom(15.0)
}
}
LaunchedEffect(node.num) {
val point = GeoPoint(node.latitude, node.longitude)
map.overlays.clear()
val marker =
Marker(map).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
map.overlays.add(marker)
map.controller.animateTo(point)
}
AndroidView(factory = { map }, modifier = modifier)
}

View file

@ -1,21 +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
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()

View file

@ -1,39 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,54 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
)
}
}

View file

@ -1,58 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
* filter).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(
modifier = modifier,
mode =
GoogleMapMode.NodeTrack(
focusedNode = focusedNode,
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
),
)
}

View file

@ -1,45 +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.prefs.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}

View file

@ -1,196 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.prefs.map
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
interface GoogleMapsPrefs {
val selectedGoogleMapType: StateFlow<String?>
fun setSelectedGoogleMapType(value: String?)
val selectedCustomTileUrl: StateFlow<String?>
fun setSelectedCustomTileUrl(value: String?)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val cameraTargetLat: StateFlow<Double>
fun setCameraTargetLat(value: Double)
val cameraTargetLng: StateFlow<Double>
fun setCameraTargetLng(value: Double)
val cameraZoom: StateFlow<Float>
fun setCameraZoom(value: Float)
val cameraTilt: StateFlow<Float>
fun setCameraTilt(value: Float)
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
@Single
class GoogleMapsPrefsImpl(
@Named("GoogleMapsDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : GoogleMapsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val selectedGoogleMapType: StateFlow<String?> =
dataStore.data
.map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
.stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
override fun setSelectedGoogleMapType(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
} else {
prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
}
}
}
}
override val selectedCustomTileUrl: StateFlow<String?> =
dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setSelectedCustomTileUrl(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
} else {
prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
}
}
}
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
}
override val cameraTargetLat: StateFlow<Double> =
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLat(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
}
override val cameraTargetLng: StateFlow<Double> =
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLng(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
}
override val cameraZoom: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
override fun setCameraZoom(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
}
override val cameraTilt: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraTilt(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
}
override val cameraBearing: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraBearing(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
}
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
}
companion object {
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
}
}

View file

@ -1,46 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
* mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets(
overlayAlignment = Alignment.BottomCenter,
overlayPadding = PaddingValues(bottom = 16.dp),
contentHorizontalAlignment = Alignment.CenterHorizontally,
)

View file

@ -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" />
<!--
@ -105,7 +102,7 @@
</queries>
<application
android:name="org.meshtastic.app.MeshUtilApplication"
android:name="com.geeksville.mesh.MeshUtilApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
@ -155,7 +152,7 @@
<!-- This is the public API for doing mesh radio operations from android apps -->
<service
android:name="org.meshtastic.core.service.MeshService"
android:name="com.geeksville.mesh.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:exported="true" tools:ignore="ExportedActivity">
@ -174,7 +171,7 @@
</service>
<activity
android:name="org.meshtastic.app.MainActivity"
android:name="com.geeksville.mesh.MainActivity"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:exported="true">
@ -201,7 +198,7 @@
<intent-filter android:autoVerify="true">
<!--
The QR codes to share channel settings and contacts are shared as meshtastic URLS.
We also support NFC NDEF Discovery for the same URLS.
We also support NFC NDEF Discovery for the same URLs.
An approximate example:
https://meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
@ -221,27 +218,6 @@
<data android:pathPrefix="/V/" />
</intent-filter>
<!-- App Links for modern RESTful navigation paths -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="meshtastic.org" />
<data android:pathPrefix="/share" />
<data android:pathPrefix="/connections" />
<data android:pathPrefix="/map" />
<data android:pathPrefix="/messages" />
<data android:pathPrefix="/quickchat" />
<data android:pathPrefix="/nodes" />
<data android:pathPrefix="/settings" />
<data android:pathPrefix="/channels" />
<data android:pathPrefix="/firmware" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
@ -252,7 +228,7 @@
android:resource="@xml/device_filter" />
</activity>
<receiver android:name="org.meshtastic.core.service.BootCompleteReceiver"
<receiver android:name="com.geeksville.mesh.service.BootCompleteReceiver"
android:exported="false">
<!-- handle boot events -->
<intent-filter>
@ -276,19 +252,19 @@
android:path="com.geeksville.mesh" /> -->
</intent-filter>
</receiver>
<receiver android:name="org.meshtastic.core.service.ReplyReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.ReactionReceiver" android:exported="false" />
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
<receiver
android:name="org.meshtastic.feature.widget.LocalStatsWidgetReceiver"
android:name="com.geeksville.mesh.widget.LocalStatsWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</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 -->
@ -301,17 +277,6 @@
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View file

@ -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": [
@ -1289,7 +1273,7 @@
"hwModelSlug": "WISMESH_TAP_V2",
"platformioTarget": "rak_wismesh_tap_v2",
"architecture": "esp32-s3",
"activelySupported": true,
"activelySupported": false,
"supportLevel": 1,
"displayName": "RAK WisMesh Tap 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": [
@ -1365,38 +1349,5 @@
"images": [
"tbeam-1w.svg"
]
},
{
"hwModel": 123,
"hwModelSlug": "T5_S3_EPAPER_PRO",
"platformioTarget": "t5-epaper-s3",
"architecture": "esp32-s3",
"activelySupported": false,
"supportLevel": 1,
"displayName": "LilyGo T5 E-paper S3 Pro",
"tags": [
"LilyGo"
],
"hasMui": false,
"partitionScheme": "8MB",
"images": [
"t5s3_epaper.svg"
]
},
{
"hwModel": 125,
"hwModelSlug": "MINI_EPAPER_S3",
"platformioTarget": "mini-epaper-s3",
"architecture": "esp32-s3",
"activelySupported": false,
"supportLevel": 1,
"displayName": "LilyGo Mini E-paper S3",
"tags": [
"LilyGo"
],
"hasMui": false,
"images": [
"mini-epaper-s3.svg"
]
}
]

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.geeksville.mesh.service.MeshServiceNotificationsImpl
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
interface ApplicationModule {
@Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
companion object {
@Provides @ProcessLifecycle
fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
@Provides
@ProcessLifecycle
fun provideProcessLifecycle(@ProcessLifecycle processLifecycleOwner: LifecycleOwner): Lifecycle =
processLifecycleOwner.lifecycle
@Singleton
@Provides
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = BuildConfig.DEBUG
override val applicationId: String = BuildConfig.APPLICATION_ID
override val versionCode: Int = BuildConfig.VERSION_CODE
override val versionName: String = BuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
}
}
}

View file

@ -0,0 +1,217 @@
/*
* 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 com.geeksville.mesh
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Intent
import android.graphics.Color
import android.hardware.usb.UsbManager
import android.net.Uri
import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.MainScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.intro.AppIntroductionScreen
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModels()
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
*/
@Inject internal lateinit var meshServiceClient: MeshServiceClient
@Inject internal lateinit var androidEnvironment: AndroidEnvironment
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setContent {
val theme by model.theme.collectAsStateWithLifecycle()
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
AppCompatDelegate.MODE_NIGHT_YES -> true
AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemInDarkTheme()
}
// Apply modern edge-to-edge drawing with theme-aware system bars
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
)
// Ensure the navigation bar remains seamless on modern Android versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@Suppress("SpreadOperator")
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
// once we've decided whether to show the intro or the main screen.
ReportDrawnWhen { true }
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
}
}
}
}
// Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
addOnNewIntentListener { intent -> handleIntent(intent) }
handleIntent(intent)
}
@Suppress("NestedBlockDepth")
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action
val appLinkData: Uri? = intent.data
when (appLinkAction) {
Intent.ACTION_VIEW -> {
appLinkData?.let { handleMeshtasticUri(it) }
}
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages =
IntentCompat.getParcelableArrayExtra(
intent,
NfcAdapter.EXTRA_NDEF_MESSAGES,
NdefMessage::class.java,
)
if (rawMessages != null) {
for (rawMsg in rawMessages) {
val msg = rawMsg as NdefMessage
for (record in msg.records) {
record.toUri()?.let { handleMeshtasticUri(it) }
}
}
}
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
showSettingsPage()
}
Intent.ACTION_MAIN -> {}
Intent.ACTION_SEND -> {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null) {
createShareIntent(text).send()
}
}
else -> {
Logger.w { "Unexpected action $appLinkAction" }
}
}
}
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
model.handleNavigationDeepLink(uri)
return
}
uri.dispatchMeshtasticUri(
onChannel = { model.setRequestChannelSet(it) },
onContact = { model.setSharedContactRequested(it) },
onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
)
}
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent =
Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val resultPendingIntent: PendingIntent? =
TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private fun createSettingsIntent(): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/connections"
val startActivityIntent =
Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val resultPendingIntent: PendingIntent? =
TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private fun showSettingsPage() {
createSettingsIntent().send()
}
}

View file

@ -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.core.service
package com.geeksville.mesh
import android.content.Context
import android.content.Context.BIND_ABOVE_CLIENT
@ -23,16 +23,25 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.startService
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.core.common.util.SequentialJob
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceClient
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */
@Factory
@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding
class MeshServiceClient(
private val context: Context,
private val serviceRepository: AndroidServiceRepository,
@ActivityScoped
class MeshServiceClient
@Inject
constructor(
@ActivityContext private val context: Context,
private val serviceRepository: ServiceRepository,
private val serviceSetupJob: SequentialJob,
) : ServiceClient<IMeshService>(IMeshService.Stub::asInterface),
DefaultLifecycleObserver {

View file

@ -14,36 +14,39 @@
* 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
package com.geeksville.mesh
import android.app.Application
import android.appwidget.AppWidgetProviderInfo
import android.os.Build
import androidx.collection.intSetOf
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.widget.LocalStatsWidgetReceiver
import com.geeksville.mesh.worker.MeshLogCleanupWorker
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
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
import kotlinx.coroutines.launch
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 no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.service.worker.MeshLogCleanupWorker
import org.meshtastic.feature.widget.LocalStatsWidgetReceiver
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ -51,23 +54,22 @@ import kotlin.time.toJavaDuration
/**
* The main application class for Meshtastic.
*
* This class initializes core application components using Koin for dependency injection.
* This class is annotated with [HiltAndroidApp] to enable Hilt for dependency injection. It initializes core
* application components, including analytics and platform-specific helpers, and manages analytics consent based on
* user preferences.
*/
@HiltAndroidApp
open class MeshUtilApplication :
Application(),
Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val applicationScope = CoroutineScope(Dispatchers.Default)
override fun onCreate() {
super.onCreate()
ContextServices.app = this
startKoin<AndroidKoinApp> {
androidContext(this@MeshUtilApplication)
workManagerFactory()
}
// Schedule periodic MeshLog cleanup
scheduleMeshLogCleanup()
@ -91,11 +93,15 @@ open class MeshUtilApplication :
pushPreview()
val widgetStateProvider: org.meshtastic.feature.widget.LocalStatsWidgetStateProvider = get()
val entryPoint =
EntryPointAccessors.fromApplication(
this@MeshUtilApplication,
com.geeksville.mesh.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
)
try {
// Wait for real data for up to 30 seconds before pushing an updated preview
withTimeout(30.seconds) {
widgetStateProvider.state.first { it.showContent && it.nodeShortName != null }
entryPoint.widgetStateProvider().state.first { it.showContent && it.nodeShortName != null }
}
Logger.i { "Real node data acquired. Pushing updated widget preview." }
@ -107,19 +113,17 @@ open class MeshUtilApplication :
}
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
applicationScope.launch {
val dbManager: DatabaseManager = get()
val meshPrefs: MeshPrefs = get()
dbManager.init(meshPrefs.deviceAddress.value)
}
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
}
override fun onTerminate() {
// Shutdown managers (useful for Robolectric tests)
get<DatabaseManager>().close()
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
entryPoint.databaseManager().close()
entryPoint.androidEnvironment().close()
applicationScope.cancel()
super.onTerminate()
org.koin.core.context.stopKoin()
}
private fun scheduleMeshLogCleanup() {
@ -135,7 +139,19 @@ open class MeshUtilApplication :
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(get()).build()
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppEntryPoint {
fun databaseManager(): DatabaseManager
fun meshPrefs(): MeshPrefs
fun meshLogPrefs(): MeshLogPrefs
fun androidEnvironment(): AndroidEnvironment
}
fun logAssert(executeReliableWrite: Boolean) {

View file

@ -0,0 +1,202 @@
/*
* 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 com.geeksville.mesh.domain.usecase
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.meshtastic
import java.util.Locale
import javax.inject.Inject
data class DiscoveredDevices(
val bleDevices: List<DeviceListEntry>,
val usbDevices: List<DeviceListEntry>,
val discoveredTcpDevices: List<DeviceListEntry>,
val recentTcpDevices: List<DeviceListEntry>,
)
@Suppress("LongParameterList")
class GetDiscoveredDevicesUseCase
@Inject
constructor(
private val bluetoothRepository: BluetoothRepository,
private val networkRepository: NetworkRepository,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val usbRepository: UsbRepository,
private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: dagger.Lazy<UsbManager>,
) {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.attributes
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
val usbDevicesFlow =
usbRepository.serialDevices.map { usb ->
usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) }
}
return combine(
nodeDb,
bondedBleFlow,
processedTcpFlow,
usbDevicesFlow,
networkRepository.resolvedList,
recentAddressesDataSource.recentAddresses,
) { args: Array<Any> ->
@Suppress("UNCHECKED_CAST", "MagicNumber")
val db = args[0] as Map<Int, Node>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val bondedBle = args[1] as List<DeviceListEntry.Ble>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val processedTcp = args[2] as List<DeviceListEntry.Tcp>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val usbDevices = args[3] as List<DeviceListEntry.Usb>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val resolved = args[4] as List<NsdServiceInfo>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
val bleForUi =
bondedBle
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val usbForUi =
(usbDevices + if (showMock) listOf(DeviceListEntry.Mock("Demo Mode")) else emptyList()).map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
DiscoveredDevices(
bleDevices = bleForUi,
usbDevices = usbForUi,
discoveredTcpDevices = discoveredTcpForUi,
recentTcpDevices = recentTcpForUi,
)
}
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model
import android.hardware.usb.UsbManager
import com.geeksville.mesh.repository.radio.InterfaceId
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.anonymize
/**
* A sealed class is used here to represent the different types of devices that can be displayed in the list. This is
* more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for
* exhaustive `when` expressions in the code, making it more robust and readable.
*
* @param name The display name of the device.
* @param fullAddress The unique address of the device, prefixed with a type identifier.
* @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB).
* @param node The [Node] associated with this device, if found in the database.
*/
sealed class DeviceListEntry(
open val name: String,
open val fullAddress: String,
open val bonded: Boolean,
open val node: Node? = null,
) {
val address: String
get() = fullAddress.substring(1)
abstract fun copy(node: Node?): DeviceListEntry
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
@Suppress("MissingPermission")
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
DeviceListEntry(
name = peripheral.name ?: "unnamed-${peripheral.address}",
fullAddress = "x${peripheral.address}",
bonded = peripheral.bondState.value == BondState.BONDED,
node = node,
) {
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
}
data class Usb(
private val radioInterfaceService: RadioInterfaceService,
private val usbManager: UsbManager,
val driver: UsbSerialDriver,
override val node: Node? = null,
) : DeviceListEntry(
name = driver.device.deviceName,
fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName),
bonded = usbManager.hasPermission(driver.device),
node = node,
) {
override fun copy(node: Node?): Usb =
copy(radioInterfaceService = radioInterfaceService, usbManager = usbManager, driver = driver, node = node)
}
data class Tcp(override val name: String, override val fullAddress: String, override val node: Node? = null) :
DeviceListEntry(name, fullAddress, true, node) {
override fun copy(node: Node?): Tcp = copy(name = name, fullAddress = fullAddress, node = node)
}
data class Mock(override val name: String, override val node: Node? = null) :
DeviceListEntry(name, "m", true, node) {
override fun copy(node: Node?): Mock = copy(name = name, node = node)
}
}
/** Matches names like Meshtastic_1234. */
private val bleNameRegex = Regex(BLE_NAME_PATTERN)
/**
* Returns the short name of the device if it's a Meshtastic device, otherwise null.
*
* @return The short name (e.g., 1234) or null.
*/
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }

View file

@ -14,16 +14,23 @@
* 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.ui.viewmodel
package com.geeksville.mesh.model
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
@ -32,90 +39,54 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
/**
* Shared base for the application-level ViewModel.
*
* Contains all platform-independent state and actions (themes, alerts, connection state, firmware checks, traceroute,
* shared contacts, channel sets, unread counts, etc.).
*/
@KoinViewModel
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class UIViewModel(
class UIViewModel
@Inject
constructor(
private val nodeDB: NodeRepository,
protected val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPrefs: UiPrefs,
private val notificationManager: NotificationManager,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
packetRepository: PacketRepository,
val alertManager: AlertManager,
val snackbarManager: SnackbarManager,
private val alertManager: AlertManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow<List<NavKey>>(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
/**
* Unified handler for all Meshtastic deep links and OS intents.
*
* This method orchestrates two distinct types of URI handling:
* 1. **Navigation:** First attempts to parse the URI into a typed [NavKey] backstack via [DeepLinkRouter]. If
* successful, navigates the user to the target screen.
* 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
*/
fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) {
// Try navigation routing first
val navKeys = DeepLinkRouter.route(uri)
if (navKeys != null) {
_navigationDeepLink.tryEmit(navKeys)
return
}
// Fallback to channel/contact importing
uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
)
}
val theme: StateFlow<Int> = uiPrefs.theme
val contrastLevel: StateFlow<Int> = uiPrefs.contrastLevel
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
@ -123,13 +94,15 @@ class UIViewModel(
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
notificationManager.cancel(notification.toString().hashCode())
meshServiceNotifications.clearClientNotification(notification)
}
/** Emits events for mesh network send/receive activity. */
val meshActivity: Flow<MeshActivity> = radioInterfaceService.meshActivity
val currentDeviceAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
/**
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
* even if they are the same.
*/
val meshActivity: SharedFlow<MeshActivity> =
radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _scrollToTopEventFlow =
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@ -139,6 +112,8 @@ class UIViewModel(
_scrollToTopEventFlow.tryEmit(event)
}
val currentAlert = alertManager.currentAlert
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
@ -183,19 +158,21 @@ class UIViewModel(
alertManager.dismissAlert()
}
fun showSnackbar(message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
snackbarManager.showSnackbar(message = message, actionLabel = actionLabel, onAction = onAction)
}
fun setDeviceAddress(address: String) {
radioController.setDeviceAddress(address)
}
val meshService: IMeshService?
get() = serviceRepository.meshService
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
private val _navigationDeepLink = MutableSharedFlow<Uri>(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
fun handleNavigationDeepLink(uri: Uri) {
_navigationDeepLink.tryEmit(uri)
}
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeInfo?>
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
init {
@ -226,7 +203,7 @@ class UIViewModel(
}
.launchIn(viewModelScope)
Logger.d { "UIViewModel created" }
Logger.d { "ViewModel created" }
}
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
@ -237,12 +214,12 @@ class UIViewModel(
_sharedContactRequested.value = contact
}
/** Clears the pending shared contact request. */
/** Called immediately after activity observes requestChannelUrl */
fun clearSharedContactRequested() {
_sharedContactRequested.value = null
}
/** Canonical app-level connection state, sourced from [ServiceRepository.connectionState]. */
// Connection state to our radio device
val connectionState
get() = serviceRepository.connectionState
@ -254,16 +231,25 @@ class UIViewModel(
_requestChannelSet.value = channelSet
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
)
}
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
/** Clears the pending channel set import request. */
/** Called immediately after activity observes requestChannelUrl */
fun clearRequestChannelUrl() {
_requestChannelSet.value = null
}
override fun onCleared() {
super.onCleared()
Logger.d { "UIViewModel cleared" }
Logger.d { "ViewModel cleared" }
}
val tracerouteResponse: Flow<TracerouteResponse?>
@ -279,9 +265,14 @@ class UIViewModel(
serviceRepository.clearNeighborInfoResponse()
}
val appIntroCompleted: StateFlow<Boolean> = uiPrefs.appIntroCompleted
val appIntroCompleted: StateFlow<Boolean> = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {
uiPrefs.setAppIntroCompleted(true)
uiPreferencesDataSource.setAppIntroCompleted(true)
}
@Composable
fun AddNavigationTrackingEffect(navController: NavHostController) {
analytics.AddNavigationTrackingEffect(navController)
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.ui.sharing.ChannelScreen
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
fun NavGraphBuilder.channelsGraph(navController: NavHostController) {
navigation<ChannelsRoutes.ChannelsGraph>(startDestination = ChannelsRoutes.Channels) {
composable<ChannelsRoutes.Channels>(
deepLinks = listOf(navDeepLink<ChannelsRoutes.Channels>(basePath = "$DEEP_LINK_BASE_URI/channels")),
) { backStackEntry ->
val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) }
ChannelScreen(
radioConfigViewModel = hiltViewModel(parentEntry),
onNavigate = { route -> navController.navigate(route) },
onNavigateUp = { navController.navigateUp() },
)
}
navController.configComposable<SettingsRoutes.ChannelConfig, ChannelsRoutes.ChannelsGraph> {
ChannelConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
navController.configComposable<SettingsRoutes.LoRa, ChannelsRoutes.ChannelsGraph> {
LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.ui.connections.ConnectionsScreen
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {
@Suppress("ktlint:standard:max-line-length")
navigation<ConnectionsRoutes.ConnectionsGraph>(startDestination = ConnectionsRoutes.Connections) {
composable<ConnectionsRoutes.Connections>(
deepLinks = listOf(
navDeepLink<ConnectionsRoutes.Connections>(basePath = "$DEEP_LINK_BASE_URI/connections"),
),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(ConnectionsRoutes.ConnectionsGraph) }
ConnectionsScreen(
radioConfigViewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> navController.navigate(route) },
)
}
navController.configComposable<SettingsRoutes.LoRa, ConnectionsRoutes.ConnectionsGraph> {
LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
}
}

View file

@ -0,0 +1,107 @@
/*
* 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 com.geeksville.mesh.navigation
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.geeksville.mesh.model.UIViewModel
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) {
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
) {
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
AdaptiveContactsScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
}
composable<ContactsRoutes.Messages>(
deepLinks =
listOf(
navDeepLink<ContactsRoutes.Messages>(
basePath =
"$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended
),
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
AdaptiveContactsScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = args.contactKey,
initialMessage = args.message,
)
}
}
composable<ContactsRoutes.Share>(
deepLinks =
listOf(
navDeepLink<ContactsRoutes.Share>(
basePath = "$DEEP_LINK_BASE_URI/share", // ?message={message} is auto-appended
),
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
ShareScreen(
onConfirm = {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
}
},
onNavigateUp = navController::navigateUp,
)
}
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
QuickChatScreen(onNavigateUp = navController::navigateUp)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 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,15 +14,18 @@
* 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.service
import android.app.NotificationManager
package com.geeksville.mesh.navigation
/** One-time alpha cleanup: remove legacy enum-name category channels introduced before canonical IDs. */
internal fun NotificationManager.removeLegacyCategoryChannels() {
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
if (getNotificationChannel(legacyId) != null) {
deleteNotificationChannel(legacyId)
}
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
fun NavGraphBuilder.firmwareGraph(navController: NavController) {
navigation<FirmwareRoutes.FirmwareGraph>(startDestination = FirmwareRoutes.FirmwareUpdate) {
composable<FirmwareRoutes.FirmwareUpdate> { FirmwareUpdateScreen(navController) }
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.map.MapScreen
fun NavGraphBuilder.mapGraph(navController: NavHostController) {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
MapScreen(
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
}

View file

@ -0,0 +1,350 @@
/*
* 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 com.geeksville.mesh.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CellTower
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material.icons.rounded.Power
import androidx.compose.material.icons.rounded.Router
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.environment
import org.meshtastic.core.resources.host
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
AdaptiveNodeListScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
nodeDetailGraph(navController, scrollToTopEvents)
}
}
@Suppress("LongMethod")
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
// We keep this route for deep linking or direct navigation to details,
// but typically users will navigate via the Adaptive screen in NodesRoutes.Nodes
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail>(
deepLinks =
listOf(
navDeepLink<NodesRoutes.NodeDetail>( // Handles both /node and /node/{destNum} due to destNum: Int?
basePath = "$DEEP_LINK_BASE_URI/node",
),
),
) { backStackEntry ->
val args = backStackEntry.toRoute<NodesRoutes.NodeDetail>()
// When navigating directly to NodeDetail (e.g. from Map or deep link),
// we use the Adaptive screen initialized with the specific node ID.
AdaptiveNodeListScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
composable<NodeDetailRoutes.NodeMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/node_map"),
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/node_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val vm = hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry)
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
composable<NodeDetailRoutes.TracerouteLog>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteLog>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
),
navDeepLink<NodeDetailRoutes.TracerouteLog>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteLog>()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = navController::navigateUp,
onViewOnMap = { requestId, responseLogUuid ->
navController.navigate(
NodeDetailRoutes.TracerouteMap(
destNum = args.destNum,
requestId = requestId,
logUuid = responseLogUuid,
),
)
},
)
}
composable<NodeDetailRoutes.TracerouteMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteMap>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
),
navDeepLink<NodeDetailRoutes.TracerouteMap>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteMap>()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = navController::navigateUp,
)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PositionLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.EnvironmentMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.SignalMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PowerMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PaxMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
else -> Unit
}
}
}
}
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
/**
* Helper to define a composable route for a screen within the node detail graph.
*
* @param R The type of the [Route] object, must be serializable.
* @param navController The [NavHostController] for navigation.
* @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
* @param screenContent A lambda that defines the composable content for the screen.
* @param getDestNum A lambda to extract the destination number from the route arguments.
*/
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
crossinline getDestNum: (R) -> Int,
) {
composable<R>(
deepLinks =
listOf(
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/${routeInfo.name.lowercase()}"),
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/node/${routeInfo.name.lowercase()}"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<R>()
val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum)
screenContent(metricsViewModel, navController::navigateUp)
}
}
enum class NodeDetailRoute(
val title: StringResource,
val routeClass: KClass<out Route>,
val icon: ImageVector?,
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
) {
DEVICE(
Res.string.device,
NodeDetailRoutes.DeviceMetrics::class,
Icons.Rounded.Router,
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
),
POSITION_LOG(
Res.string.position_log,
NodeDetailRoutes.PositionLog::class,
Icons.Rounded.LocationOn,
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
),
ENVIRONMENT(
Res.string.environment,
NodeDetailRoutes.EnvironmentMetrics::class,
Icons.Rounded.LightMode,
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
),
SIGNAL(
Res.string.signal,
NodeDetailRoutes.SignalMetrics::class,
Icons.Rounded.CellTower,
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
),
TRACEROUTE(
Res.string.traceroute,
NodeDetailRoutes.TracerouteLog::class,
Icons.Rounded.PermScanWifi,
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
NEIGHBOR_INFO(
Res.string.neighbor_info,
NodeDetailRoutes.NeighborInfoLog::class,
Icons.Rounded.Groups,
{ metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
POWER(
Res.string.power,
NodeDetailRoutes.PowerMetrics::class,
Icons.Rounded.Power,
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
),
HOST(
Res.string.host,
NodeDetailRoutes.HostMetricsLog::class,
Icons.Rounded.Memory,
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
),
PAX(
Res.string.pax,
NodeDetailRoutes.PaxMetrics::class,
Icons.Rounded.People,
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),
}

View file

@ -0,0 +1,204 @@
/*
* 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/>.
*/
@file:Suppress("Wrapping", "SpacingAroundColon")
package com.geeksville.mesh.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.Graph
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
import org.meshtastic.feature.settings.radio.component.PositionConfigScreen
import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@Suppress("LongMethod")
fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
navigation<SettingsRoutes.SettingsGraph>(startDestination = SettingsRoutes.Settings()) {
composable<SettingsRoutes.Settings>(
deepLinks = listOf(navDeepLink<SettingsRoutes.Settings>(basePath = "$DEEP_LINK_BASE_URI/settings")),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
) {
navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
}
}
composable<SettingsRoutes.CleanNodeDb>(
deepLinks =
listOf(
navDeepLink<SettingsRoutes.CleanNodeDb>(
basePath = "$DEEP_LINK_BASE_URI/settings/radio/clean_node_db",
),
),
) {
CleanNodeDatabaseScreen()
}
ConfigRoute.entries.forEach { entry ->
navController.configComposable(
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.DEVICE -> DeviceConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.POSITION -> PositionConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.SECURITY -> SecurityConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
ModuleRoute.entries.forEach { entry ->
navController.configComposable(
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.EXT_NOTIFICATION ->
ExternalNotificationConfigScreen(viewModel = viewModel, onBack = navController::popBackStack)
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.CANNED_MESSAGE ->
CannedMessageConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.REMOTE_HARDWARE ->
RemoteHardwareConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.NEIGHBOR_INFO ->
NeighborInfoConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.AMBIENT_LIGHTING ->
AmbientLightingConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.DETECTION_SENSOR ->
DetectionSensorConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
composable<SettingsRoutes.DebugPanel>(
deepLinks =
listOf(navDeepLink<SettingsRoutes.DebugPanel>(basePath = "$DEEP_LINK_BASE_URI/settings/debug_panel")),
) {
DebugScreen(onNavigateUp = navController::navigateUp)
}
composable<SettingsRoutes.About> { AboutScreen(onNavigateUp = navController::navigateUp) }
composable<SettingsRoutes.FilterSettings> { FilterSettingsScreen(onBack = navController::navigateUp) }
}
}
context(_: NavGraphBuilder)
inline fun <reified R : Route, reified G : Graph> NavHostController.configComposable(
noinline content: @Composable (RadioConfigViewModel) -> Unit,
) {
configComposable(route = R::class, parentGraphRoute = G::class, content = content)
}
context(navGraphBuilder: NavGraphBuilder)
fun <R : Route, G : Graph> NavHostController.configComposable(
route: KClass<R>,
parentGraphRoute: KClass<G>,
content: @Composable (RadioConfigViewModel) -> Unit,
) {
navGraphBuilder.composable(route = route) { backStackEntry ->
val parentEntry = remember(backStackEntry) { getBackStackEntry(parentGraphRoute) }
content(hiltViewModel(parentEntry))
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,8 @@
* 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.network.repository
package com.geeksville.mesh.repository.network
import android.net.ConnectivityManager
import android.net.Network
@ -26,8 +27,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
internal fun ConnectivityManager.networkAvailable(): Flow<Boolean> =
observeNetworks().map { activeNetworksList -> activeNetworksList.isNotEmpty() }.distinctUntilChanged()
internal fun ConnectivityManager.networkAvailable(): Flow<Boolean> = observeNetworks()
.map { activeNetworksList -> activeNetworksList.isNotEmpty() }
.distinctUntilChanged()
internal fun ConnectivityManager.observeNetworks(
networkRequest: NetworkRequest = NetworkRequest.Builder().build(),
@ -35,26 +37,30 @@ internal fun ConnectivityManager.observeNetworks(
// Keep track of the current active networks
val activeNetworks = mutableSetOf<Network>()
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
activeNetworks.add(network)
trySend(activeNetworks.toList())
}
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
activeNetworks.add(network)
trySend(activeNetworks.toList())
}
override fun onLost(network: Network) {
activeNetworks.remove(network)
trySend(activeNetworks.toList())
}
override fun onLost(network: Network) {
activeNetworks.remove(network)
trySend(activeNetworks.toList())
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (activeNetworks.contains(network)) {
trySend(activeNetworks.toList())
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
if (activeNetworks.contains(network)) {
trySend(activeNetworks.toList())
}
}
}
registerNetworkCallback(networkRequest, callback)
awaitClose { unregisterNetworkCallback(callback) }
awaitClose {
unregisterNetworkCallback(callback)
}
}

View file

@ -0,0 +1,178 @@
/*
* 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 com.geeksville.mesh.repository.network
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttAsyncClient
import org.eclipse.paho.client.mqttv3.MqttAsyncClient.generateClientId
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.subscribeList
import org.meshtastic.proto.MqttClientProxyMessage
import java.net.URI
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
@Singleton
class MQTTRepository
@Inject
constructor(
private val radioConfigRepository: RadioConfigRepository,
private val nodeRepository: NodeRepository,
) {
companion object {
/**
* Quality of Service (QoS) levels in MQTT:
* - QoS 0: "at most once". Packets are sent once without validation if it has been received.
* - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server.
* MQTT ensures delivery, but duplicates may occur.
* - QoS 2: "exactly once". Similar to QoS 1, but with no duplicates.
*/
private const val DEFAULT_QOS = 1
private const val DEFAULT_TOPIC_ROOT = "msh"
private const val DEFAULT_TOPIC_LEVEL = "/2/e/"
private const val JSON_TOPIC_LEVEL = "/2/json/"
private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org"
}
private var mqttClient: MqttAsyncClient? = null
fun disconnect() {
Logger.i { "MQTT Disconnected" }
mqttClient?.apply {
if (isConnected) {
ignoreException { disconnect() }
}
ignoreException { close(true) }
}
mqttClient = null
}
val proxyMessageFlow: Flow<MqttClientProxyMessage> = callbackFlow {
val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: generateClientId()}"
val channelSet = radioConfigRepository.channelSetFlow.first()
val mqttConfig = radioConfigRepository.moduleConfigFlow.first().mqtt
val sslContext = SSLContext.getInstance("TLS")
// Create a custom SSLContext that trusts all certificates
sslContext.init(null, arrayOf<TrustManager>(TrustAllX509TrustManager()), SecureRandom())
val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT }
val connectOptions =
MqttConnectOptions().apply {
userName = mqttConfig?.username
password = mqttConfig?.password?.toCharArray()
isAutomaticReconnect = true
if (mqttConfig?.tls_enabled == true) {
socketFactory = sslContext.socketFactory
}
}
val bufferOptions =
DisconnectedBufferOptions().apply {
isBufferEnabled = true
bufferSize = 512
isPersistBuffer = false
isDeleteOldestMessages = true
}
val callback =
object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String) {
Logger.i { "MQTT connectComplete: $serverURI reconnect: $reconnect" }
channelSet.subscribeList
.ifEmpty {
return
}
.forEach { globalId ->
subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
if (mqttConfig?.json_enabled == true) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
}
subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
}
override fun connectionLost(cause: Throwable) {
Logger.i { "MQTT connectionLost cause: $cause" }
if (cause is IllegalArgumentException) close(cause)
}
override fun messageArrived(topic: String, message: MqttMessage) {
trySend(
MqttClientProxyMessage(
topic = topic,
data_ = message.payload.toByteString(),
retained = message.isRetained,
),
)
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
Logger.i { "MQTT deliveryComplete messageId: ${token?.messageId}" }
}
}
val scheme = if (mqttConfig?.tls_enabled == true) "ssl" else "tcp"
val (host, port) =
(mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1)
}
mqttClient =
MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply {
setCallback(callback)
setBufferOpts(bufferOptions)
connect(connectOptions)
}
awaitClose { disconnect() }
}
private fun subscribe(topic: String) {
mqttClient?.subscribe(topic, DEFAULT_QOS)
Logger.i { "MQTT Subscribed to topic: $topic" }
}
fun publish(topic: String, data: ByteArray, retained: Boolean) {
try {
val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained)
Logger.i { "MQTT Publish messageId: ${token?.messageId}" }
} catch (ex: Exception) {
if (ex.message?.contains("Client is disconnected") == true) {
Logger.w { "MQTT Publish skipped: Client is disconnected" }
} else {
Logger.e(ex) { "MQTT Publish error: ${ex.message}" }
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,8 +14,11 @@
* 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.network.repository
package com.geeksville.mesh.repository.network
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.Flow
@ -24,20 +27,25 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
@Single(binds = [NetworkRepository::class])
class NetworkRepositoryImpl(
networkMonitor: NetworkMonitor,
serviceDiscovery: ServiceDiscovery,
@Singleton
class NetworkRepository
@Inject
constructor(
private val nsdManagerLazy: dagger.Lazy<NsdManager>,
private val connectivityManager: dagger.Lazy<ConnectivityManager>,
private val dispatchers: CoroutineDispatchers,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
) : NetworkRepository {
@ProcessLifecycle private val processLifecycle: Lifecycle,
) {
override val networkAvailable: Flow<Boolean> by lazy {
networkMonitor.networkAvailable
val networkAvailable: Flow<Boolean> by lazy {
connectivityManager
.get()
.networkAvailable()
.flowOn(dispatchers.io)
.conflate()
.shareIn(
@ -48,8 +56,10 @@ class NetworkRepositoryImpl(
.distinctUntilChanged()
}
override val resolvedList: Flow<List<DiscoveredService>> by lazy {
serviceDiscovery.resolvedServices
val resolvedList: Flow<List<NsdServiceInfo>> by lazy {
nsdManagerLazy
.get()
.serviceList(SERVICE_TYPE)
.flowOn(dispatchers.io)
.conflate()
.shareIn(
@ -58,4 +68,17 @@ class NetworkRepositoryImpl(
replay = 1,
)
}
companion object {
internal const val SERVICE_PORT = 4403
private const val SERVICE_TYPE = "_meshtastic._tcp"
fun NsdServiceInfo.toAddressString() = buildString {
@Suppress("DEPRECATION")
append(host.hostAddress)
if (serviceType.trim('.') == SERVICE_TYPE && port != SERVICE_PORT) {
append(":$port")
}
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.network
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class NetworkRepositoryModule {
companion object {
@Provides
fun provideConnectivityManager(application: Application): ConnectivityManager {
return application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
@Provides
fun provideNsdManager(application: Application): NsdManager {
return application.getSystemService(Context.NSD_SERVICE) as NsdManager
}
}
}

View file

@ -14,9 +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/>.
*/
@file:Suppress("SwallowedException")
package org.meshtastic.core.network.repository
package com.geeksville.mesh.repository.network
import android.annotation.SuppressLint
import android.net.nsd.NsdManager
@ -27,56 +25,24 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
private const val RESOLVE_TIMEOUT_MS = 10000L
private const val RESOLVE_BACKOFF_MS = 1000L
@Suppress("TooGenericExceptionCaught")
@OptIn(ExperimentalCoroutinesApi::class)
internal fun NsdManager.serviceList(
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
private fun NsdManager.discoverServices(
serviceType: String,
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
): Flow<List<NsdServiceInfo>> = callbackFlow {
val resolvedServices = CopyOnWriteArrayList<NsdServiceInfo>()
val resolveChannel = Channel<NsdServiceInfo>(Channel.UNLIMITED)
val mutex = Mutex()
launch {
for (service in resolveChannel) {
mutex.withLock {
try {
val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(service) }
if (resolved != null) {
resolvedServices.removeAll { it.serviceName == resolved.serviceName }
resolvedServices.add(resolved)
trySend(resolvedServices.toList())
}
} catch (e: IllegalArgumentException) {
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
delay(RESOLVE_BACKOFF_MS)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
delay(RESOLVE_BACKOFF_MS)
}
}
}
}
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
@ -98,13 +64,14 @@ internal fun NsdManager.serviceList(
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service found: $serviceInfo" }
resolveChannel.trySend(serviceInfo)
serviceList += serviceInfo
trySend(serviceList)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service lost: $serviceInfo" }
resolvedServices.removeAll { it.serviceName == serviceInfo.serviceName }
trySend(resolvedServices.toList())
serviceList.removeAll { it.serviceName == serviceInfo.serviceName }
trySend(serviceList)
}
}
trySend(emptyList()) // Emit an initial empty list

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 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,17 +14,16 @@
* 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.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
package com.geeksville.mesh.repository.network
/** No-op stubs for iOS target in core:ble. */
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// No-op for stubs
import android.annotation.SuppressLint
import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
class TrustAllX509TrustManager : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
throw UnsupportedOperationException("iOS Peripheral not yet implemented")
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,15 +14,17 @@
* 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.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.network.service.ApiServiceImpl
package com.geeksville.mesh.repository.radio
@Module
class GoogleNetworkModule {
import java.io.Closeable
@Single fun bindApiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl
interface IRadioInterface : Closeable {
fun handleSendToRadio(p: ByteArray)
/**
* If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This
* function can be implemented by interfaces to see if we are really connected.
*/
fun keepAlive() {}
}

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