mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Compare commits
No commits in common. "main" and "v2.7.14-internal.46" have entirely different histories.
main
...
v2.7.14-in
617 changed files with 9996 additions and 13415 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
# Ignore build artifacts and generated files from Copilot indexing
|
|
||||||
# This saves context window tokens and prevents Copilot from hallucinating off of minified code.
|
|
||||||
|
|
||||||
# Build directories
|
|
||||||
**/build/**
|
|
||||||
.gradle/
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Android generated files
|
|
||||||
**/generated/**
|
|
||||||
.cxx/
|
|
||||||
.externalNativeBuild/
|
|
||||||
|
|
||||||
# Git history & worktrees
|
|
||||||
.git/
|
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers)
|
|
||||||
core/proto/
|
|
||||||
|
|
||||||
# Environment and secrets
|
|
||||||
local.properties
|
|
||||||
secrets.properties
|
|
||||||
*.jks
|
|
||||||
|
|
||||||
# Agent References (Prevents pollution of project space with external code)
|
|
||||||
.agent_refs/
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"context": {
|
|
||||||
"fileName": ["AGENTS.md", "GEMINI.md"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
.github/actions/gradle-setup/action.yml
vendored
13
.github/actions/gradle-setup/action.yml
vendored
|
|
@ -27,6 +27,15 @@ runs:
|
||||||
distribution: ${{ inputs.jdk_distribution }}
|
distribution: ${{ inputs.jdk_distribution }}
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
|
|
||||||
|
# Robolectric downloads instrumented SDK jars from Maven Central at test time.
|
||||||
|
# Cache them to avoid flaky SocketException failures on CI runners.
|
||||||
|
# Update the key when bumping robolectric version in libs.versions.toml or sdk in robolectric.properties.
|
||||||
|
- name: Cache Robolectric SDK jars
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.m2/repository/org/robolectric
|
||||||
|
key: robolectric-4.16.1-sdk34
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,7 +43,3 @@ runs:
|
||||||
cache-encryption-key: ${{ inputs.gradle_encryption_key }}
|
cache-encryption-key: ${{ inputs.gradle_encryption_key }}
|
||||||
cache-cleanup: on-success
|
cache-cleanup: on-success
|
||||||
add-job-summary: always
|
add-job-summary: always
|
||||||
gradle-home-cache-includes: |
|
|
||||||
caches
|
|
||||||
notifications
|
|
||||||
~/.m2/repository/org/robolectric
|
|
||||||
27
.github/copilot-commit-message-instructions.md
vendored
27
.github/copilot-commit-message-instructions.md
vendored
|
|
@ -1,27 +0,0 @@
|
||||||
# GitHub Copilot Commit Message Instructions
|
|
||||||
|
|
||||||
<role>
|
|
||||||
You are an expert Git maintainer enforcing Conventional Commits.
|
|
||||||
</role>
|
|
||||||
|
|
||||||
<instructions>
|
|
||||||
1. **Format:** Use the Conventional Commits format: `<type>(<scope>): <subject>` (Replace angle brackets with actual text, do NOT output angle brackets).
|
|
||||||
2. **Types allowed:**
|
|
||||||
- `feat` (new feature for the user, not a new feature for build script)
|
|
||||||
- `fix` (bug fix for the user, not a fix to a build script)
|
|
||||||
- `docs` (changes to the documentation)
|
|
||||||
- `style` (formatting, missing semi colons, etc; no production code change)
|
|
||||||
- `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain)
|
|
||||||
- `test` (adding missing tests, refactoring tests; no production code change)
|
|
||||||
- `chore` (updating grunt tasks etc; no production code change)
|
|
||||||
3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`).
|
|
||||||
4. **Subject line:**
|
|
||||||
- Use the imperative, present tense: "change" not "changed" nor "changes".
|
|
||||||
- Do not capitalize the first letter.
|
|
||||||
- Do not use a period (.) at the end.
|
|
||||||
- Keep it under 50 characters if possible.
|
|
||||||
5. **Body (Optional but recommended for large diffs):**
|
|
||||||
- Leave one blank line after the subject.
|
|
||||||
- Explain *why* the change was made, not just *what* changed.
|
|
||||||
- If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework".
|
|
||||||
</instructions>
|
|
||||||
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
# Meshtastic Android - GitHub Copilot Guide
|
# Meshtastic Android - Agent Guide
|
||||||
|
|
||||||
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
|
**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically.
|
||||||
|
|
||||||
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
|
See [AGENTS.md](../AGENTS.md) for architecture, conventions, execution protocol, and coding standards.
|
||||||
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
|
See [docs/agent-playbooks/README.md](../docs/agent-playbooks/README.md) for version baselines and task recipes.
|
||||||
|
|
|
||||||
18
.github/copilot-pull-request-instructions.md
vendored
18
.github/copilot-pull-request-instructions.md
vendored
|
|
@ -1,18 +0,0 @@
|
||||||
# GitHub Copilot Pull Request Instructions
|
|
||||||
|
|
||||||
<role>
|
|
||||||
You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs.
|
|
||||||
</role>
|
|
||||||
|
|
||||||
<instructions>
|
|
||||||
1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text.
|
|
||||||
2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`.
|
|
||||||
3. **Structured Changes:** Break down the code changes into bullet points categorized by:
|
|
||||||
- 🌟 **New Features** (UI, modules, logic)
|
|
||||||
- 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates)
|
|
||||||
- 🐛 **Bug Fixes**
|
|
||||||
- 🧹 **Chores** (Dependencies, formatting, docs)
|
|
||||||
4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone".
|
|
||||||
5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified.
|
|
||||||
6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots.
|
|
||||||
</instructions>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
---
|
|
||||||
applyTo: "**/androidMain/**/*.kt"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Android Source-Set Rules
|
|
||||||
|
|
||||||
- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here.
|
|
||||||
- Do NOT put business logic here. Business logic belongs in `commonMain`.
|
|
||||||
- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`.
|
|
||||||
- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI.
|
|
||||||
- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors.
|
|
||||||
10
.github/instructions/build-logic.instructions.md
vendored
10
.github/instructions/build-logic.instructions.md
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
---
|
|
||||||
applyTo: "build-logic/**/*.kt"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Build-Logic Convention Plugin Rules
|
|
||||||
|
|
||||||
- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs).
|
|
||||||
- Avoid `afterEvaluate` unless there is no viable lazy alternative.
|
|
||||||
- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones.
|
|
||||||
- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
---
|
|
||||||
applyTo: "**/*.yml"
|
|
||||||
excludeAgent: "code-review"
|
|
||||||
---
|
|
||||||
|
|
||||||
# CI Workflow Rules
|
|
||||||
|
|
||||||
- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`).
|
|
||||||
- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
|
|
||||||
- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
|
|
||||||
- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.
|
|
||||||
- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`.
|
|
||||||
- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners.
|
|
||||||
- Gradle-heavy jobs: use `ubuntu-24.04` runners.
|
|
||||||
20
.github/instructions/kmp-common.instructions.md
vendored
20
.github/instructions/kmp-common.instructions.md
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
applyTo: "**/commonMain/**/*.kt"
|
|
||||||
---
|
|
||||||
|
|
||||||
# KMP commonMain Rules
|
|
||||||
|
|
||||||
- NEVER import `java.*` or `android.*` in `commonMain`.
|
|
||||||
- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`.
|
|
||||||
- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`.
|
|
||||||
- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`.
|
|
||||||
- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`.
|
|
||||||
- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies.
|
|
||||||
- Use `compose-multiplatform-*` catalog aliases for CMP dependencies.
|
|
||||||
- Never use plain `androidx.compose` dependencies in `commonMain`.
|
|
||||||
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
|
|
||||||
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
|
|
||||||
- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
|
|
||||||
- Check `gradle/libs.versions.toml` before adding dependencies.
|
|
||||||
- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
|
|
||||||
- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.
|
|
||||||
12
.github/lsp.json
vendored
12
.github/lsp.json
vendored
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"lspServers": {
|
|
||||||
"kotlin": {
|
|
||||||
"command": "kotlin-language-server",
|
|
||||||
"args": [],
|
|
||||||
"fileExtensions": {
|
|
||||||
".kt": "kotlin",
|
|
||||||
".kts": "kotlin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
221
.github/renovate.json
vendored
221
.github/renovate.json
vendored
|
|
@ -49,31 +49,236 @@
|
||||||
"automerge": true
|
"automerge": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Meshtastic Protobufs changelog link",
|
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"https://github.com/meshtastic/protobufs.git"
|
"https://github.com/meshtastic/protobufs.git"
|
||||||
],
|
],
|
||||||
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
|
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
|
||||||
|
"groupName": "Meshtastic Protobufs",
|
||||||
|
"groupSlug": "meshtastic-protobufs",
|
||||||
"automerge": true
|
"automerge": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
|
"description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
|
||||||
"groupName": "compose-multiplatform",
|
"groupName": "AndroidX (General)",
|
||||||
|
"groupSlug": "androidx-general",
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"/^org\\.jetbrains\\.compose/",
|
"/^androidx\\./",
|
||||||
"androidx.compose.runtime:runtime-tracing",
|
"!/^androidx\\.room/",
|
||||||
"androidx.compose.ui:ui-test-manifest"
|
"!/^androidx\\.lifecycle/",
|
||||||
|
"!/^androidx\\.navigation/",
|
||||||
|
"!/^androidx\\.datastore/",
|
||||||
|
"!/^androidx\\.compose\\.material3\\.adaptive/",
|
||||||
|
"!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
|
||||||
|
"!/^androidx\\.test\\.espresso/",
|
||||||
|
"!/^androidx\\.test\\.ext/",
|
||||||
|
"!/^androidx\\.compose\\.ui:ui-test-junit4$/",
|
||||||
|
"!/^androidx\\.hilt/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Restrict sensitive infrastructure to manual minor updates",
|
"description": "Group Kotlin standard library, coroutines, and serialization",
|
||||||
|
"groupName": "Kotlin Ecosystem",
|
||||||
|
"groupSlug": "kotlin",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^org\\.jetbrains\\.kotlin/",
|
||||||
|
"/^org\\.jetbrains\\.kotlinx/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Dagger and Hilt dependencies",
|
||||||
|
"groupName": "Dagger & Hilt",
|
||||||
|
"groupSlug": "hilt",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.google\\.dagger/",
|
||||||
|
"/^androidx\\.hilt/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Accompanist libraries",
|
||||||
|
"groupName": "Accompanist",
|
||||||
|
"groupSlug": "accompanist",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.google\\.accompanist/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
|
||||||
|
"groupName": "JVM Testing Libraries",
|
||||||
|
"groupSlug": "jvm-testing",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^junit:junit$/",
|
||||||
|
"/^org\\.mockito:/",
|
||||||
|
"/^org\\.robolectric:robolectric$/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX Testing libraries",
|
||||||
|
"groupName": "AndroidX Testing",
|
||||||
|
"groupSlug": "androidx-testing",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.test\\.espresso/",
|
||||||
|
"/^androidx\\.test\\.ext/",
|
||||||
|
"/^androidx\\.compose\\.ui:ui-test-junit4$/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Static Analysis tools (Detekt, Spotless)",
|
||||||
|
"groupName": "Static Analysis",
|
||||||
|
"groupSlug": "static-analysis",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^io\\.gitlab\\.arturbosch\\.detekt/",
|
||||||
|
"/^io\\.nlopez\\.compose\\.rules/",
|
||||||
|
"/^com\\.diffplug\\.spotless/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Square networking libraries (OkHttp, Retrofit)",
|
||||||
|
"groupName": "Square Networking",
|
||||||
|
"groupSlug": "square-network",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.squareup\\.okhttp3/",
|
||||||
|
"/^com\\.squareup\\.retrofit2/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Coil image loading library",
|
||||||
|
"groupName": "Coil",
|
||||||
|
"groupSlug": "coil",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^io\\.coil-kt\\.coil3/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group ZXing barcode scanning libraries",
|
||||||
|
"groupName": "ZXing",
|
||||||
|
"groupSlug": "zxing",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.journeyapps:zxing-android-embedded/",
|
||||||
|
"/^com\\.google\\.zxing:core/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Eclipse Paho MQTT client libraries",
|
||||||
|
"groupName": "MQTT Paho Client",
|
||||||
|
"groupSlug": "mqtt-paho",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^org\\.eclipse\\.paho/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Mike Penz Markdown renderer libraries",
|
||||||
|
"groupName": "Markdown Renderer (Mike Penz)",
|
||||||
|
"groupSlug": "markdown-renderer-mikepenz",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.mikepenz/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Firebase libraries",
|
||||||
|
"groupName": "Firebase",
|
||||||
|
"groupSlug": "firebase",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.google\\.firebase/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Datadog libraries",
|
||||||
|
"groupName": "Datadog",
|
||||||
|
"groupSlug": "datadog",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.datadoghq/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group OpenStreetMap (OSM) libraries",
|
||||||
|
"groupName": "OSM Libraries",
|
||||||
|
"groupSlug": "osm-libraries",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^org\\.osmdroid/",
|
||||||
|
"/^com\\.github\\.MKergall\\.osmbonuspack/",
|
||||||
|
"/^mil\\.nga/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Google Maps Compose libraries",
|
||||||
|
"groupName": "Google Maps Compose",
|
||||||
|
"groupSlug": "google-maps-compose",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.google\\.android\\.gms:play-services-location/",
|
||||||
|
"/^com\\.google\\.maps\\.android/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Google Protobuf runtime libraries",
|
||||||
|
"groupName": "Protobuf Runtime",
|
||||||
|
"groupSlug": "protobuf-runtime",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^com\\.google\\.protobuf/",
|
||||||
|
"!https://github.com/meshtastic/protobufs.git"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX Room libraries",
|
||||||
|
"groupName": "AndroidX Room",
|
||||||
|
"groupSlug": "androidx-room",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.room/"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX Lifecycle libraries",
|
||||||
|
"groupName": "AndroidX Lifecycle",
|
||||||
|
"groupSlug": "androidx-lifecycle",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.lifecycle/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX Navigation libraries",
|
||||||
|
"groupName": "AndroidX Navigation",
|
||||||
|
"groupSlug": "androidx-navigation",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.navigation/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX DataStore libraries",
|
||||||
|
"groupName": "AndroidX DataStore",
|
||||||
|
"groupSlug": "androidx-datastore",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.datastore/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group AndroidX Adaptive UI libraries",
|
||||||
|
"groupName": "AndroidX Adaptive UI",
|
||||||
|
"groupSlug": "androidx-adaptive-ui",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"/^androidx\\.compose\\.material3\\.adaptive/",
|
||||||
|
"/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
"minor"
|
"minor"
|
||||||
],
|
],
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"/^org\\.jetbrains\\.kotlin/",
|
"/^org\\.jetbrains\\.kotlin/",
|
||||||
"/^org\\.jetbrains\\.kotlinx/",
|
"/^org\\.jetbrains\\.kotlinx/",
|
||||||
"/^org\\.jetbrains\\.compose/",
|
|
||||||
"/^com\\.google\\.dagger/",
|
"/^com\\.google\\.dagger/",
|
||||||
"/^androidx\\.hilt/",
|
"/^androidx\\.hilt/",
|
||||||
"/^com\\.google\\.protobuf/",
|
"/^com\\.google\\.protobuf/",
|
||||||
|
|
|
||||||
108
.github/workflows/codeql.yml
vendored
Normal file
108
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
# push:
|
||||||
|
# branches: [ "main" ]
|
||||||
|
# pull_request:
|
||||||
|
# branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 0'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||||
|
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||||
|
# - https://gh.io/supported-runners-and-hardware-resources
|
||||||
|
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||||
|
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }}
|
||||||
|
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||||
|
permissions:
|
||||||
|
# required for all workflows
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
# required to fetch internal or private CodeQL packs
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
# only required for workflows in private repositories
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: java-kotlin
|
||||||
|
build-mode: autobuild
|
||||||
|
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||||
|
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||||
|
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||||
|
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||||
|
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||||
|
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||||
|
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||||
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||||
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||||
|
# or others). This is typically only required for manual builds.
|
||||||
|
# - name: Setup runtime (example)
|
||||||
|
# uses: actions/setup-example@v1
|
||||||
|
- name: Java Setup
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
java-version: '21'
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
# If the analyze step fails for one of the languages you are analyzing with
|
||||||
|
# "We were unable to automatically build your code", modify the matrix above
|
||||||
|
# to set the build mode to "manual" for that language. Then modify this step
|
||||||
|
# to build your code.
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
- if: matrix.build-mode == 'manual'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||||
|
'languages you are analyzing, replace this with the commands to build' \
|
||||||
|
'your code, for example:'
|
||||||
|
echo ' make bootstrap'
|
||||||
|
echo ' make release'
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
18
.github/workflows/docs.yml
vendored
18
.github/workflows/docs.yml
vendored
|
|
@ -6,16 +6,6 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
# Only rebuild docs when source code changes (Dokka generates from KDoc)
|
|
||||||
- 'app/src/**'
|
|
||||||
- 'core/**/src/**'
|
|
||||||
- 'feature/**/src/**'
|
|
||||||
- 'desktop/src/**'
|
|
||||||
- 'build-logic/**'
|
|
||||||
- 'build.gradle.kts'
|
|
||||||
- 'settings.gradle.kts'
|
|
||||||
- '.github/workflows/docs.yml'
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -39,11 +29,11 @@ permissions:
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
# Allow only one concurrent deployment; cancel queued runs since only the latest
|
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||||
# main state matters for documentation.
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "pages"
|
group: "pages"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docs:
|
build-docs:
|
||||||
|
|
@ -66,7 +56,7 @@ jobs:
|
||||||
run: ./gradlew dokkaGeneratePublicationHtml
|
run: ./gradlew dokkaGeneratePublicationHtml
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v5
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: build/dokka/html
|
path: build/dokka/html
|
||||||
|
|
||||||
|
|
|
||||||
5
.github/workflows/main-check.yml
vendored
5
.github/workflows/main-check.yml
vendored
|
|
@ -20,7 +20,8 @@ jobs:
|
||||||
uses: ./.github/workflows/reusable-check.yml
|
uses: ./.github/workflows/reusable-check.yml
|
||||||
with:
|
with:
|
||||||
run_lint: true
|
run_lint: true
|
||||||
run_unit_tests: false
|
run_unit_tests: true
|
||||||
run_desktop_builds: false
|
run_instrumented_tests: true
|
||||||
|
api_levels: '[35]' # One API level is enough for post-merge sanity check
|
||||||
upload_artifacts: true
|
upload_artifacts: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
|
||||||
2
.github/workflows/merge-queue.yml
vendored
2
.github/workflows/merge-queue.yml
vendored
|
|
@ -18,6 +18,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
run_lint: true
|
run_lint: true
|
||||||
run_unit_tests: true
|
run_unit_tests: true
|
||||||
|
run_instrumented_tests: true
|
||||||
|
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
|
||||||
upload_artifacts: false
|
upload_artifacts: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
|
|
||||||
14
.github/workflows/models_pr_triage.yml
vendored
14
.github/workflows/models_pr_triage.yml
vendored
|
|
@ -44,16 +44,13 @@ jobs:
|
||||||
uses: actions/ai-inference@v2
|
uses: actions/ai-inference@v2
|
||||||
id: quality
|
id: quality
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
|
||||||
with:
|
with:
|
||||||
max-tokens: 20
|
max-tokens: 20
|
||||||
prompt: |
|
prompt: |
|
||||||
Is this GitHub pull request spam, AI-generated slop, or low quality?
|
Is this GitHub pull request spam, AI-generated slop, or low quality?
|
||||||
|
|
||||||
Title: ${{ env.PR_TITLE }}
|
Title: ${{ github.event.pull_request.title }}
|
||||||
Body: ${{ env.PR_BODY }}
|
Body: ${{ github.event.pull_request.body }}
|
||||||
|
|
||||||
Respond with exactly one of: spam, ai-generated, needs-review, ok
|
Respond with exactly one of: spam, ai-generated, needs-review, ok
|
||||||
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
|
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
|
||||||
|
|
@ -97,9 +94,6 @@ jobs:
|
||||||
uses: actions/ai-inference@v2
|
uses: actions/ai-inference@v2
|
||||||
id: classify
|
id: classify
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
|
||||||
with:
|
with:
|
||||||
max-tokens: 30
|
max-tokens: 30
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|
@ -111,8 +105,8 @@ jobs:
|
||||||
Use enhancement if it adds a new feature, improves performance, or adds new functionality.
|
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 refactor if it restructures code without changing behavior, cleans up code, or improves architecture.
|
||||||
|
|
||||||
Title: ${{ env.PR_TITLE }}
|
Title: ${{ github.event.pull_request.title }}
|
||||||
Body: ${{ env.PR_BODY }}
|
Body: ${{ github.event.pull_request.body }}
|
||||||
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
|
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
|
||||||
model: openai/gpt-4o-mini
|
model: openai/gpt-4o-mini
|
||||||
|
|
||||||
|
|
|
||||||
1
.github/workflows/promote.yml
vendored
1
.github/workflows/promote.yml
vendored
|
|
@ -139,7 +139,6 @@ jobs:
|
||||||
gh release edit ${{ inputs.tag_name }} \
|
gh release edit ${{ inputs.tag_name }} \
|
||||||
--tag ${{ inputs.final_tag }} \
|
--tag ${{ inputs.final_tag }} \
|
||||||
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
|
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
|
||||||
--draft=false \
|
|
||||||
--prerelease=${{ inputs.channel != 'production' }}
|
--prerelease=${{ inputs.channel != 'production' }}
|
||||||
|
|
||||||
- name: Notify Discord
|
- name: Notify Discord
|
||||||
|
|
|
||||||
12
.github/workflows/pull-request.yml
vendored
12
.github/workflows/pull-request.yml
vendored
|
|
@ -3,6 +3,10 @@ name: Pull Request CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.gitignore'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -35,6 +39,7 @@ jobs:
|
||||||
- 'desktop/**'
|
- 'desktop/**'
|
||||||
- 'core/**'
|
- 'core/**'
|
||||||
- 'feature/**'
|
- 'feature/**'
|
||||||
|
- 'mesh_service_example/**'
|
||||||
# Shared build infrastructure
|
# Shared build infrastructure
|
||||||
- 'build-logic/**'
|
- 'build-logic/**'
|
||||||
- 'config/**'
|
- 'config/**'
|
||||||
|
|
@ -94,9 +99,7 @@ jobs:
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
|
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
|
||||||
# We disable coverage and desktop builds for PRs to keep feedback fast
|
# We disable instrumented tests and coverage for PRs to keep feedback fast (< 10 mins).
|
||||||
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
|
|
||||||
# task in the shard-app test shard.
|
|
||||||
validate-and-build:
|
validate-and-build:
|
||||||
needs: check-changes
|
needs: check-changes
|
||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
@ -104,8 +107,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
run_lint: true
|
run_lint: true
|
||||||
run_unit_tests: true
|
run_unit_tests: true
|
||||||
|
run_instrumented_tests: false
|
||||||
run_coverage: false
|
run_coverage: false
|
||||||
run_desktop_builds: false
|
api_levels: '[35]'
|
||||||
upload_artifacts: true
|
upload_artifacts: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -328,7 +328,7 @@ jobs:
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
|
|
||||||
- name: Create or Update GitHub Release
|
- name: Create or Update GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.tag_name }}
|
tag_name: ${{ inputs.tag_name }}
|
||||||
target_commitish: ${{ inputs.commit_sha || github.sha }}
|
target_commitish: ${{ inputs.commit_sha || github.sha }}
|
||||||
|
|
@ -341,7 +341,7 @@ jobs:
|
||||||
- name: Create or Update internal GitHub Release
|
- name: Create or Update internal GitHub Release
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
|
if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
|
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
|
||||||
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
|
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
|
||||||
|
|
|
||||||
193
.github/workflows/reusable-check.yml
vendored
193
.github/workflows/reusable-check.yml
vendored
|
|
@ -9,12 +9,15 @@ on:
|
||||||
run_unit_tests:
|
run_unit_tests:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
|
run_instrumented_tests:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
run_coverage:
|
run_coverage:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
run_desktop_builds:
|
api_levels:
|
||||||
type: boolean
|
type: string
|
||||||
default: true
|
default: '[35]'
|
||||||
upload_artifacts:
|
upload_artifacts:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
|
|
@ -94,7 +97,7 @@ jobs:
|
||||||
|
|
||||||
- name: Lint, Analysis & KMP Smoke Compile
|
- name: Lint, Analysis & KMP Smoke Compile
|
||||||
if: inputs.run_lint == true
|
if: inputs.run_lint == true
|
||||||
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
|
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug kmpSmokeCompile -Pci=true --continue --scan
|
||||||
|
|
||||||
- name: KMP Smoke Compile (lint skipped)
|
- name: KMP Smoke Compile (lint skipped)
|
||||||
if: inputs.run_lint == false
|
if: inputs.run_lint == false
|
||||||
|
|
@ -173,12 +176,14 @@ jobs:
|
||||||
:desktop:test
|
:desktop:test
|
||||||
:core:barcode:testFdroidDebugUnitTest
|
:core:barcode:testFdroidDebugUnitTest
|
||||||
:core:barcode:testGoogleDebugUnitTest
|
:core:barcode:testGoogleDebugUnitTest
|
||||||
|
:mesh_service_example:test
|
||||||
kover: >-
|
kover: >-
|
||||||
:app:koverXmlReportFdroidDebug
|
:app:koverXmlReportFdroidDebug
|
||||||
:app:koverXmlReportGoogleDebug
|
:app:koverXmlReportGoogleDebug
|
||||||
:core:barcode:koverXmlReportFdroidDebug
|
:core:barcode:koverXmlReportFdroidDebug
|
||||||
:core:barcode:koverXmlReportGoogleDebug
|
:core:barcode:koverXmlReportGoogleDebug
|
||||||
:desktop:koverXmlReport
|
:desktop:koverXmlReport
|
||||||
|
:mesh_service_example:koverXmlReportDebug
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
@ -213,7 +218,7 @@ jobs:
|
||||||
files: "**/build/test-results/**/*.xml"
|
files: "**/build/test-results/**/*.xml"
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: ${{ !cancelled() && inputs.run_coverage }}
|
if: ${{ !cancelled() }}
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
@ -232,8 +237,130 @@ jobs:
|
||||||
**/build/test-results
|
**/build/test-results
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ── Android Build ────────────────────────────────────────────────────
|
# ── Android Build & Instrumented Tests ──────────────────────────────
|
||||||
android-check:
|
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 }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
api_level: ${{ fromJson(inputs.api_levels) }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- 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: Determine matrix metadata
|
||||||
|
id: matrix_meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
first_api=$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
print(json.loads('${{ inputs.api_levels }}')[0])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then
|
||||||
|
echo "is_first_api=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "is_first_api=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Determine Android tasks
|
||||||
|
id: tasks
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tasks=(
|
||||||
|
"app:assembleFdroidDebug"
|
||||||
|
"app:assembleGoogleDebug"
|
||||||
|
"mesh_service_example:assembleDebug"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then
|
||||||
|
tasks+=(
|
||||||
|
"app:connectedFdroidDebugAndroidTest"
|
||||||
|
"app:connectedGoogleDebugAndroidTest"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Enable KVM group perms
|
||||||
|
if: inputs.run_instrumented_tests == true
|
||||||
|
run: |
|
||||||
|
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger --name-match=kvm
|
||||||
|
|
||||||
|
- name: Run Android Build & Instrumented Tests
|
||||||
|
if: inputs.run_instrumented_tests == true
|
||||||
|
uses: reactivecircus/android-emulator-runner@v2
|
||||||
|
with:
|
||||||
|
api-level: ${{ matrix.api_level }}
|
||||||
|
arch: x86_64
|
||||||
|
force-avd-creation: false
|
||||||
|
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||||
|
disable-animations: true
|
||||||
|
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
|
||||||
|
|
||||||
|
- name: Run Android Build
|
||||||
|
if: inputs.run_instrumented_tests == false
|
||||||
|
run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
|
||||||
|
|
||||||
|
- name: Upload instrumented test results to Codecov
|
||||||
|
if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }}
|
||||||
|
uses: codecov/codecov-action@v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: meshtastic/Meshtastic-Android
|
||||||
|
flags: android-instrumented
|
||||||
|
fail_ci_if_error: false
|
||||||
|
report_type: test_results
|
||||||
|
files: "**/build/outputs/androidTest-results/**/*.xml"
|
||||||
|
|
||||||
|
- name: Upload debug artifact
|
||||||
|
if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: app-debug-apks
|
||||||
|
path: app/build/outputs/apk/*/debug/*.apk
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Report App Size
|
||||||
|
if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
|
||||||
|
run: |
|
||||||
|
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Upload Android reports
|
||||||
|
if: ${{ always() && inputs.upload_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: reports-android-api-${{ matrix.api_level }}
|
||||||
|
path: |
|
||||||
|
**/build/outputs/androidTest-results
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# ── Desktop Build ───────────────────────────────────────────────────
|
||||||
|
build-desktop:
|
||||||
|
name: Build Desktop Debug
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -242,54 +369,6 @@ jobs:
|
||||||
env:
|
env:
|
||||||
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
|
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Gradle Setup
|
|
||||||
uses: ./.github/actions/gradle-setup
|
|
||||||
with:
|
|
||||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
|
||||||
|
|
||||||
- name: Build Android APKs
|
|
||||||
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
|
|
||||||
|
|
||||||
- name: Upload debug artifact
|
|
||||||
if: ${{ inputs.upload_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: app-debug-apks
|
|
||||||
path: app/build/outputs/apk/*/debug/*.apk
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Report App Size
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# ── 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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
@ -304,12 +383,12 @@ jobs:
|
||||||
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
|
||||||
|
|
||||||
- name: Build Desktop
|
- name: Build Desktop
|
||||||
run: ./gradlew :desktop:createDistributable -Pci=true --scan
|
run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan
|
||||||
|
|
||||||
- name: Upload Desktop artifact
|
- name: Upload Desktop artifact
|
||||||
if: ${{ inputs.upload_artifacts }}
|
if: ${{ inputs.upload_artifacts }}
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: desktop-app-${{ runner.os }}-${{ runner.arch }}
|
name: desktop-app
|
||||||
path: desktop/build/compose/binaries/main/app/
|
path: desktop/build/compose/binaries/main/app/Meshtastic/bin/*
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
|
||||||
4
.github/workflows/scheduled-updates.yml
vendored
4
.github/workflows/scheduled-updates.yml
vendored
|
|
@ -2,8 +2,8 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
|
- cron: '0 * * * *' # Run every hour
|
||||||
workflow_dispatch: # Allow manual triggering
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update_assets:
|
update_assets:
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -53,6 +53,3 @@ wireless-install.sh
|
||||||
.worktrees/
|
.worktrees/
|
||||||
/firebase-debug.log.jdk/
|
/firebase-debug.log.jdk/
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
.agent_plans/
|
|
||||||
.agent_refs/
|
|
||||||
.agent_artifacts/
|
|
||||||
|
|
|
||||||
295
.pr5167.diff
295
.pr5167.diff
|
|
@ -1,295 +0,0 @@
|
||||||
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000000..2a27b96906
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
|
||||||
@@ -0,0 +1,39 @@
|
|
||||||
+/*
|
|
||||||
+ * Copyright (c) 2026 Meshtastic LLC
|
|
||||||
+ *
|
|
||||||
+ * This program is free software: you can redistribute it and/or modify
|
|
||||||
+ * it under the terms of the GNU General Public License as published by
|
|
||||||
+ * the Free Software Foundation, either version 3 of the License, or
|
|
||||||
+ * (at your option) any later version.
|
|
||||||
+ *
|
|
||||||
+ * This program is distributed in the hope that it will be useful,
|
|
||||||
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
+ * GNU General Public License for more details.
|
|
||||||
+ *
|
|
||||||
+ * You should have received a copy of the GNU General Public License
|
|
||||||
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
+ */
|
|
||||||
+package org.meshtastic.core.common.di
|
|
||||||
+
|
|
||||||
+import kotlinx.coroutines.CoroutineScope
|
|
||||||
+import kotlinx.coroutines.SupervisorJob
|
|
||||||
+import org.koin.core.annotation.Single
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
+
|
|
||||||
+/**
|
|
||||||
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
|
|
||||||
+ *
|
|
||||||
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
|
|
||||||
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
|
|
||||||
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
|
|
||||||
+ *
|
|
||||||
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
|
|
||||||
+ * and should be used sparingly.
|
|
||||||
+ */
|
|
||||||
+interface ApplicationCoroutineScope : CoroutineScope
|
|
||||||
+
|
|
||||||
+@Single(binds = [ApplicationCoroutineScope::class])
|
|
||||||
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
|
|
||||||
+ override val coroutineContext = SupervisorJob() + ioDispatcher
|
|
||||||
+}
|
|
||||||
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
index 231c84d401..5365ab95e2 100644
|
|
||||||
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import com.eygraber.uri.toAndroidUri
|
|
||||||
import com.eygraber.uri.toKmpUri
|
|
||||||
-import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.jetbrains.compose.resources.getString
|
|
||||||
import org.meshtastic.core.common.gpsDisabled
|
|
||||||
import org.meshtastic.core.common.util.CommonUri
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
import java.net.URLEncoder
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
|
|
||||||
val context = LocalContext.current
|
|
||||||
return remember(context) {
|
|
||||||
{ uri, maxChars ->
|
|
||||||
- withContext(Dispatchers.IO) {
|
|
||||||
+ withContext(ioDispatcher) {
|
|
||||||
@Suppress("TooGenericExceptionCaught")
|
|
||||||
try {
|
|
||||||
val androidUri = uri.toAndroidUri()
|
|
||||||
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
index 031e1fe35d..a938f92ea6 100644
|
|
||||||
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
|
||||||
@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
-import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.meshtastic.core.common.util.CommonUri
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
import java.awt.Desktop
|
|
||||||
import java.awt.FileDialog
|
|
||||||
import java.awt.Frame
|
|
||||||
@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
|
|
||||||
/** JVM — Reads text from a file URI. */
|
|
||||||
@Composable
|
|
||||||
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
|
|
||||||
- withContext(Dispatchers.IO) {
|
|
||||||
+ withContext(ioDispatcher) {
|
|
||||||
@Suppress("TooGenericExceptionCaught")
|
|
||||||
try {
|
|
||||||
val file = File(URI(uri.toString()))
|
|
||||||
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
|
||||||
index dc1c459716..f8ff9fcac8 100644
|
|
||||||
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
|
||||||
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
|
||||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.koin.core.annotation.KoinViewModel
|
|
||||||
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
|
|
||||||
import org.meshtastic.core.common.util.CommonUri
|
|
||||||
import org.meshtastic.core.common.util.safeCatching
|
|
||||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
|
||||||
@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
|
|
||||||
private val firmwareUpdateManager: FirmwareUpdateManager,
|
|
||||||
private val usbManager: FirmwareUsbManager,
|
|
||||||
private val fileHandler: FirmwareFileHandler,
|
|
||||||
+ private val applicationScope: ApplicationCoroutineScope,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
|
|
||||||
@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
|
|
||||||
- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
|
|
||||||
- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
|
|
||||||
- // is cancelled concurrently.
|
|
||||||
- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
|
||||||
- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
|
|
||||||
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
|
|
||||||
+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
|
|
||||||
+ // running even if something tries to cancel it mid-flight.
|
|
||||||
+ applicationScope.launch(NonCancellable) {
|
|
||||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
|
||||||
index 4c48a1ced5..030d84effd 100644
|
|
||||||
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
|
||||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
|
||||||
@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
|
|
||||||
firmwareUpdateManager,
|
|
||||||
usbManager,
|
|
||||||
fileHandler,
|
|
||||||
+ TestApplicationCoroutineScope(testDispatcher),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
|
||||||
index 7032ed4088..a8eddff838 100644
|
|
||||||
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
|
||||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
|
||||||
@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
|
|
||||||
firmwareUpdateManager,
|
|
||||||
usbManager,
|
|
||||||
fileHandler,
|
|
||||||
+ TestApplicationCoroutineScope(testDispatcher),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000000..3ef5c44ef4
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
|
|
||||||
@@ -0,0 +1,26 @@
|
|
||||||
+/*
|
|
||||||
+ * Copyright (c) 2026 Meshtastic LLC
|
|
||||||
+ *
|
|
||||||
+ * This program is free software: you can redistribute it and/or modify
|
|
||||||
+ * it under the terms of the GNU General Public License as published by
|
|
||||||
+ * the Free Software Foundation, either version 3 of the License, or
|
|
||||||
+ * (at your option) any later version.
|
|
||||||
+ *
|
|
||||||
+ * This program is distributed in the hope that it will be useful,
|
|
||||||
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
+ * GNU General Public License for more details.
|
|
||||||
+ *
|
|
||||||
+ * You should have received a copy of the GNU General Public License
|
|
||||||
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
+ */
|
|
||||||
+package org.meshtastic.feature.firmware
|
|
||||||
+
|
|
||||||
+import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
+import kotlinx.coroutines.CoroutineScope
|
|
||||||
+import kotlinx.coroutines.SupervisorJob
|
|
||||||
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
|
|
||||||
+
|
|
||||||
+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
|
|
||||||
+ ApplicationCoroutineScope,
|
|
||||||
+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
|
||||||
index acb1545bdd..23a0d03ab2 100644
|
|
||||||
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
|
||||||
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
|
||||||
@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
|
|
||||||
firmwareUpdateManager,
|
|
||||||
usbManager,
|
|
||||||
fileHandler,
|
|
||||||
+ TestApplicationCoroutineScope(testDispatcher),
|
|
||||||
)
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
index c251b4d5ef..315ad1da85 100644
|
|
||||||
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
import org.meshtastic.core.resources.Res
|
|
||||||
import org.meshtastic.core.resources.debug_export_failed
|
|
||||||
import org.meshtastic.core.resources.debug_export_success
|
|
||||||
@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
|
|
||||||
- withContext(Dispatchers.IO) {
|
|
||||||
+ withContext(ioDispatcher) {
|
|
||||||
try {
|
|
||||||
if (logs.isEmpty()) {
|
|
||||||
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
|
|
||||||
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
index 9afde85e5f..a28a576788 100644
|
|
||||||
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
-import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
|
|
||||||
@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
|
|
||||||
return { fileName -> exportLauncher.launch(fileName) }
|
|
||||||
}
|
|
||||||
|
|
||||||
-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
|
|
||||||
+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
|
|
||||||
Logger.i { "TAK data package exported successfully to $targetUri" }
|
|
||||||
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
index 5b63cc90a3..a9a7285593 100644
|
|
||||||
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
|
||||||
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
-import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
import java.awt.FileDialog
|
|
||||||
import java.awt.Frame
|
|
||||||
import java.io.File
|
|
||||||
@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
- withContext(Dispatchers.IO) {
|
|
||||||
+ withContext(ioDispatcher) {
|
|
||||||
// Run file dialog to ask user where to save
|
|
||||||
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
|
|
||||||
fileDialog.file = fileName
|
|
||||||
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
index 9fb71379fc..bfbb85bc0d 100644
|
|
||||||
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
|
||||||
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
-import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
+import org.meshtastic.core.common.util.ioDispatcher
|
|
||||||
import java.awt.FileDialog
|
|
||||||
import java.awt.Frame
|
|
||||||
import java.io.File
|
|
||||||
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
|
|
||||||
if (directory != null && file != null) {
|
|
||||||
val targetFile = File(directory, file)
|
|
||||||
val data = dataPackageProvider()
|
|
||||||
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
|
|
||||||
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
|
|
||||||
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# Skill: Code Review
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
|
|
||||||
|
|
||||||
## Code Review Checklist
|
|
||||||
|
|
||||||
When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
|
|
||||||
|
|
||||||
### 1. KMP Architecture & Source Set Boundaries
|
|
||||||
- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets.
|
|
||||||
- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries:
|
|
||||||
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
|
|
||||||
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
|
|
||||||
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
|
|
||||||
- `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
|
|
||||||
- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
|
|
||||||
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
|
|
||||||
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
|
|
||||||
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
|
|
||||||
|
|
||||||
### 2. UI & Compose Multiplatform (CMP)
|
|
||||||
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
|
|
||||||
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
|
|
||||||
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
|
|
||||||
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
|
|
||||||
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
|
|
||||||
|
|
||||||
### 3. Navigation & State
|
|
||||||
- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets.
|
|
||||||
- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves.
|
|
||||||
- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry<T>` blocks to correctly tie to the backstack lifetime.
|
|
||||||
|
|
||||||
### 4. Dependency Injection (Koin Annotations)
|
|
||||||
- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`).
|
|
||||||
- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`).
|
|
||||||
|
|
||||||
### 5. Networking, DB & I/O
|
|
||||||
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
|
|
||||||
- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
|
|
||||||
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
|
|
||||||
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
|
|
||||||
- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
|
|
||||||
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
|
|
||||||
|
|
||||||
### 6. Dependency Catalog Aliases
|
|
||||||
- [ ] **JetBrains vs. AndroidX:**
|
|
||||||
- In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`).
|
|
||||||
- In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`.
|
|
||||||
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
|
|
||||||
|
|
||||||
### 7. Testing
|
|
||||||
- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
|
|
||||||
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
|
|
||||||
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
|
|
||||||
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
|
|
||||||
|
|
||||||
### 8. ProGuard / R8 Rules
|
|
||||||
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
|
|
||||||
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
|
|
||||||
|
|
||||||
## Review Output Guidelines
|
|
||||||
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
|
|
||||||
2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
|
|
||||||
3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous.
|
|
||||||
4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions.
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# Skill: Compose Multiplatform (CMP) UI
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
|
|
||||||
|
|
||||||
## 1. UI Components & Layouts
|
|
||||||
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
|
|
||||||
- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
|
|
||||||
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
|
|
||||||
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
|
|
||||||
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
|
|
||||||
|
|
||||||
## 2. Strings & Resources
|
|
||||||
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
|
|
||||||
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
|
|
||||||
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
|
|
||||||
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
|
|
||||||
```kotlin
|
|
||||||
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
|
|
||||||
stringResource(Res.string.battery_percent, formatted) // uses %1$s
|
|
||||||
```
|
|
||||||
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
|
|
||||||
|
|
||||||
### String Formatting Decision Tree
|
|
||||||
Choose the right tool for the job:
|
|
||||||
|
|
||||||
| Scenario | Tool | Example |
|
|
||||||
|----------|------|---------|
|
|
||||||
| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` |
|
|
||||||
| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
|
|
||||||
| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
|
|
||||||
| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
|
|
||||||
| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
|
|
||||||
| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
|
|
||||||
2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
|
|
||||||
3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
|
|
||||||
4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
|
|
||||||
|
|
||||||
- **Workflow to Add a String:**
|
|
||||||
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
|
|
||||||
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
|
|
||||||
3. Validate UI presentation.
|
|
||||||
|
|
||||||
## 3. Tooling & Capabilities
|
|
||||||
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
|
|
||||||
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
|
|
||||||
|
|
||||||
## 4. Compose Previews
|
|
||||||
- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
|
|
||||||
- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
|
|
||||||
|
|
||||||
## 5. Dialog & State Patterns
|
|
||||||
- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
|
|
||||||
|
|
||||||
## Reference Anchors
|
|
||||||
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
|
|
||||||
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
|
|
||||||
- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# Skill: Implement a Feature
|
|
||||||
|
|
||||||
## Description
|
|
||||||
A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### 1. Update Dependencies & Aliases
|
|
||||||
- Check `gradle/libs.versions.toml` before adding libraries.
|
|
||||||
- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`.
|
|
||||||
- Use `compose-multiplatform-*` aliases for CMP dependencies.
|
|
||||||
|
|
||||||
### 2. Define the State & ViewModels
|
|
||||||
- Follow MVI/UDF patterns.
|
|
||||||
- Extend shared ViewModel logic in `feature/<name>/src/commonMain/kotlin/org/meshtastic/feature/<name>/<Name>ViewModel.kt`.
|
|
||||||
- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows.
|
|
||||||
- Keep the ViewModel free of Android framework dependencies.
|
|
||||||
|
|
||||||
### 3. Build the UI
|
|
||||||
- Use Jetpack Compose Multiplatform (CMP).
|
|
||||||
- Define strings in `core:resources` (see the `compose-ui` skill).
|
|
||||||
- Support adaptive layouts (Large/XL breakpoints).
|
|
||||||
|
|
||||||
### 4. Wire Navigation & DI
|
|
||||||
- Define typed route objects in `core:navigation`.
|
|
||||||
- Export the navigation graph as an extension function on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.myFeatureGraph()`).
|
|
||||||
- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`.
|
|
||||||
- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell.
|
|
||||||
|
|
||||||
### 5. Validate Platform Separation
|
|
||||||
- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin.
|
|
||||||
|
|
||||||
### 6. Verify Locally
|
|
||||||
- Run the baseline checks (see `testing-ci` skill):
|
|
||||||
```bash
|
|
||||||
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
|
|
||||||
```
|
|
||||||
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
|
|
||||||
```bash
|
|
||||||
./gradlew assembleFdroidRelease :desktop:runRelease
|
|
||||||
```
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# Skill: KMP Architecture & Source-Set Bridging
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules.
|
|
||||||
|
|
||||||
## 1. Source-Set Boundaries
|
|
||||||
- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports.
|
|
||||||
- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings).
|
|
||||||
- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks.
|
|
||||||
- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`.
|
|
||||||
|
|
||||||
## 2. Bridging Strategies
|
|
||||||
- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`.
|
|
||||||
- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`.
|
|
||||||
- **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors.
|
|
||||||
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
|
|
||||||
|
|
||||||
## 3. Core Libraries & Constraints
|
|
||||||
- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
|
|
||||||
- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
|
|
||||||
- **Standard Library Replacements:**
|
|
||||||
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
|
|
||||||
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
|
|
||||||
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
|
|
||||||
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
|
|
||||||
- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
|
|
||||||
- **BLE:** Route through `core:ble` using **Kable**.
|
|
||||||
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
|
|
||||||
|
|
||||||
## 4. Hierarchy & Source-Set Conventions
|
|
||||||
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
|
|
||||||
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
|
|
||||||
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`.
|
|
||||||
|
|
||||||
## 5. Dependency Catalog Aliases
|
|
||||||
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
|
|
||||||
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`.
|
|
||||||
- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available.
|
|
||||||
|
|
||||||
## 6. I/O & Serialization
|
|
||||||
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
|
|
||||||
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
|
|
||||||
- **Room Patterns:**
|
|
||||||
- Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
|
|
||||||
- Use `LIMIT 1` on `@Query` methods that expect a single row.
|
|
||||||
- Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List<T>)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
|
|
||||||
|
|
||||||
## 7. Build-Logic Conventions
|
|
||||||
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
|
|
||||||
|
|
||||||
## 8. Onboarding a New Target (Desktop/iOS)
|
|
||||||
1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.).
|
|
||||||
2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds.
|
|
||||||
3. Test using `kmpSmokeCompile` to verify cross-platform compilation.
|
|
||||||
4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
|
|
||||||
|
|
||||||
## Reference Anchors
|
|
||||||
- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt`
|
|
||||||
- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt`
|
|
||||||
- **Version Catalog:** `gradle/libs.versions.toml`
|
|
||||||
- **Convention Plugins:** `build-logic/convention/`
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# Skill: DI and Navigation 3 Architecture
|
|
||||||
|
|
||||||
## Description
|
|
||||||
This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
|
|
||||||
|
|
||||||
## Dependency Injection (Koin)
|
|
||||||
|
|
||||||
### Guidelines
|
|
||||||
1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
|
|
||||||
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
|
|
||||||
3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
|
|
||||||
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
|
|
||||||
|
|
||||||
### Anti-Patterns
|
|
||||||
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
|
|
||||||
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
|
|
||||||
|
|
||||||
### Koin Startup Pattern (K2 Compiler Plugin)
|
|
||||||
The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin<T>()` stub, which the plugin transforms at compile time via IR:
|
|
||||||
```kotlin
|
|
||||||
// Bootstrap class — separate from @Module, references the root module graph
|
|
||||||
@KoinApplication(modules = [AppKoinModule::class])
|
|
||||||
object AndroidKoinApp
|
|
||||||
|
|
||||||
// In Application.onCreate()
|
|
||||||
startKoin<AndroidKoinApp> {
|
|
||||||
androidContext(this@MeshUtilApplication)
|
|
||||||
workManagerFactory()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
|
|
||||||
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
|
|
||||||
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
|
|
||||||
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
|
|
||||||
|
|
||||||
## Navigation 3
|
|
||||||
|
|
||||||
### Guidelines
|
|
||||||
1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`).
|
|
||||||
2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings.
|
|
||||||
3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack)`).
|
|
||||||
4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules.
|
|
||||||
5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`.
|
|
||||||
6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths.
|
|
||||||
|
|
||||||
### Anti-Patterns
|
|
||||||
- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`).
|
|
||||||
- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction.
|
|
||||||
- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack<NavKey>` directly with `add(...)` and `removeLastOrNull()`.
|
|
||||||
|
|
||||||
## Reference Anchors
|
|
||||||
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
|
|
||||||
- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
|
|
||||||
- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
|
|
||||||
- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
|
|
||||||
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
# Skill: New Branch Bootstrap
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
|
|
||||||
whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
|
|
||||||
branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
|
|
||||||
|
|
||||||
This replaces the ad-hoc prose that used to be retyped at the start of every session.
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
- Starting any new feature, fix, chore, or refactor.
|
|
||||||
- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
|
|
||||||
- Reproducing a CI failure from a clean baseline.
|
|
||||||
|
|
||||||
## Preconditions (verify before branching)
|
|
||||||
1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
|
|
||||||
2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
|
|
||||||
`meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
|
|
||||||
3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
|
|
||||||
workspace bootstrap rules.
|
|
||||||
4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
|
|
||||||
(required for `google` flavor builds).
|
|
||||||
|
|
||||||
## Standard Recipe
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Fetch latest upstream
|
|
||||||
git fetch upstream --prune --tags
|
|
||||||
|
|
||||||
# 2. Create the branch from upstream/main (never from a local stale main)
|
|
||||||
git switch -c <branch-name> upstream/main
|
|
||||||
|
|
||||||
# 3. Ensure submodules track the new base
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
# 4. Sanity check
|
|
||||||
git --no-pager log -1 --oneline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Branch Naming
|
|
||||||
Use conventional-commit style prefixes that match the PR title convention in AGENTS.md
|
|
||||||
`<git_and_prs>`:
|
|
||||||
|
|
||||||
| Prefix | Use for |
|
|
||||||
| :--- | :--- |
|
|
||||||
| `feat/<scope>` | New user-visible behavior |
|
|
||||||
| `fix/<scope>` | Bug fixes |
|
|
||||||
| `refactor/<scope>` | Code structure changes, no behavior change |
|
|
||||||
| `chore/<scope>` | Tooling, deps, CI, cleanup |
|
|
||||||
| `docs/<scope>` | Documentation only |
|
|
||||||
|
|
||||||
Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
|
|
||||||
|
|
||||||
## Rebase Variant
|
|
||||||
When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch upstream --prune
|
|
||||||
gh pr checkout <NNNN> # checks out the PR head locally
|
|
||||||
git rebase upstream/main
|
|
||||||
git submodule update --init --recursive
|
|
||||||
# Resolve conflicts, then:
|
|
||||||
git push --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
|
|
||||||
|
|
||||||
## Post-Branch Checklist
|
|
||||||
- [ ] Branch name follows conventional prefix.
|
|
||||||
- [ ] Submodules up to date.
|
|
||||||
- [ ] `local.properties` exists.
|
|
||||||
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
|
|
||||||
- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
|
|
||||||
|
|
||||||
## Tip: Prefer `/delegate` for Long Audits
|
|
||||||
If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since
|
|
||||||
v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider
|
|
||||||
suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR
|
|
||||||
end-to-end while the user keeps working locally. See AGENTS.md `<copilot_cli_workflow>`.
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Skill: Project Overview & Codebase Map
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
|
|
||||||
|
|
||||||
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
|
|
||||||
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
|
|
||||||
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
|
|
||||||
|
|
||||||
## Codebase Map
|
|
||||||
|
|
||||||
| Directory | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
|
|
||||||
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
|
|
||||||
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
|
|
||||||
| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. |
|
|
||||||
| `core/model` | Domain models and common data structures. |
|
|
||||||
| `core:proto` | Protobuf definitions (Git submodule). |
|
|
||||||
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
|
|
||||||
| `core:database` | Room KMP database implementation. |
|
|
||||||
| `core:datastore` | Multiplatform DataStore for preferences. |
|
|
||||||
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
|
|
||||||
| `core:domain` | Pure KMP business logic and UseCases. |
|
|
||||||
| `core:data` | Core manager implementations and data orchestration. |
|
|
||||||
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. |
|
|
||||||
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
|
||||||
| `core:barcode` | Barcode scanning (Android-only). |
|
|
||||||
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
|
|
||||||
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
|
|
||||||
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
|
|
||||||
| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. |
|
|
||||||
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
|
|
||||||
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
|
|
||||||
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
|
|
||||||
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
|
|
||||||
|
|
||||||
## Namespacing
|
|
||||||
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
|
|
||||||
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
|
|
||||||
|
|
||||||
## Environment Setup
|
|
||||||
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
|
|
||||||
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
|
|
||||||
```properties
|
|
||||||
MAPS_API_KEY=dummy_key
|
|
||||||
datadogApplicationId=dummy_id
|
|
||||||
datadogClientToken=dummy_token
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workspace Bootstrap (MUST run before any build)
|
|
||||||
Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you.
|
|
||||||
|
|
||||||
1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
|
|
||||||
```bash
|
|
||||||
# Check common macOS/Linux locations in order of preference
|
|
||||||
if [ -z "$ANDROID_HOME" ]; then
|
|
||||||
for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
|
|
||||||
if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path.
|
|
||||||
|
|
||||||
2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors:
|
|
||||||
```bash
|
|
||||||
git submodule update --init
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
|
|
||||||
```bash
|
|
||||||
[ -f local.properties ] || cp secrets.defaults.properties local.properties
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
|
|
||||||
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
|
|
||||||
- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
# Skill: Testing and CI Verification
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type.
|
|
||||||
|
|
||||||
## 1) Baseline local verification order
|
|
||||||
|
|
||||||
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
|
|
||||||
|
|
||||||
> **Why `test allTests` and not just `test`:**
|
|
||||||
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
|
|
||||||
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
|
|
||||||
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
|
|
||||||
> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
|
|
||||||
|
|
||||||
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
|
|
||||||
|
|
||||||
## 2) Change-type verification matrix
|
|
||||||
|
|
||||||
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.
|
|
||||||
- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`.
|
|
||||||
- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`.
|
|
||||||
- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available.
|
|
||||||
- If touching any KMP module, also run `kmpSmokeCompile`.
|
|
||||||
- `worker/service/background` changes: Broad tests, targeted WorkManager checks.
|
|
||||||
- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`.
|
|
||||||
|
|
||||||
## 3) Flavor checks
|
|
||||||
|
|
||||||
Run these when relevant to map, provider, or flavor-specific behavior:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew lintFdroidDebug lintGoogleDebug
|
|
||||||
./gradlew testFdroidDebug testGoogleDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4) CI Pipeline Architecture
|
|
||||||
|
|
||||||
CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups:
|
|
||||||
|
|
||||||
1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
|
|
||||||
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
|
|
||||||
- `shard-core`: `allTests` for all `core:*` KMP modules.
|
|
||||||
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
|
|
||||||
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
|
|
||||||
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
|
|
||||||
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
|
|
||||||
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
|
|
||||||
4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`).
|
|
||||||
|
|
||||||
### Runner Strategy (Three Tiers)
|
|
||||||
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times.
|
|
||||||
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility.
|
|
||||||
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging.
|
|
||||||
|
|
||||||
### CI Gradle Properties
|
|
||||||
`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
|
|
||||||
- `org.gradle.daemon=false` (single-use runners)
|
|
||||||
- `kotlin.incremental=false` (fresh checkouts)
|
|
||||||
- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon
|
|
||||||
- VFS watching disabled, workers capped at 4
|
|
||||||
- `org.gradle.isolated-projects=true` for better parallelism
|
|
||||||
- Disables unused Android build features (`resvalues`, `shaders`)
|
|
||||||
|
|
||||||
### CI Conventions
|
|
||||||
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
|
|
||||||
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
|
|
||||||
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
|
|
||||||
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level.
|
|
||||||
- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
|
|
||||||
- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
|
|
||||||
- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10).
|
|
||||||
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
|
|
||||||
- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI.
|
|
||||||
- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
|
|
||||||
- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache.
|
|
||||||
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
|
|
||||||
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.
|
|
||||||
|
|
||||||
296
AGENTS.md
296
AGENTS.md
|
|
@ -1,108 +1,208 @@
|
||||||
# Meshtastic Android - Unified Agent & Developer Guide
|
# Meshtastic Android - Agent Guide
|
||||||
|
|
||||||
<role>
|
This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
|
||||||
You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns.
|
|
||||||
</role>
|
|
||||||
|
|
||||||
<context_and_memory>
|
For execution-focused recipes, see `docs/agent-playbooks/README.md`.
|
||||||
- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience.
|
|
||||||
- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP.
|
|
||||||
- **Core Architecture:**
|
|
||||||
- `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings.
|
|
||||||
- App root DI and graph assembly live in the `app` and `desktop` host shells.
|
|
||||||
- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work:
|
|
||||||
- `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting.
|
|
||||||
- `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions.
|
|
||||||
- `.skills/compose-ui/` - Adaptive UI, placeholders, string resources.
|
|
||||||
- `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations.
|
|
||||||
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
|
|
||||||
- `.skills/implement-feature/` - Step-by-step feature workflow.
|
|
||||||
- `.skills/code-review/` - PR validation checklist.
|
|
||||||
- `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
|
|
||||||
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
|
|
||||||
</context_and_memory>
|
|
||||||
|
|
||||||
<process>
|
## 1. Project Vision & Architecture
|
||||||
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
|
Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
|
||||||
1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
|
|
||||||
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
|
|
||||||
3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
|
|
||||||
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
|
|
||||||
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
|
|
||||||
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
|
|
||||||
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
|
|
||||||
```
|
|
||||||
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
|
|
||||||
```
|
|
||||||
> **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
|
|
||||||
> For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
|
|
||||||
</process>
|
|
||||||
|
|
||||||
<agent_tools>
|
- **Language:** Kotlin (primary), AIDL.
|
||||||
- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search.
|
- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED.
|
||||||
- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt.
|
- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
|
||||||
- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11.
|
- **Flavors:**
|
||||||
- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended:
|
- `fdroid`: Open source only, no tracking/analytics.
|
||||||
- `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs)
|
- `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production").
|
||||||
- `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x)
|
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
|
||||||
- `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI)
|
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
|
||||||
- `https://github.com/JuulLabs/kable` (BLE)
|
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
|
||||||
- `https://github.com/coil-kt/coil` (Coil 3 KMP)
|
- **UI:** Jetpack Compose Multiplatform (Material 3).
|
||||||
- `https://github.com/ktorio/ktor` (Ktor Networking)
|
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
|
||||||
- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing.
|
- **Navigation:** JetBrains Navigation 3 (Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
|
||||||
</agent_tools>
|
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
|
||||||
|
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
|
||||||
|
- **Database:** Room KMP.
|
||||||
|
|
||||||
<documentation_sync>
|
## 2. Codebase Map
|
||||||
`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here:
|
|
||||||
- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`.
|
|
||||||
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
|
|
||||||
- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
|
|
||||||
|
|
||||||
Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
|
| Directory | Description |
|
||||||
</documentation_sync>
|
| :--- | :--- |
|
||||||
|
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
|
||||||
|
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
|
||||||
|
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
|
||||||
|
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
|
||||||
|
| `core/model` | Domain models and common data structures. |
|
||||||
|
| `core:proto` | Protobuf definitions (Git submodule). |
|
||||||
|
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
|
||||||
|
| `core:database` | Room KMP database implementation. |
|
||||||
|
| `core:datastore` | Multiplatform DataStore for preferences. |
|
||||||
|
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
|
||||||
|
| `core:domain` | Pure KMP business logic and UseCases. |
|
||||||
|
| `core:data` | Core manager implementations and data orchestration. |
|
||||||
|
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
||||||
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies per feature domain (e.g., `SettingsRoute`, `NodesRoute`). `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence — new routes are registered at compile time. |
|
||||||
|
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
|
| `core:barcode` | Barcode scanning (Android-only). |
|
||||||
|
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
|
||||||
|
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
|
||||||
|
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
|
||||||
|
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
|
||||||
|
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
|
||||||
|
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. |
|
||||||
|
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
|
||||||
|
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
|
||||||
|
| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. |
|
||||||
|
|
||||||
<rules>
|
## 3. Development Guidelines & Coding Standards
|
||||||
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
|
|
||||||
- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
|
|
||||||
- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
|
|
||||||
- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
|
|
||||||
- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
|
|
||||||
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
|
|
||||||
- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
|
|
||||||
- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
|
|
||||||
- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
|
|
||||||
- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
|
|
||||||
</rules>
|
|
||||||
|
|
||||||
<copilot_cli_workflow>
|
### A. UI Development (Jetpack Compose)
|
||||||
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
|
- **Material 3:** The app uses Material 3.
|
||||||
section.
|
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
||||||
|
- **String formatting:** CMP's `stringResource(res, args)` / `getString(res, args)` only support `%N$s` (string) and `%N$d` (integer) positional specifiers. Float formats like `%N$.1f` are NOT supported — they pass through unsubstituted. For float values, pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass the result as a `%N$s` string arg. Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings, since CMP does not convert `%%` to `%`. For JVM-only code using `formatString()` (which wraps `String.format()`), full printf specifiers including `%N$.Nf` and `%%` are supported.
|
||||||
|
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
||||||
|
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
|
||||||
|
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
|
||||||
|
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
|
||||||
|
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
|
||||||
|
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
||||||
|
|
||||||
- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet"
|
### B. Logic & Data Layer
|
||||||
prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*,
|
- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
|
||||||
*"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub
|
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
|
||||||
cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive
|
- `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
|
||||||
session on work that can run unattended.
|
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
||||||
- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"*
|
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
||||||
on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep
|
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). Note: JetBrains now recommends `kotlinx-io` as the official Kotlin I/O library (built on Okio). This project standardizes on Okio directly; do not migrate without explicit decision.
|
||||||
research across GitHub and the web with better source grounding than an ad-hoc prompt.
|
- `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). Note: `Dispatchers.IO` is available in `commonMain` since kotlinx.coroutines 1.8.0, but this project uses the `ioDispatcher` wrapper for consistency.
|
||||||
- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation
|
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
|
||||||
plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to
|
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
|
||||||
plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent
|
- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
|
||||||
from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to
|
- **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.
|
||||||
`.agent_plans/` (git-ignored) for multi-module refactors.
|
- **`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.
|
||||||
- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle
|
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
|
||||||
quality passes, offer `/share` to export the findings to a gist or markdown file. These
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
reports are valuable artifacts — don't let them die in session history.
|
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
||||||
- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
|
||||||
file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration.
|
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
|
||||||
Avoid re-issuing the same prompt verbatim.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main"
|
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
|
||||||
or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe.
|
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
|
||||||
</copilot_cli_workflow>
|
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
||||||
|
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane properties (Android) or Gradle CLI (desktop) to enable remote license/funding fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone — that burns API calls on every PR check.
|
||||||
|
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
|
||||||
|
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
|
||||||
|
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
|
||||||
|
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code.
|
||||||
|
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
|
||||||
|
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
|
||||||
|
|
||||||
<git_and_prs>
|
### C. Namespacing
|
||||||
- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
|
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
|
||||||
- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
|
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
|
||||||
- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
|
|
||||||
</git_and_prs>
|
## 4. Execution Protocol
|
||||||
|
|
||||||
|
### A. Environment Setup
|
||||||
|
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
|
||||||
|
2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
|
||||||
|
```properties
|
||||||
|
MAPS_API_KEY=dummy_key
|
||||||
|
datadogApplicationId=dummy_id
|
||||||
|
datadogClientToken=dummy_token
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Strict Execution Commands
|
||||||
|
Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
|
||||||
|
|
||||||
|
**Baseline (recommended order):**
|
||||||
|
```bash
|
||||||
|
./gradlew clean
|
||||||
|
./gradlew spotlessCheck
|
||||||
|
./gradlew spotlessApply
|
||||||
|
./gradlew detekt
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew test allTests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
```bash
|
||||||
|
# Full host-side unit test run (required — see note below):
|
||||||
|
./gradlew test allTests
|
||||||
|
|
||||||
|
# Pure-Android / pure-JVM modules only (app, desktop, core:api, core:barcode, feature:widget, mesh_service_example):
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# KMP modules only (all core:* KMP + all feature:* KMP modules — jvmTest + testAndroidHostTest + iosSimulatorArm64Test):
|
||||||
|
./gradlew allTests
|
||||||
|
|
||||||
|
# CI-aligned flavor-explicit Android unit tests:
|
||||||
|
./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest
|
||||||
|
|
||||||
|
./gradlew connectedAndroidTest # Run instrumented tests
|
||||||
|
./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
|
||||||
|
./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why `test allTests` and not just `test`:**
|
||||||
|
> In KMP modules, the `test` task name is **ambiguous** — Gradle matches both `testAndroid` and
|
||||||
|
> `testAndroidHostTest` and refuses to run either, silently skipping all 25 KMP modules.
|
||||||
|
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP Gradle plugin for each
|
||||||
|
> KMP module. It runs `jvmTest`, `testAndroidHostTest` (where declared with `withHostTest {}`), and
|
||||||
|
> `iosSimulatorArm64Test` (disabled at execution — iOS targets are compile-only). Conversely,
|
||||||
|
> `allTests` does **not** cover the pure-Android modules (`:app`, `:core:api`, `:core:barcode`,
|
||||||
|
> `:feature:widget`, `:mesh_service_example`, `:desktop`), which is why both are needed.
|
||||||
|
|
||||||
|
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
|
||||||
|
|
||||||
|
**CI workflow conventions (GitHub Actions):**
|
||||||
|
- Reusable CI in `.github/workflows/reusable-check.yml` is structured as four parallel job groups:
|
||||||
|
1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation. Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
|
||||||
|
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
|
||||||
|
- `shard-core`: `allTests` for all `core:*` KMP modules.
|
||||||
|
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
|
||||||
|
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`).
|
||||||
|
Each shard generates its own Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
|
||||||
|
Downstream jobs (test-shards, android-check, build-desktop) use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
|
||||||
|
3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`).
|
||||||
|
4. **`build-desktop`** — Desktop packaging (depends on `lint-check`).
|
||||||
|
- Test sharding uses `fail-fast: false` so a failure in one shard does not cancel the others.
|
||||||
|
- JUnit Platform parallel execution is enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races (JVM-global singleton used by 19+ ViewModel test classes). Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
|
||||||
|
- `test-retry` plugin (maxRetries=2, maxFailures=10) is applied to all module types: `AndroidApplicationConventionPlugin`, `AndroidLibraryConventionPlugin`, and `KmpLibraryConventionPlugin`.
|
||||||
|
- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
|
||||||
|
- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
|
||||||
|
- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
|
||||||
|
- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
|
||||||
|
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
|
||||||
|
- **Runner strategy (three tiers):**
|
||||||
|
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times.
|
||||||
|
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility.
|
||||||
|
- **Desktop runners:** Reusable CI uses `ubuntu-24.04` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`.
|
||||||
|
- **CI Gradle properties:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties` before any Gradle invocation. Key CI overrides: `org.gradle.daemon=false` (single-use runners), `kotlin.incremental=false` (fresh checkouts), `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4, `org.gradle.isolated-projects=true` for better parallelism. Disables unused Android build features (`resvalues`, `shaders`). This follows the nowinandroid `ci-gradle.properties` pattern.
|
||||||
|
- **CI optimization strategies (2026):** Applied comprehensive CI optimizations (P0-P3):
|
||||||
|
- **P0 (merged Gradle invocations):** `lint-check` merges spotlessCheck, detekt, android lint, and kmpSmokeCompile into a single Gradle invocation to avoid 3x cold-start overhead. Uses `filter: 'blob:none'` for blobless git clone. Switches submodules from `'recursive'` to boolean (saves overhead on nested submodule discovery).
|
||||||
|
- **P1 (reduced PR overhead):** Added `run_coverage` workflow input (default: true); PRs skip Kover reports via conditional tasks in test-shards matrix. Increased `maxParallelForks` in CI to use all available processors (4 on standard runners) when `ci=true` property is set, vs. half locally for system responsiveness.
|
||||||
|
- **P2 (build feature optimization):** Detekt disables non-essential report formats in CI (html, txt, md); retains only xml + sarif for GitHub annotations. Disables unused Android build features (resvalues, shaders) in `ci-gradle.properties`.
|
||||||
|
- **P3 (structural improvement):** Removed `verify-check-changes-filter` from `validate-and-build` dependencies; it now runs in parallel as a standalone required check instead of gating the main build.
|
||||||
|
- **`maxParallelForks` CI logic:** ProjectExtensions.kt line ~79 checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true` to enable this.
|
||||||
|
- **Detekt report formats:** Detekt.kt line ~44 checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are required for GitHub reporting.
|
||||||
|
- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
|
||||||
|
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` failures when Robolectric downloads instrumented SDK jars. The cache key is `robolectric-{version}-sdk{level}` — update it when bumping the Robolectric version in `libs.versions.toml` or the SDK level in `robolectric.properties` / `@Config(sdk = ...)`.
|
||||||
|
- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle.
|
||||||
|
- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent the agent from getting stuck in an interactive prompt.
|
||||||
|
- **Text Search:** Prefer using `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase.
|
||||||
|
|
||||||
|
### C. Documentation Sync
|
||||||
|
`AGENTS.md` is the single source of truth for agent instructions. `.github/copilot-instructions.md` and `GEMINI.md` are thin stubs that redirect here — do NOT duplicate content into them.
|
||||||
|
|
||||||
|
When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed.
|
||||||
|
|
||||||
|
## 5. Troubleshooting
|
||||||
|
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
|
||||||
|
- **Missing Secrets:** Check `local.properties`.
|
||||||
|
- **JDK Version:** JDK 21 is required.
|
||||||
|
- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
|
||||||
|
- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Meshtastic Android - Google Gemini Guide
|
# Meshtastic Android - Agent Guide
|
||||||
|
|
||||||
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
|
**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically.
|
||||||
|
|
||||||
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
|
See [AGENTS.md](AGENTS.md) for architecture, conventions, execution protocol, and coding standards.
|
||||||
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
|
See [docs/agent-playbooks/README.md](docs/agent-playbooks/README.md) for version baselines and task recipes.
|
||||||
|
|
|
||||||
46
Gemfile.lock
46
Gemfile.lock
|
|
@ -3,13 +3,13 @@ GEM
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.9.0)
|
addressable (2.8.8)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1240.0)
|
aws-partitions (1.1213.0)
|
||||||
aws-sdk-core (3.245.0)
|
aws-sdk-core (3.242.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
|
|
@ -17,11 +17,11 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.123.0)
|
aws-sdk-kms (1.121.0)
|
||||||
aws-sdk-core (~> 3, >= 3.244.0)
|
aws-sdk-core (~> 3, >= 3.241.4)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.219.0)
|
aws-sdk-s3 (1.213.0)
|
||||||
aws-sdk-core (~> 3, >= 3.244.0)
|
aws-sdk-core (~> 3, >= 3.241.4)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
|
|
@ -29,7 +29,7 @@ GEM
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.1.2)
|
bigdecimal (4.0.1)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
|
|
@ -68,11 +68,11 @@ GEM
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.4)
|
faraday-retry (1.0.3)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.0)
|
||||||
fastlane (2.233.0)
|
fastlane (2.232.2)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
abbrev (~> 0.1.2)
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
|
@ -92,7 +92,7 @@ GEM
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.1.0)
|
fastlane-sirp (>= 1.0.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
|
@ -122,9 +122,10 @@ GEM
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.0.0)
|
||||||
|
sysrandom (~> 1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.99.0)
|
google-apis-androidpublisher_v3 (0.95.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
|
@ -138,15 +139,15 @@ GEM
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.17.0)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.61.0)
|
google-apis-storage_v1 (0.59.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-cloud-core (1.8.0)
|
google-cloud-core (1.8.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (2.1.1)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.6.0)
|
google-cloud-errors (1.5.0)
|
||||||
google-cloud-storage (1.59.0)
|
google-cloud-storage (1.58.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-core (>= 0.18, < 2)
|
google-apis-core (>= 0.18, < 2)
|
||||||
|
|
@ -168,13 +169,13 @@ GEM
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.19.4)
|
json (2.18.1)
|
||||||
jwt (2.10.2)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.20.1)
|
multi_json (1.19.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
|
|
@ -184,13 +185,13 @@ GEM
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.2)
|
||||||
rake (13.4.2)
|
rake (13.3.1)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.4.1)
|
retriable (3.1.2)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
|
|
@ -204,6 +205,7 @@ GEM
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
|
sysrandom (1.0.5)
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|
|
||||||
31
SOUL.md
Normal file
31
SOUL.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Meshtastic-Android: AI Agent Soul (SOUL.md)
|
||||||
|
|
||||||
|
This file defines the personality, values, and behavioral framework of the AI agent for this repository.
|
||||||
|
|
||||||
|
## 1. Core Identity
|
||||||
|
I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack.
|
||||||
|
|
||||||
|
## 2. Core Truths & Values
|
||||||
|
- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets.
|
||||||
|
- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible.
|
||||||
|
- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic.
|
||||||
|
- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility.
|
||||||
|
|
||||||
|
## 3. Communication Style (The "Vibe")
|
||||||
|
- **Direct & Concise:** I skip the fluff. I provide technical rationale first.
|
||||||
|
- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions.
|
||||||
|
- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it.
|
||||||
|
|
||||||
|
## 4. Operational Boundaries
|
||||||
|
- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules.
|
||||||
|
- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic.
|
||||||
|
- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity.
|
||||||
|
- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system.
|
||||||
|
|
||||||
|
## 5. Evolution
|
||||||
|
I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers.
|
||||||
|
|
||||||
|
For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth.
|
||||||
|
For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -171,6 +171,8 @@ configure<ApplicationExtension> {
|
||||||
} else {
|
} else {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -241,10 +243,9 @@ dependencies {
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.compose.multiplatform.animation)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.compose.multiplatform.material3)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.compose.multiplatform.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.text)
|
||||||
implementation(libs.compose.multiplatform.ui)
|
|
||||||
implementation(libs.androidx.glance.appwidget)
|
implementation(libs.androidx.glance.appwidget)
|
||||||
implementation(libs.androidx.glance.appwidget.preview)
|
implementation(libs.androidx.glance.appwidget.preview)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
@ -264,6 +265,7 @@ dependencies {
|
||||||
implementation(libs.usb.serial.android)
|
implementation(libs.usb.serial.android)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.androidx.compose)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
implementation(libs.koin.compose.viewmodel)
|
||||||
implementation(libs.koin.androidx.workmanager)
|
implementation(libs.koin.androidx.workmanager)
|
||||||
implementation(libs.koin.annotations)
|
implementation(libs.koin.annotations)
|
||||||
|
|
@ -279,6 +281,7 @@ dependencies {
|
||||||
googleImplementation(libs.maps.compose)
|
googleImplementation(libs.maps.compose)
|
||||||
googleImplementation(libs.maps.compose.utils)
|
googleImplementation(libs.maps.compose.utils)
|
||||||
googleImplementation(libs.maps.compose.widgets)
|
googleImplementation(libs.maps.compose.widgets)
|
||||||
|
googleImplementation(libs.dd.sdk.android.compose)
|
||||||
googleImplementation(libs.dd.sdk.android.logs)
|
googleImplementation(libs.dd.sdk.android.logs)
|
||||||
googleImplementation(libs.dd.sdk.android.rum)
|
googleImplementation(libs.dd.sdk.android.rum)
|
||||||
googleImplementation(libs.dd.sdk.android.session.replay)
|
googleImplementation(libs.dd.sdk.android.session.replay)
|
||||||
|
|
@ -294,6 +297,12 @@ dependencies {
|
||||||
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
|
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
|
||||||
fdroidImplementation(libs.osmbonuspack)
|
fdroidImplementation(libs.osmbonuspack)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
androidTestImplementation(libs.koin.test)
|
||||||
|
|
||||||
testImplementation(kotlin("test-junit"))
|
testImplementation(kotlin("test-junit"))
|
||||||
testImplementation(libs.androidx.work.testing)
|
testImplementation(libs.androidx.work.testing)
|
||||||
testImplementation(libs.koin.test)
|
testImplementation(libs.koin.test)
|
||||||
|
|
@ -301,7 +310,7 @@ dependencies {
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.androidx.test.core)
|
testImplementation(libs.androidx.test.core)
|
||||||
testImplementation(libs.compose.multiplatform.ui.test)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
testImplementation(libs.androidx.test.ext.junit)
|
testImplementation(libs.androidx.test.ext.junit)
|
||||||
testImplementation(libs.androidx.glance.appwidget)
|
testImplementation(libs.androidx.glance.appwidget)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
app/proguard-rules.pro
vendored
84
app/proguard-rules.pro
vendored
|
|
@ -1,45 +1,61 @@
|
||||||
# ============================================================================
|
# Add project specific ProGuard rules here.
|
||||||
# Meshtastic Android — ProGuard / R8 rules for release minification
|
# You can control the set of applied configuration files using the
|
||||||
# ============================================================================
|
# proguardFiles setting in build.gradle.kts.
|
||||||
# Open-source project: obfuscation and optimization are disabled. We rely on
|
|
||||||
# tree-shaking (unused code removal) for APK size reduction.
|
|
||||||
#
|
#
|
||||||
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room,
|
# For more details, see
|
||||||
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3,
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
|
|
||||||
# config/proguard/shared-rules.pro and are wired in by the
|
|
||||||
# AndroidApplicationConventionPlugin. This file holds only Android-specific
|
|
||||||
# rules and R8-only directives.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# ---- General ----------------------------------------------------------------
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
# Open-source — no need to obfuscate
|
# Uncomment this to preserve the line number information for
|
||||||
-dontobfuscate
|
# debugging stack traces.
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
# Disable R8 optimization passes. Tree-shaking (unused code removal) still
|
# If you keep the line number information, uncomment this to
|
||||||
# runs — only method-body rewrites and call-site transformations are suppressed.
|
# hide the original source file name.
|
||||||
#
|
#-renamesourcefileattribute SourceFile
|
||||||
# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on
|
|
||||||
# Composer.<clinit>() and ComposerImpl.<clinit>(), plus -assumevalues on
|
|
||||||
# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives
|
|
||||||
# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the
|
|
||||||
# target classes are preserved by -keep rules. The result is that the Compose
|
|
||||||
# recomposer/frame-clock/animation state machines silently freeze on their
|
|
||||||
# first frame in release builds. -dontoptimize is the only directive that
|
|
||||||
# disables processing of -assumenosideeffects/-assumevalues. See #5146.
|
|
||||||
-dontoptimize
|
|
||||||
|
|
||||||
# Dump the full merged R8 configuration (app rules + all library consumer rules)
|
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
|
||||||
# for auditing. Inspect this file after a release build to see what libraries inject.
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
-printconfiguration build/outputs/mapping/r8-merged-config.txt
|
|
||||||
|
|
||||||
# ---- Networking (transitive references from Ktor on Android) ----------------
|
# Needed for protobufs
|
||||||
|
-keep class com.google.protobuf.** { *; }
|
||||||
|
-keep class org.meshtastic.proto.** { *; }
|
||||||
|
|
||||||
|
# Networking
|
||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
|
|
||||||
# Compose runtime/ui/animation/foundation/material3 keep rules now live in
|
# ?
|
||||||
# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
|
-dontwarn java.lang.reflect.**
|
||||||
# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
# Our app is opensource no need to obsfucate
|
||||||
|
-dontobfuscate
|
||||||
|
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
|
||||||
|
|
||||||
|
# Koin DI: prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
|
||||||
|
# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable).
|
||||||
|
-keep class org.koin.core.error.** { *; }
|
||||||
|
|
||||||
|
# R8 optimization for Kotlin null checks (AGP 9.0+)
|
||||||
|
-processkotlinnullchecks remove
|
||||||
|
|
||||||
|
# Compose Multiplatform resources: keep the resource library internals and generated Res
|
||||||
|
# accessor classes so R8 does not tree-shake the resource loading infrastructure.
|
||||||
|
# Without these rules the fdroid flavor (which has fewer transitive Compose dependencies
|
||||||
|
# than google) crashes at startup with a misleading URLDecodeException due to R8
|
||||||
|
# exception-class merging (see Koin keep rule above).
|
||||||
|
-keep class org.jetbrains.compose.resources.** { *; }
|
||||||
|
-keep class org.meshtastic.core.resources.** { *; }
|
||||||
|
|
||||||
|
# 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.** { *; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.meshtastic.app.filter
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.inject
|
||||||
|
import org.meshtastic.core.repository.FilterPrefs
|
||||||
|
import org.meshtastic.core.repository.MessageFilter
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MessageFilterIntegrationTest : KoinTest {
|
||||||
|
|
||||||
|
private val filterPrefs: FilterPrefs by inject()
|
||||||
|
|
||||||
|
private val filterService: MessageFilter by inject()
|
||||||
|
|
||||||
|
@org.junit.Ignore("Flaky integration test, needs Koin test rule setup")
|
||||||
|
@Test
|
||||||
|
fun filterPrefsIntegration() = runTest {
|
||||||
|
filterPrefs.setFilterEnabled(true)
|
||||||
|
filterPrefs.setFilterWords(setOf("test", "spam"))
|
||||||
|
// Wait briefly for DataStore to process the writes and flows to emit
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
filterService.rebuildPatterns()
|
||||||
|
|
||||||
|
assertTrue(filterService.shouldFilter("this is a test message"))
|
||||||
|
assertTrue(filterService.shouldFilter("spam content"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,8 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
|
||||||
import org.meshtastic.app.map.component.CacheLayout
|
import org.meshtastic.app.map.component.CacheLayout
|
||||||
import org.meshtastic.app.map.component.DownloadButton
|
import org.meshtastic.app.map.component.DownloadButton
|
||||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||||
|
import org.meshtastic.app.map.component.MapButton
|
||||||
|
import org.meshtastic.app.map.component.MapControlsOverlay
|
||||||
import org.meshtastic.app.map.model.CustomTileSource
|
import org.meshtastic.app.map.model.CustomTileSource
|
||||||
import org.meshtastic.app.map.model.MarkerWithLabel
|
import org.meshtastic.app.map.model.MarkerWithLabel
|
||||||
import org.meshtastic.core.common.gpsDisabled
|
import org.meshtastic.core.common.gpsDisabled
|
||||||
|
|
@ -128,8 +130,6 @@ import org.meshtastic.core.ui.util.formatAgo
|
||||||
import org.meshtastic.core.ui.util.showToast
|
import org.meshtastic.core.ui.util.showToast
|
||||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||||
import org.meshtastic.feature.map.LastHeardFilter
|
import org.meshtastic.feature.map.LastHeardFilter
|
||||||
import org.meshtastic.feature.map.component.MapButton
|
|
||||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
|
||||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||||
import org.meshtastic.proto.Waypoint
|
import org.meshtastic.proto.Waypoint
|
||||||
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
|
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
|
||||||
|
|
@ -861,9 +861,15 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
|
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
|
||||||
) {
|
) {
|
||||||
val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
|
Text(
|
||||||
val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
|
modifier = Modifier.padding(16.dp),
|
||||||
Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
|
text =
|
||||||
|
stringResource(
|
||||||
|
Res.string.map_cache_info,
|
||||||
|
cacheCapacity / (1024.0 * 1024.0),
|
||||||
|
currentCacheUsage / (1024.0 * 1024.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,21 +124,20 @@ fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: ()
|
||||||
return polyline
|
return polyline
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MapView.addPositionMarkers(positions: List<Position>, onClick: (Int) -> Unit): List<Marker> {
|
fun MapView.addPositionMarkers(positions: List<Position>, onClick: () -> Unit): List<Marker> {
|
||||||
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
|
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
|
||||||
val markers =
|
val markers = positions.map {
|
||||||
positions.map { pos ->
|
Marker(this).apply {
|
||||||
Marker(this).apply {
|
icon = navIcon
|
||||||
icon = navIcon
|
rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
|
||||||
rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7)
|
||||||
position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
|
setOnMarkerClickListener { _, _ ->
|
||||||
setOnMarkerClickListener { _, _ ->
|
onClick()
|
||||||
onClick(pos.time)
|
true
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
overlays.addAll(markers)
|
overlays.addAll(markers)
|
||||||
|
|
||||||
return markers
|
return markers
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,9 @@ import org.meshtastic.proto.Position
|
||||||
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
|
* 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
|
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
|
||||||
* ([NodeTrackOsmMap]).
|
* ([NodeTrackOsmMap]).
|
||||||
*
|
|
||||||
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NodeTrackMap(
|
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
|
||||||
destNum: Int,
|
|
||||||
positions: List<Position>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
selectedPositionTime: Int? = null,
|
|
||||||
onPositionSelected: ((Int) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val vm = koinViewModel<NodeMapViewModel>()
|
val vm = koinViewModel<NodeMapViewModel>()
|
||||||
vm.setDestNum(destNum)
|
vm.setDestNum(destNum)
|
||||||
NodeTrackOsmMap(
|
NodeTrackOsmMap(
|
||||||
|
|
@ -44,7 +36,5 @@ fun NodeTrackMap(
|
||||||
applicationId = vm.applicationId,
|
applicationId = vm.applicationId,
|
||||||
mapStyleId = vm.mapStyleId,
|
mapStyleId = vm.mapStyleId,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
selectedPositionTime = selectedPositionTime,
|
|
||||||
onPositionSelected = onPositionSelected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import org.meshtastic.app.map.addCopyright
|
||||||
import org.meshtastic.app.map.addPolyline
|
import org.meshtastic.app.map.addPolyline
|
||||||
import org.meshtastic.app.map.addPositionMarkers
|
import org.meshtastic.app.map.addPositionMarkers
|
||||||
import org.meshtastic.app.map.addScaleBarOverlay
|
import org.meshtastic.app.map.addScaleBarOverlay
|
||||||
|
import org.meshtastic.app.map.component.MapControlsOverlay
|
||||||
import org.meshtastic.app.map.model.CustomTileSource
|
import org.meshtastic.app.map.model.CustomTileSource
|
||||||
import org.meshtastic.app.map.rememberMapViewWithLifecycle
|
import org.meshtastic.app.map.rememberMapViewWithLifecycle
|
||||||
import org.meshtastic.core.common.util.nowSeconds
|
import org.meshtastic.core.common.util.nowSeconds
|
||||||
|
|
@ -49,7 +50,6 @@ import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.last_heard_filter_label
|
import org.meshtastic.core.resources.last_heard_filter_label
|
||||||
import org.meshtastic.feature.map.LastHeardFilter
|
import org.meshtastic.feature.map.LastHeardFilter
|
||||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
|
||||||
import org.meshtastic.proto.Position
|
import org.meshtastic.proto.Position
|
||||||
import org.osmdroid.util.BoundingBox
|
import org.osmdroid.util.BoundingBox
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
|
|
@ -61,10 +61,8 @@ import kotlin.math.roundToInt
|
||||||
*
|
*
|
||||||
* Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
|
* 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
|
* 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
|
* minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so
|
||||||
* so users can adjust the time range directly from the map.
|
* 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
|
* 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.
|
* location tracking. It is designed to be embedded inside the position-log adaptive layout.
|
||||||
|
|
@ -75,8 +73,6 @@ fun NodeTrackOsmMap(
|
||||||
applicationId: String,
|
applicationId: String,
|
||||||
mapStyleId: Int,
|
mapStyleId: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selectedPositionTime: Int? = null,
|
|
||||||
onPositionSelected: ((Int) -> Unit)? = null,
|
|
||||||
mapViewModel: MapViewModel = koinViewModel(),
|
mapViewModel: MapViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -113,15 +109,7 @@ fun NodeTrackOsmMap(
|
||||||
map.addCopyright()
|
map.addCopyright()
|
||||||
map.addScaleBarOverlay(density)
|
map.addScaleBarOverlay(density)
|
||||||
map.addPolyline(density, geoPoints) {}
|
map.addPolyline(density, geoPoints) {}
|
||||||
map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
|
map.addPositionMarkers(filteredPositions) {}
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import co.touchlab.kermit.LogWriter
|
||||||
import co.touchlab.kermit.Severity
|
import co.touchlab.kermit.Severity
|
||||||
import com.datadog.android.Datadog
|
import com.datadog.android.Datadog
|
||||||
import com.datadog.android.DatadogSite
|
import com.datadog.android.DatadogSite
|
||||||
|
import com.datadog.android.compose.enableComposeActionTracking
|
||||||
import com.datadog.android.core.configuration.Configuration
|
import com.datadog.android.core.configuration.Configuration
|
||||||
import com.datadog.android.log.Logger
|
import com.datadog.android.log.Logger
|
||||||
import com.datadog.android.log.Logs
|
import com.datadog.android.log.Logs
|
||||||
|
|
@ -159,6 +160,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||||
.trackFrustrations(false) // Disable click-tracking based frustration detection
|
.trackFrustrations(false) // Disable click-tracking based frustration detection
|
||||||
.trackLongTasks()
|
.trackLongTasks()
|
||||||
.trackNonFatalAnrs(true)
|
.trackNonFatalAnrs(true)
|
||||||
|
.enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags
|
||||||
.setSessionSampleRate(sampleRate)
|
.setSessionSampleRate(sampleRate)
|
||||||
.build()
|
.build()
|
||||||
Rum.enable(rumConfiguration)
|
Rum.enable(rumConfiguration)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -97,6 +96,8 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog
|
||||||
import org.meshtastic.app.map.component.CustomMapLayersSheet
|
import org.meshtastic.app.map.component.CustomMapLayersSheet
|
||||||
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
|
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
|
||||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||||
|
import org.meshtastic.app.map.component.MapButton
|
||||||
|
import org.meshtastic.app.map.component.MapControlsOverlay
|
||||||
import org.meshtastic.app.map.component.MapFilterDropdown
|
import org.meshtastic.app.map.component.MapFilterDropdown
|
||||||
import org.meshtastic.app.map.component.MapTypeDropdown
|
import org.meshtastic.app.map.component.MapTypeDropdown
|
||||||
import org.meshtastic.app.map.component.NodeClusterMarkers
|
import org.meshtastic.app.map.component.NodeClusterMarkers
|
||||||
|
|
@ -135,8 +136,6 @@ import org.meshtastic.core.ui.util.formatAgo
|
||||||
import org.meshtastic.core.ui.util.formatPositionTime
|
import org.meshtastic.core.ui.util.formatPositionTime
|
||||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||||
import org.meshtastic.feature.map.LastHeardFilter
|
import org.meshtastic.feature.map.LastHeardFilter
|
||||||
import org.meshtastic.feature.map.component.MapButton
|
|
||||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
|
||||||
import org.meshtastic.feature.map.tracerouteNodeSelection
|
import org.meshtastic.feature.map.tracerouteNodeSelection
|
||||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||||
import org.meshtastic.proto.Position
|
import org.meshtastic.proto.Position
|
||||||
|
|
@ -156,12 +155,7 @@ sealed interface GoogleMapMode {
|
||||||
data object Main : GoogleMapMode
|
data object Main : GoogleMapMode
|
||||||
|
|
||||||
/** Focused node position track: polyline + gradient markers for historical positions. */
|
/** Focused node position track: polyline + gradient markers for historical positions. */
|
||||||
data class NodeTrack(
|
data class NodeTrack(val focusedNode: Node?, val positions: List<Position>) : GoogleMapMode
|
||||||
val focusedNode: Node?,
|
|
||||||
val positions: List<Position>,
|
|
||||||
val selectedPositionTime: Int? = null,
|
|
||||||
val onPositionSelected: ((Int) -> Unit)? = null,
|
|
||||||
) : GoogleMapMode
|
|
||||||
|
|
||||||
/** Traceroute visualization: offset forward/return polylines + hop markers. */
|
/** Traceroute visualization: offset forward/return polylines + hop markers. */
|
||||||
data class Traceroute(
|
data class Traceroute(
|
||||||
|
|
@ -430,17 +424,6 @@ fun MapView(
|
||||||
Logger.d { "Error centering track map: ${e.message}" }
|
Logger.d { "Error centering track map: ${e.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate to selected position marker when card is tapped in the list
|
|
||||||
LaunchedEffect(mode.selectedPositionTime) {
|
|
||||||
val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect
|
|
||||||
val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect
|
|
||||||
try {
|
|
||||||
cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
Logger.d { "Error animating to selected position: ${e.message}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode is GoogleMapMode.Traceroute) {
|
if (mode is GoogleMapMode.Traceroute) {
|
||||||
|
|
@ -594,8 +577,6 @@ fun MapView(
|
||||||
sortedPositions = sortedTrackPositions,
|
sortedPositions = sortedTrackPositions,
|
||||||
displayUnits = displayUnits,
|
displayUnits = displayUnits,
|
||||||
myNodeNum = myNodeNum,
|
myNodeNum = myNodeNum,
|
||||||
selectedPositionTime = mode.selectedPositionTime,
|
|
||||||
onPositionSelected = mode.onPositionSelected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -827,24 +808,17 @@ private fun MainMapContent(
|
||||||
* Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
|
* Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
|
||||||
* transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
|
* transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
|
||||||
* [TripOrigin] dot with an info-window on tap.
|
* [TripOrigin] dot with an info-window on tap.
|
||||||
*
|
|
||||||
* When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
|
|
||||||
* elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(MapsComposeExperimentalApi::class)
|
@OptIn(MapsComposeExperimentalApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@Suppress("LongMethod")
|
|
||||||
private fun NodeTrackOverlay(
|
private fun NodeTrackOverlay(
|
||||||
focusedNode: Node,
|
focusedNode: Node,
|
||||||
sortedPositions: List<Position>,
|
sortedPositions: List<Position>,
|
||||||
displayUnits: DisplayUnits,
|
displayUnits: DisplayUnits,
|
||||||
myNodeNum: Int?,
|
myNodeNum: Int?,
|
||||||
selectedPositionTime: Int? = null,
|
|
||||||
onPositionSelected: ((Int) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
|
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
|
||||||
val activeNodeZIndex = if (isHighPriority) 5f else 4f
|
val activeNodeZIndex = if (isHighPriority) 5f else 4f
|
||||||
val selectedColor = MaterialTheme.colorScheme.primary
|
|
||||||
|
|
||||||
sortedPositions.forEachIndexed { index, position ->
|
sortedPositions.forEachIndexed { index, position ->
|
||||||
key(position.time) {
|
key(position.time) {
|
||||||
|
|
@ -855,23 +829,13 @@ private fun NodeTrackOverlay(
|
||||||
} else {
|
} else {
|
||||||
1f
|
1f
|
||||||
}
|
}
|
||||||
val isSelected = position.time == selectedPositionTime
|
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
|
||||||
val color =
|
|
||||||
if (isSelected) {
|
|
||||||
selectedColor
|
|
||||||
} else {
|
|
||||||
Color(focusedNode.colors.second).copy(alpha = alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index == sortedPositions.lastIndex) {
|
if (index == sortedPositions.lastIndex) {
|
||||||
MarkerComposable(
|
MarkerComposable(
|
||||||
state = markerState,
|
state = markerState,
|
||||||
zIndex = activeNodeZIndex,
|
zIndex = activeNodeZIndex,
|
||||||
alpha = if (isHighPriority) 1.0f else 0.9f,
|
alpha = if (isHighPriority) 1.0f else 0.9f,
|
||||||
onClick = {
|
|
||||||
onPositionSelected?.invoke(position.time)
|
|
||||||
false // Allow default info window behavior
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
NodeChip(node = focusedNode)
|
NodeChip(node = focusedNode)
|
||||||
}
|
}
|
||||||
|
|
@ -880,18 +844,13 @@ private fun NodeTrackOverlay(
|
||||||
state = markerState,
|
state = markerState,
|
||||||
title = stringResource(Res.string.position),
|
title = stringResource(Res.string.position),
|
||||||
snippet = formatAgo(position.time),
|
snippet = formatAgo(position.time),
|
||||||
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
|
zIndex = 1f + alpha,
|
||||||
onClick = {
|
|
||||||
onPositionSelected?.invoke(position.time)
|
|
||||||
false // Allow default info window behavior
|
|
||||||
},
|
|
||||||
infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
|
infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = MeshtasticIcons.TripOrigin,
|
imageVector = MeshtasticIcons.TripOrigin,
|
||||||
contentDescription = stringResource(Res.string.track_point),
|
contentDescription = stringResource(Res.string.track_point),
|
||||||
tint = color,
|
tint = color,
|
||||||
modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,7 @@ import com.google.android.gms.maps.model.TileProvider
|
||||||
import com.google.android.gms.maps.model.UrlTileProvider
|
import com.google.android.gms.maps.model.UrlTileProvider
|
||||||
import com.google.maps.android.compose.CameraPositionState
|
import com.google.maps.android.compose.CameraPositionState
|
||||||
import com.google.maps.android.compose.MapType
|
import com.google.maps.android.compose.MapType
|
||||||
import io.ktor.client.HttpClient
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.isSuccess
|
|
||||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
@ -49,7 +45,6 @@ import org.koin.core.annotation.KoinViewModel
|
||||||
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
||||||
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
|
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
|
||||||
import org.meshtastic.app.map.repository.CustomTileProviderRepository
|
import org.meshtastic.app.map.repository.CustomTileProviderRepository
|
||||||
import org.meshtastic.core.di.CoroutineDispatchers
|
|
||||||
import org.meshtastic.core.model.RadioController
|
import org.meshtastic.core.model.RadioController
|
||||||
import org.meshtastic.core.repository.MapPrefs
|
import org.meshtastic.core.repository.MapPrefs
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
|
|
@ -82,8 +77,6 @@ data class MapCameraPosition(
|
||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
class MapViewModel(
|
class MapViewModel(
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
|
||||||
private val httpClient: HttpClient,
|
|
||||||
mapPrefs: MapPrefs,
|
mapPrefs: MapPrefs,
|
||||||
private val googleMapsPrefs: GoogleMapsPrefs,
|
private val googleMapsPrefs: GoogleMapsPrefs,
|
||||||
nodeRepository: NodeRepository,
|
nodeRepository: NodeRepository,
|
||||||
|
|
@ -411,7 +404,7 @@ class MapViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadPersistedLayers() {
|
private fun loadPersistedLayers() {
|
||||||
viewModelScope.launch(dispatchers.io) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val layersDir = File(application.filesDir, "map_layers")
|
val layersDir = File(application.filesDir, "map_layers")
|
||||||
if (layersDir.exists() && layersDir.isDirectory) {
|
if (layersDir.exists() && layersDir.isDirectory) {
|
||||||
|
|
@ -419,33 +412,32 @@ class MapViewModel(
|
||||||
|
|
||||||
if (persistedLayerFiles != null) {
|
if (persistedLayerFiles != null) {
|
||||||
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
||||||
val loadedItems =
|
val loadedItems = persistedLayerFiles.mapNotNull { file ->
|
||||||
persistedLayerFiles.mapNotNull { file ->
|
if (file.isFile) {
|
||||||
if (file.isFile) {
|
val layerType =
|
||||||
val layerType =
|
when (file.extension.lowercase()) {
|
||||||
when (file.extension.lowercase()) {
|
"kml",
|
||||||
"kml",
|
"kmz",
|
||||||
"kmz",
|
-> LayerType.KML
|
||||||
-> LayerType.KML
|
"geojson",
|
||||||
"geojson",
|
"json",
|
||||||
"json",
|
-> LayerType.GEOJSON
|
||||||
-> LayerType.GEOJSON
|
else -> null
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
layerType?.let {
|
|
||||||
val uri = Uri.fromFile(file)
|
|
||||||
MapLayerItem(
|
|
||||||
name = file.nameWithoutExtension,
|
|
||||||
uri = uri,
|
|
||||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
|
||||||
layerType = it,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
null
|
layerType?.let {
|
||||||
|
val uri = Uri.fromFile(file)
|
||||||
|
MapLayerItem(
|
||||||
|
name = file.nameWithoutExtension,
|
||||||
|
uri = uri,
|
||||||
|
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||||
|
layerType = it,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val networkItems =
|
val networkItems =
|
||||||
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
||||||
|
|
@ -558,7 +550,7 @@ class MapViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
|
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val inputStream = application.contentResolver.openInputStream(uri)
|
val inputStream = application.contentResolver.openInputStream(uri)
|
||||||
val directory = File(application.filesDir, "map_layers")
|
val directory = File(application.filesDir, "map_layers")
|
||||||
|
|
@ -629,7 +621,7 @@ class MapViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deleteFileToInternalStorage(uri: Uri) {
|
private suspend fun deleteFileToInternalStorage(uri: Uri) {
|
||||||
withContext(dispatchers.io) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val file = uri.toFile()
|
val file = uri.toFile()
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
|
|
@ -644,15 +636,11 @@ class MapViewModel(
|
||||||
@Suppress("Recycle")
|
@Suppress("Recycle")
|
||||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
||||||
val uriToLoad = layerItem.uri ?: return null
|
val uriToLoad = layerItem.uri ?: return null
|
||||||
return withContext(dispatchers.io) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
|
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
|
||||||
val response = httpClient.get(uriToLoad.toString())
|
val url = java.net.URL(uriToLoad.toString())
|
||||||
if (!response.status.isSuccess()) {
|
java.io.BufferedInputStream(url.openStream())
|
||||||
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
response.bodyAsChannel().toInputStream()
|
|
||||||
} else {
|
} else {
|
||||||
application.contentResolver.openInputStream(uriToLoad)
|
application.contentResolver.openInputStream(uriToLoad)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconToggleButton
|
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
|
@ -126,10 +125,7 @@ fun CustomMapLayersSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconToggleButton(
|
IconButton(onClick = { onToggleVisibility(layer.id) }) {
|
||||||
checked = layer.isVisible,
|
|
||||||
onCheckedChange = { onToggleVisibility(layer.id) },
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
if (layer.isVisible) {
|
if (layer.isVisible) {
|
||||||
|
|
|
||||||
|
|
@ -31,28 +31,11 @@ import org.meshtastic.proto.Position
|
||||||
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
|
* [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
|
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
|
||||||
* filter).
|
* filter).
|
||||||
*
|
|
||||||
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NodeTrackMap(
|
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
|
||||||
destNum: Int,
|
|
||||||
positions: List<Position>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
selectedPositionTime: Int? = null,
|
|
||||||
onPositionSelected: ((Int) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val vm = koinViewModel<NodeMapViewModel>()
|
val vm = koinViewModel<NodeMapViewModel>()
|
||||||
vm.setDestNum(destNum)
|
vm.setDestNum(destNum)
|
||||||
val focusedNode by vm.node.collectAsStateWithLifecycle()
|
val focusedNode by vm.node.collectAsStateWithLifecycle()
|
||||||
MapView(
|
MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
|
||||||
modifier = modifier,
|
|
||||||
mode =
|
|
||||||
GoogleMapMode.NodeTrack(
|
|
||||||
focusedNode = focusedNode,
|
|
||||||
positions = positions,
|
|
||||||
selectedPositionTime = selectedPositionTime,
|
|
||||||
onPositionSelected = onPositionSelected,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import org.koin.core.annotation.ComponentScan
|
import org.koin.core.annotation.ComponentScan
|
||||||
import org.koin.core.annotation.Module
|
import org.koin.core.annotation.Module
|
||||||
import org.koin.core.annotation.Named
|
import org.koin.core.annotation.Named
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.di.CoroutineDispatchers
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ComponentScan("org.meshtastic.app.map")
|
@ComponentScan("org.meshtastic.app.map")
|
||||||
|
|
@ -36,10 +36,9 @@ class GoogleMapsKoinModule {
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
@Named("GoogleMapsDataStore")
|
@Named("GoogleMapsDataStore")
|
||||||
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
|
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
PreferenceDataStoreFactory.create(
|
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||||
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
|
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
||||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/widget_local_stats_info" />
|
android:resource="@xml/local_stats_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- allow for plugin discovery -->
|
<!-- allow for plugin discovery -->
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -45,12 +45,11 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.compose.setSingletonImageLoaderFactory
|
import coil3.compose.setSingletonImageLoaderFactory
|
||||||
import com.eygraber.uri.toKmpUri
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.meshtastic.app.intro.AnalyticsIntro
|
import org.meshtastic.app.intro.AnalyticsIntro
|
||||||
import org.meshtastic.app.map.getMapViewProvider
|
import org.meshtastic.app.map.getMapViewProvider
|
||||||
|
|
@ -58,8 +57,8 @@ import org.meshtastic.app.node.component.InlineMap
|
||||||
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
|
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
|
||||||
import org.meshtastic.app.ui.MainScreen
|
import org.meshtastic.app.ui.MainScreen
|
||||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||||
|
import org.meshtastic.core.common.util.toMeshtasticUri
|
||||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||||
import org.meshtastic.core.network.repository.UsbRepository
|
|
||||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.channel_invalid
|
import org.meshtastic.core.resources.channel_invalid
|
||||||
|
|
@ -92,8 +91,6 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val model: UIViewModel by viewModel()
|
private val model: UIViewModel by viewModel()
|
||||||
|
|
||||||
private val usbRepository: UsbRepository by inject()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
|
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
|
||||||
* itself as a LifecycleObserver in its init block.
|
* itself as a LifecycleObserver in its init block.
|
||||||
|
|
@ -127,8 +124,6 @@ class MainActivity : ComponentActivity() {
|
||||||
setSingletonImageLoaderFactory { get<ImageLoader>() }
|
setSingletonImageLoaderFactory { get<ImageLoader>() }
|
||||||
|
|
||||||
val theme by model.theme.collectAsStateWithLifecycle()
|
val theme by model.theme.collectAsStateWithLifecycle()
|
||||||
val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
|
|
||||||
val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
|
|
||||||
val dynamic = theme == MODE_DYNAMIC
|
val dynamic = theme == MODE_DYNAMIC
|
||||||
val dark =
|
val dark =
|
||||||
when (theme) {
|
when (theme) {
|
||||||
|
|
@ -146,7 +141,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
AppCompositionLocals {
|
AppCompositionLocals {
|
||||||
AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
|
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
||||||
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Signal to the system that the initial UI is "fully drawn"
|
// Signal to the system that the initial UI is "fully drawn"
|
||||||
|
|
@ -169,16 +164,6 @@ class MainActivity : ComponentActivity() {
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
|
|
||||||
// resumed while a USB device is already attached (e.g. process restart, returning
|
|
||||||
// from another app), the manifest-declared attach intent may have already fired
|
|
||||||
// before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
|
|
||||||
// reality without requiring the user to physically replug.
|
|
||||||
usbRepository.refreshState()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppCompositionLocals(content: @Composable () -> Unit) {
|
private fun AppCompositionLocals(content: @Composable () -> Unit) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
|
@ -190,14 +175,8 @@ class MainActivity : ComponentActivity() {
|
||||||
LocalMapViewProvider provides getMapViewProvider(),
|
LocalMapViewProvider provides getMapViewProvider(),
|
||||||
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
|
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
|
||||||
LocalNodeTrackMapProvider provides
|
LocalNodeTrackMapProvider provides
|
||||||
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
|
{ destNum, positions, modifier ->
|
||||||
org.meshtastic.app.map.node.NodeTrackMap(
|
org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
|
||||||
destNum,
|
|
||||||
positions,
|
|
||||||
modifier,
|
|
||||||
selectedPositionTime,
|
|
||||||
onPositionSelected,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
|
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
|
||||||
LocalTracerouteMapProvider provides
|
LocalTracerouteMapProvider provides
|
||||||
|
|
@ -270,11 +249,6 @@ class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||||
Logger.d { "USB device attached" }
|
Logger.d { "USB device attached" }
|
||||||
// Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
|
|
||||||
// receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
|
|
||||||
// never sees this event. Forward it explicitly so the serialDevices StateFlow
|
|
||||||
// refreshes and the device shows up in the Connect → Serial tab.
|
|
||||||
usbRepository.refreshState()
|
|
||||||
showSettingsPage()
|
showSettingsPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,7 +270,7 @@ class MainActivity : ComponentActivity() {
|
||||||
private fun handleMeshtasticUri(uri: Uri) {
|
private fun handleMeshtasticUri(uri: Uri) {
|
||||||
Logger.d { "Handling Meshtastic URI: $uri" }
|
Logger.d { "Handling Meshtastic URI: $uri" }
|
||||||
|
|
||||||
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createShareIntent(message: String): PendingIntent {
|
private fun createShareIntent(message: String): PendingIntent {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import androidx.work.WorkManager
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -37,8 +36,9 @@ import kotlinx.coroutines.withTimeout
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.workmanager.koin.workManagerFactory
|
import org.koin.androidx.workmanager.koin.workManagerFactory
|
||||||
import org.koin.plugin.module.dsl.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.meshtastic.app.di.AndroidKoinApp
|
import org.meshtastic.app.di.AppKoinModule
|
||||||
|
import org.meshtastic.app.di.module
|
||||||
import org.meshtastic.core.common.ContextServices
|
import org.meshtastic.core.common.ContextServices
|
||||||
import org.meshtastic.core.database.DatabaseManager
|
import org.meshtastic.core.database.DatabaseManager
|
||||||
import org.meshtastic.core.repository.MeshPrefs
|
import org.meshtastic.core.repository.MeshPrefs
|
||||||
|
|
@ -57,15 +57,16 @@ open class MeshUtilApplication :
|
||||||
Application(),
|
Application(),
|
||||||
Configuration.Provider {
|
Configuration.Provider {
|
||||||
|
|
||||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val applicationScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ContextServices.app = this
|
ContextServices.app = this
|
||||||
|
|
||||||
startKoin<AndroidKoinApp> {
|
startKoin {
|
||||||
androidContext(this@MeshUtilApplication)
|
androidContext(this@MeshUtilApplication)
|
||||||
workManagerFactory()
|
workManagerFactory()
|
||||||
|
modules(AppKoinModule().module())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule periodic MeshLog cleanup
|
// Schedule periodic MeshLog cleanup
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ import coil3.ImageLoader
|
||||||
import coil3.annotation.ExperimentalCoilApi
|
import coil3.annotation.ExperimentalCoilApi
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
import coil3.memoryCacheMaxSizePercentWhileInBackground
|
|
||||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
|
||||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.svg.SvgDecoder
|
import coil3.svg.SvgDecoder
|
||||||
|
|
@ -33,25 +31,18 @@ import coil3.util.DebugLogger
|
||||||
import coil3.util.Logger
|
import coil3.util.Logger
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.android.Android
|
import io.ktor.client.engine.android.Android
|
||||||
import io.ktor.client.plugins.DefaultRequest
|
|
||||||
import io.ktor.client.plugins.HttpRequestRetry
|
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.plugins.logging.LogLevel
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
import io.ktor.client.request.url
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okio.Path.Companion.toOkioPath
|
import okio.Path.Companion.toOkioPath
|
||||||
import org.koin.core.annotation.Module
|
import org.koin.core.annotation.Module
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.BuildConfigProvider
|
import org.meshtastic.core.common.BuildConfigProvider
|
||||||
import org.meshtastic.core.network.HttpClientDefaults
|
|
||||||
import org.meshtastic.core.network.KermitHttpLogger
|
|
||||||
|
|
||||||
private const val DISK_CACHE_PERCENT = 0.02
|
private const val DISK_CACHE_PERCENT = 0.02
|
||||||
private const val MEMORY_CACHE_PERCENT = 0.25
|
private const val MEMORY_CACHE_PERCENT = 0.25
|
||||||
private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
class NetworkModule {
|
class NetworkModule {
|
||||||
|
|
@ -72,12 +63,7 @@ class NetworkModule {
|
||||||
buildConfigProvider: BuildConfigProvider,
|
buildConfigProvider: BuildConfigProvider,
|
||||||
): ImageLoader = ImageLoader.Builder(context = application)
|
): ImageLoader = ImageLoader.Builder(context = application)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(KtorNetworkFetcherFactory(httpClient = httpClient))
|
||||||
KtorNetworkFetcherFactory(
|
|
||||||
httpClient = httpClient,
|
|
||||||
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
add(SvgDecoder.Factory(scaleToDensity = true))
|
add(SvgDecoder.Factory(scaleToDensity = true))
|
||||||
}
|
}
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
|
|
@ -90,7 +76,6 @@ class NetworkModule {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
|
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
|
||||||
.memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
|
|
||||||
.crossfade(enable = true)
|
.crossfade(enable = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|
@ -98,21 +83,8 @@ class NetworkModule {
|
||||||
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
|
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
|
||||||
HttpClient(engineFactory = Android) {
|
HttpClient(engineFactory = Android) {
|
||||||
install(plugin = ContentNegotiation) { json(json) }
|
install(plugin = ContentNegotiation) { json(json) }
|
||||||
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
|
|
||||||
install(plugin = HttpTimeout) {
|
|
||||||
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
|
||||||
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
|
||||||
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
|
||||||
}
|
|
||||||
install(plugin = HttpRequestRetry) {
|
|
||||||
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
|
|
||||||
exponentialDelay()
|
|
||||||
}
|
|
||||||
if (buildConfigProvider.isDebug) {
|
if (buildConfigProvider.isDebug) {
|
||||||
install(plugin = Logging) {
|
install(plugin = Logging) { level = LogLevel.BODY }
|
||||||
logger = KermitHttpLogger
|
|
||||||
level = LogLevel.BODY
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.map.component
|
package org.meshtastic.app.map.component
|
||||||
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -14,15 +14,13 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.map.component
|
package org.meshtastic.app.map.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.FloatingToolbarDefaults
|
|
||||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -43,9 +41,8 @@ import org.meshtastic.core.ui.icon.Tune
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass,
|
* Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
|
||||||
* filter button, location tracking button, and optional slots for flavor-specific content (map type selector, layers,
|
* tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
|
||||||
* refresh).
|
|
||||||
*
|
*
|
||||||
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
|
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
|
||||||
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
|
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
|
||||||
|
|
@ -57,7 +54,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||||
* @param isRefreshing Whether a refresh is currently in progress.
|
* @param isRefreshing Whether a refresh is currently in progress.
|
||||||
* @param onRefresh Callback when the refresh button is clicked.
|
* @param onRefresh Callback when the refresh button is clicked.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
@Composable
|
@Composable
|
||||||
fun MapControlsOverlay(
|
fun MapControlsOverlay(
|
||||||
|
|
@ -75,11 +71,7 @@ fun MapControlsOverlay(
|
||||||
isRefreshing: Boolean = false,
|
isRefreshing: Boolean = false,
|
||||||
onRefresh: () -> Unit = {},
|
onRefresh: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
HorizontalFloatingToolbar(
|
Row(modifier = modifier) {
|
||||||
expanded = true,
|
|
||||||
modifier = modifier,
|
|
||||||
colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
|
|
||||||
) {
|
|
||||||
// Compass
|
// Compass
|
||||||
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
|
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
|
||||||
|
|
||||||
|
|
@ -25,7 +25,6 @@ import androidx.work.WorkerParameters
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.HttpClientEngine
|
import io.ktor.client.engine.HttpClientEngine
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import org.koin.plugin.module.dsl.koinApplication
|
|
||||||
import org.koin.test.verify.definition
|
import org.koin.test.verify.definition
|
||||||
import org.koin.test.verify.injectedParameters
|
import org.koin.test.verify.injectedParameters
|
||||||
import org.koin.test.verify.verify
|
import org.koin.test.verify.verify
|
||||||
|
|
@ -61,19 +60,4 @@ class KoinVerificationTest {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun verifyTypedBootstrapLoadsModuleGraph() {
|
|
||||||
// koinApplication<T>() is a K2 compiler plugin stub. If the plugin fails to
|
|
||||||
// transform it, the stub throws NotImplementedError at runtime. This test
|
|
||||||
// validates that the production bootstrap path is correctly transformed by
|
|
||||||
// successfully creating and closing the generated Koin application.
|
|
||||||
val app = koinApplication<AndroidKoinApp>()
|
|
||||||
try {
|
|
||||||
// No-op: reaching this point proves the typed bootstrap path did not
|
|
||||||
// throw and the generated application could be created.
|
|
||||||
} finally {
|
|
||||||
app.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.app.service
|
package org.meshtastic.app.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import dev.mokkery.MockMode
|
import dev.mokkery.MockMode
|
||||||
import dev.mokkery.mock
|
import dev.mokkery.mock
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
|
|
@ -36,7 +37,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
||||||
override fun updateServiceStateNotification(
|
override fun updateServiceStateNotification(
|
||||||
state: org.meshtastic.core.model.ConnectionState,
|
state: org.meshtastic.core.model.ConnectionState,
|
||||||
telemetry: Telemetry?,
|
telemetry: Telemetry?,
|
||||||
) {}
|
): Notification = mock(MockMode.autofill)
|
||||||
|
|
||||||
override suspend fun updateMessageNotification(
|
override suspend fun updateMessageNotification(
|
||||||
contactKey: String,
|
contactKey: String,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.app.ui
|
package org.meshtastic.app.ui
|
||||||
|
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.runComposeUiTest
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.meshtastic.core.navigation.NodesRoute
|
import org.meshtastic.core.navigation.NodesRoute
|
||||||
|
|
@ -35,14 +35,15 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34])
|
@Config(sdk = [34])
|
||||||
class NavigationAssemblyTest {
|
class NavigationAssemblyTest {
|
||||||
|
|
||||||
|
@get:Rule val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest {
|
fun verifyNavigationGraphsAssembleWithoutCrashing() {
|
||||||
setContent {
|
composeTestRule.setContent {
|
||||||
val backStack = rememberNavBackStack(NodesRoute.NodesGraph)
|
val backStack = rememberNavBackStack(NodesRoute.NodesGraph)
|
||||||
entryProvider<NavKey> {
|
entryProvider<NavKey> {
|
||||||
contactsGraph(backStack, emptyFlow())
|
contactsGraph(backStack, emptyFlow())
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ dependencies {
|
||||||
compileOnly(libs.kotlin.gradlePlugin)
|
compileOnly(libs.kotlin.gradlePlugin)
|
||||||
compileOnly(libs.ksp.gradlePlugin)
|
compileOnly(libs.ksp.gradlePlugin)
|
||||||
compileOnly(libs.androidx.room.gradlePlugin)
|
compileOnly(libs.androidx.room.gradlePlugin)
|
||||||
|
compileOnly(libs.secrets.gradlePlugin)
|
||||||
compileOnly(libs.spotless.gradlePlugin)
|
compileOnly(libs.spotless.gradlePlugin)
|
||||||
compileOnly(libs.test.retry.gradlePlugin)
|
compileOnly(libs.test.retry.gradlePlugin)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import com.android.build.api.dsl.ApplicationExtension
|
||||||
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
|
||||||
import com.datadog.gradle.plugin.DdExtension
|
import com.datadog.gradle.plugin.DdExtension
|
||||||
import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask
|
import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask
|
||||||
|
import com.datadog.gradle.plugin.InstrumentationMode
|
||||||
import com.datadog.gradle.plugin.SdkCheckLevel
|
import com.datadog.gradle.plugin.SdkCheckLevel
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
|
|
@ -110,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin<Project> {
|
||||||
variants {
|
variants {
|
||||||
register(variant.name) {
|
register(variant.name) {
|
||||||
site = "US5"
|
site = "US5"
|
||||||
|
composeInstrumentation = InstrumentationMode.AUTO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkProjectDependencies = SdkCheckLevel.NONE
|
checkProjectDependencies = SdkCheckLevel.NONE
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
* Copyright (c) 2025 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.android.build.api.dsl.ApplicationExtension
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
|
|
@ -25,6 +26,7 @@ import org.meshtastic.buildlogic.configureTestOptions
|
||||||
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) {
|
||||||
with(target) {
|
with(target) {
|
||||||
|
|
||||||
apply(plugin = "com.android.application")
|
apply(plugin = "com.android.application")
|
||||||
apply(plugin = "org.gradle.test-retry")
|
apply(plugin = "org.gradle.test-retry")
|
||||||
apply(plugin = "meshtastic.android.lint")
|
apply(plugin = "meshtastic.android.lint")
|
||||||
|
|
@ -37,7 +39,15 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
extensions.configure<ApplicationExtension> {
|
extensions.configure<ApplicationExtension> {
|
||||||
configureKotlinAndroid(this)
|
configureKotlinAndroid(this)
|
||||||
|
|
||||||
defaultConfig { vectorDrawables.useSupportLibrary = true }
|
defaultConfig {
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
animationsDisabled = true
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
|
|
@ -45,8 +55,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
rootProject.file("config/proguard/shared-rules.pro"),
|
"proguard-rules.pro"
|
||||||
"proguard-rules.pro",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
|
@ -58,7 +67,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures { buildConfig = true }
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
configureTestOptions()
|
configureTestOptions()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
extensions.configure<LibraryExtension> {
|
extensions.configure<LibraryExtension> {
|
||||||
configureKotlinAndroid(this)
|
configureKotlinAndroid(this)
|
||||||
|
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
testOptions {
|
||||||
|
animationsDisabled = true
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// When flavorless modules depend on flavored modules (like :core:data),
|
// When flavorless modules depend on flavored modules (like :core:data),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
||||||
extensions.configure<KotlinMultiplatformExtension> {
|
extensions.configure<KotlinMultiplatformExtension> {
|
||||||
sourceSets.getByName("commonMain").dependencies {
|
sourceSets.getByName("commonMain").dependencies {
|
||||||
// Compose Multiplatform UI
|
// Compose Multiplatform UI
|
||||||
implementation(libs.library("compose-multiplatform-animation"))
|
|
||||||
implementation(libs.library("compose-multiplatform-material3"))
|
implementation(libs.library("compose-multiplatform-material3"))
|
||||||
|
|
||||||
// Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain)
|
// Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain)
|
||||||
|
|
@ -54,18 +53,19 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
implementation(libs.library("kermit"))
|
implementation(libs.library("kermit"))
|
||||||
|
|
||||||
// @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview)
|
|
||||||
// org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11
|
|
||||||
implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets.getByName("androidMain").dependencies {
|
sourceSets.getByName("androidMain").dependencies {
|
||||||
|
// Compose BOM for consistent Android Compose versions
|
||||||
|
implementation(target.dependencies.platform(libs.library("androidx-compose-bom")))
|
||||||
|
|
||||||
// Common Android Compose dependencies
|
// Common Android Compose dependencies
|
||||||
implementation(libs.library("accompanist-permissions"))
|
implementation(libs.library("accompanist-permissions"))
|
||||||
implementation(libs.library("androidx-activity-compose"))
|
implementation(libs.library("androidx-activity-compose"))
|
||||||
|
implementation(libs.library("androidx-compose-material3"))
|
||||||
|
|
||||||
implementation(libs.library("compose-multiplatform-ui"))
|
implementation(libs.library("androidx-compose-ui-text"))
|
||||||
|
implementation(libs.library("androidx-compose-ui-tooling-preview"))
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
|
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,10 @@ class KmpLibraryComposeConventionPlugin : Plugin<Project> {
|
||||||
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
|
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
|
||||||
|
|
||||||
extensions.configure<KotlinMultiplatformExtension> {
|
extensions.configure<KotlinMultiplatformExtension> {
|
||||||
sourceSets.matching { it.name == "commonMain" }.configureEach {
|
sourceSets.getByName("commonMain").dependencies {
|
||||||
dependencies {
|
implementation(libs.library("compose-multiplatform-runtime"))
|
||||||
implementation(libs.library("compose-multiplatform-runtime"))
|
// API because consuming modules will usually need the resource types
|
||||||
// API because consuming modules will usually need the resource types
|
api(libs.library("compose-multiplatform-resources"))
|
||||||
api(libs.library("compose-multiplatform-resources"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
configureComposeCompiler()
|
configureComposeCompiler()
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
import dev.mokkery.gradle.MokkeryGradleExtension
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.kotlin.dsl.apply
|
import org.gradle.kotlin.dsl.apply
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback
|
import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback
|
||||||
import org.meshtastic.buildlogic.configureKmpTestDependencies
|
import org.meshtastic.buildlogic.configureKmpTestDependencies
|
||||||
import org.meshtastic.buildlogic.configureKotlinMultiplatform
|
import org.meshtastic.buildlogic.configureKotlinMultiplatform
|
||||||
|
|
@ -37,6 +39,8 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
|
||||||
apply(plugin = "org.gradle.test-retry")
|
apply(plugin = "org.gradle.test-retry")
|
||||||
apply(plugin = libs.plugin("mokkery").get().pluginId)
|
apply(plugin = libs.plugin("mokkery").get().pluginId)
|
||||||
|
|
||||||
|
extensions.configure<MokkeryGradleExtension> { stubs.allowConcreteClassInstantiation.set(true) }
|
||||||
|
|
||||||
configureKotlinMultiplatform()
|
configureKotlinMultiplatform()
|
||||||
configureKmpTestDependencies()
|
configureKmpTestDependencies()
|
||||||
configureTestOptions()
|
configureTestOptions()
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,11 @@ class KoinConventionPlugin : Plugin<Project> {
|
||||||
|
|
||||||
// Configure Koin K2 Compiler Plugin (0.4.0+)
|
// Configure Koin K2 Compiler Plugin (0.4.0+)
|
||||||
extensions.configure(KoinGradleExtension::class.java) {
|
extensions.configure(KoinGradleExtension::class.java) {
|
||||||
// Meshtastic uses dependency inversion across KMP modules — interfaces in
|
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
|
||||||
// commonMain, implementations wired at the composition root. Koin's compileSafety
|
// per-module safety checks strictly enforce that all dependencies must be explicitly
|
||||||
// flag enables A1 per-module checks that treat every module as self-contained,
|
// provided or included locally. This breaks decoupled Clean Architecture designs.
|
||||||
// which breaks this pattern. There is no separate flag for A3 full-graph
|
// We disable compile safety globally to properly rely on Koin's A3 full-graph
|
||||||
// validation. Until Koin exposes granular safety levels we keep this disabled;
|
// validation which perfectly handles inverted dependencies at the composition root.
|
||||||
// runtime graph verification is handled by KoinVerificationTest instead.
|
|
||||||
compileSafety.set(false)
|
compileSafety.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,46 +24,18 @@ import org.gradle.kotlin.dsl.dependencies
|
||||||
internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) {
|
internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) {
|
||||||
commonExtension.apply { buildFeatures.compose = true }
|
commonExtension.apply { buildFeatures.compose = true }
|
||||||
|
|
||||||
// CMP is the sole Compose version authority (BOM removed from the catalog).
|
|
||||||
// Third-party libraries (maps-compose, datadog, etc.) carry a transitive
|
|
||||||
// compose-bom whose constraints conflict with CMP-published AndroidX artifacts.
|
|
||||||
// Exclude it globally so CMP's own dependency graph wins.
|
|
||||||
configurations.configureEach {
|
|
||||||
exclude(mapOf("group" to "androidx.compose", "module" to "compose-bom"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CMP publishes these core AndroidX groups at the CMP version tag.
|
|
||||||
// Material, Material3, and Adaptive follow separate AndroidX version numbers
|
|
||||||
// and must NOT be included here (see CMP release notes for the mapping table).
|
|
||||||
val cmpVersion = libs.version("compose-multiplatform")
|
|
||||||
val cmpAlignedGroups = setOf(
|
|
||||||
"androidx.compose.animation",
|
|
||||||
"androidx.compose.foundation",
|
|
||||||
"androidx.compose.runtime",
|
|
||||||
"androidx.compose.ui",
|
|
||||||
)
|
|
||||||
|
|
||||||
// The BOM exclusion above strips versions from transitive material deps
|
|
||||||
// (e.g. maps-compose-widgets, datadog). Pin the material group to the
|
|
||||||
// AndroidX version that matches this CMP release.
|
|
||||||
val materialVersion = libs.version("androidx-compose-material")
|
|
||||||
|
|
||||||
configurations.configureEach {
|
|
||||||
resolutionStrategy.eachDependency {
|
|
||||||
if (requested.group in cmpAlignedGroups) {
|
|
||||||
useVersion(cmpVersion)
|
|
||||||
} else if (requested.group == "androidx.compose.material") {
|
|
||||||
useVersion(materialVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists()
|
val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists()
|
||||||
dependencies {
|
dependencies {
|
||||||
"debugImplementation"(libs.library("compose-multiplatform-ui-tooling"))
|
val bom = libs.library("androidx-compose-bom")
|
||||||
"implementation"(libs.library("compose-multiplatform-runtime"))
|
"implementation"(platform(bom))
|
||||||
|
if (hasAndroidTest) {
|
||||||
|
"androidTestImplementation"(platform(bom))
|
||||||
|
}
|
||||||
|
"debugImplementation"(libs.library("androidx-compose-ui-tooling"))
|
||||||
|
"implementation"(libs.library("androidx-compose-runtime"))
|
||||||
"runtimeOnly"(libs.library("androidx-compose-runtime-tracing"))
|
"runtimeOnly"(libs.library("androidx-compose-runtime-tracing"))
|
||||||
|
|
||||||
|
"implementation"(libs.library("compose-multiplatform-runtime"))
|
||||||
"implementation"(libs.library("compose-multiplatform-resources"))
|
"implementation"(libs.library("compose-multiplatform-resources"))
|
||||||
|
|
||||||
// Add Espresso explicitly to avoid version mismatch issues on newer Android versions
|
// Add Espresso explicitly to avoid version mismatch issues on newer Android versions
|
||||||
|
|
|
||||||
|
|
@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
|
||||||
compileSdk = compileSdkVersion
|
compileSdk = compileSdkVersion
|
||||||
|
|
||||||
defaultConfig.minSdk = minSdkVersion
|
defaultConfig.minSdk = minSdkVersion
|
||||||
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
|
|
||||||
if (this is ApplicationExtension) {
|
if (this is ApplicationExtension) {
|
||||||
defaultConfig.targetSdk = targetSdkVersion
|
defaultConfig.targetSdk = targetSdkVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
|
val javaVersion = if (project.name in listOf("api", "model", "proto")) {
|
||||||
|
JavaVersion.VERSION_17
|
||||||
|
} else {
|
||||||
|
JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
compileOptions.sourceCompatibility = javaVersion
|
compileOptions.sourceCompatibility = javaVersion
|
||||||
compileOptions.targetCompatibility = javaVersion
|
compileOptions.targetCompatibility = javaVersion
|
||||||
|
|
||||||
testOptions.animationsDisabled = true
|
|
||||||
testOptions.unitTests.isReturnDefaultValues = true
|
|
||||||
|
|
||||||
// Exclude duplicate META-INF license files shipped by JUnit Platform JARs
|
// Exclude duplicate META-INF license files shipped by JUnit Platform JARs
|
||||||
packaging.resources.excludes.addAll(
|
packaging.resources.excludes.addAll(
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -72,23 +72,6 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
|
||||||
|
|
||||||
/** Configure Kotlin Multiplatform options */
|
/** Configure Kotlin Multiplatform options */
|
||||||
internal fun Project.configureKotlinMultiplatform() {
|
internal fun Project.configureKotlinMultiplatform() {
|
||||||
// Skiko is an internal CMP implementation detail; third-party KMP libraries
|
|
||||||
// (e.g. coil3) can carry an older skiko transitive requirement that Gradle
|
|
||||||
// upgrades to the CMP-bundled version, triggering a "Skiko dependencies'
|
|
||||||
// versions are incompatible" warning from CMP's compatibility checker.
|
|
||||||
// Force the version to match CMP so the checker sees a consistent graph.
|
|
||||||
// Pinned here rather than in the version catalog because this plugin is the
|
|
||||||
// only consumer — bump together with the compose-multiplatform version.
|
|
||||||
val skikoVersion = "0.144.5"
|
|
||||||
configurations.configureEach {
|
|
||||||
resolutionStrategy.eachDependency {
|
|
||||||
if (requested.group == "org.jetbrains.skiko") {
|
|
||||||
useVersion(skikoVersion)
|
|
||||||
because("Align Skiko with the version bundled by Compose Multiplatform")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions.configure<KotlinMultiplatformExtension> {
|
extensions.configure<KotlinMultiplatformExtension> {
|
||||||
// Standard KMP targets for Meshtastic
|
// Standard KMP targets for Meshtastic
|
||||||
jvm()
|
jvm()
|
||||||
|
|
@ -207,25 +190,11 @@ internal fun Project.configureKotlinJvm() {
|
||||||
configureKotlin<KotlinJvmProjectExtension>()
|
configureKotlin<KotlinJvmProjectExtension>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modules published for external consumers — use Java 17 for broader compatibility. */
|
|
||||||
private val PUBLISHED_MODULES = setOf("api", "model", "proto")
|
|
||||||
|
|
||||||
/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */
|
|
||||||
private val SHARED_COMPILER_ARGS = listOf(
|
|
||||||
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
|
||||||
"-opt-in=kotlin.time.ExperimentalTime",
|
|
||||||
"-Xexpect-actual-classes",
|
|
||||||
"-Xcontext-parameters",
|
|
||||||
"-Xannotation-default-target=param-property",
|
|
||||||
"-Xskip-prerelease-check",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Configure base Kotlin options */
|
/** Configure base Kotlin options */
|
||||||
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
||||||
val isPublishedModule = project.name in PUBLISHED_MODULES
|
|
||||||
|
|
||||||
extensions.configure<T> {
|
extensions.configure<T> {
|
||||||
val javaVersion = if (isPublishedModule) 17 else 21
|
val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
|
||||||
|
val isPublishedModule = project.name in listOf("api", "model", "proto")
|
||||||
// Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments),
|
// Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments),
|
||||||
// and Java 21 for the rest of the app.
|
// and Java 21 for the rest of the app.
|
||||||
jvmToolchain(javaVersion)
|
jvmToolchain(javaVersion)
|
||||||
|
|
@ -239,7 +208,14 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
||||||
if (!isPublishedModule) {
|
if (!isPublishedModule) {
|
||||||
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
|
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
|
||||||
}
|
}
|
||||||
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
|
freeCompilerArgs.addAll(
|
||||||
|
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
||||||
|
"-opt-in=kotlin.time.ExperimentalTime",
|
||||||
|
"-Xexpect-actual-classes",
|
||||||
|
"-Xcontext-parameters",
|
||||||
|
"-Xannotation-default-target=param-property",
|
||||||
|
"-Xskip-prerelease-check",
|
||||||
|
)
|
||||||
if (isJvmTarget) {
|
if (isJvmTarget) {
|
||||||
freeCompilerArgs.add("-jvm-default=no-compatibility")
|
freeCompilerArgs.add("-jvm-default=no-compatibility")
|
||||||
}
|
}
|
||||||
|
|
@ -254,13 +230,21 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
||||||
|
|
||||||
tasks.withType<KotlinCompile>().configureEach {
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
|
val isPublishedModule = project.name in listOf("api", "model", "proto")
|
||||||
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
|
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
|
||||||
allWarningsAsErrors.set(warningsAsErrors)
|
allWarningsAsErrors.set(warningsAsErrors)
|
||||||
if (!isPublishedModule) {
|
if (!isPublishedModule) {
|
||||||
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
|
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
|
||||||
}
|
}
|
||||||
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
|
freeCompilerArgs.addAll(
|
||||||
freeCompilerArgs.add("-jvm-default=no-compatibility")
|
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
||||||
|
"-opt-in=kotlin.time.ExperimentalTime",
|
||||||
|
"-Xexpect-actual-classes",
|
||||||
|
"-Xcontext-parameters",
|
||||||
|
"-Xannotation-default-target=param-property",
|
||||||
|
"-Xskip-prerelease-check",
|
||||||
|
"-jvm-default=no-compatibility",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ pluginManagement {
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.gradle.develocity") version("4.4.1")
|
id("com.gradle.develocity") version("4.4.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ component_management:
|
||||||
name: Desktop
|
name: Desktop
|
||||||
paths:
|
paths:
|
||||||
- desktop/**
|
- desktop/**
|
||||||
|
- component_id: example
|
||||||
|
name: Example
|
||||||
|
paths:
|
||||||
|
- mesh_service_example/**
|
||||||
|
|
||||||
ignore:
|
ignore:
|
||||||
- "**/build/**"
|
- "**/build/**"
|
||||||
|
|
|
||||||
23
conductor/code_styleguides/general.md
Normal file
23
conductor/code_styleguides/general.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# General Code Style Principles
|
||||||
|
|
||||||
|
This document outlines general coding principles that apply across all languages and frameworks used in this project.
|
||||||
|
|
||||||
|
## Readability
|
||||||
|
- Code should be easy to read and understand by humans.
|
||||||
|
- Avoid overly clever or obscure constructs.
|
||||||
|
|
||||||
|
## Consistency
|
||||||
|
- Follow existing patterns in the codebase.
|
||||||
|
- Maintain consistent formatting, naming, and structure.
|
||||||
|
|
||||||
|
## Simplicity
|
||||||
|
- Prefer simple solutions over complex ones.
|
||||||
|
- Break down complex problems into smaller, manageable parts.
|
||||||
|
|
||||||
|
## Maintainability
|
||||||
|
- Write code that is easy to modify and extend.
|
||||||
|
- Minimize dependencies and coupling.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Document *why* something is done, not just *what*.
|
||||||
|
- Keep documentation up-to-date with code changes.
|
||||||
14
conductor/index.md
Normal file
14
conductor/index.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Project Context
|
||||||
|
|
||||||
|
## Definition
|
||||||
|
- [Product Definition](./product.md)
|
||||||
|
- [Product Guidelines](./product-guidelines.md)
|
||||||
|
- [Tech Stack](./tech-stack.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
- [Workflow](./workflow.md)
|
||||||
|
- [Code Style Guides](./code_styleguides/)
|
||||||
|
|
||||||
|
## Management
|
||||||
|
- [Tracks Registry](./tracks.md)
|
||||||
|
- [Tracks Directory](./tracks/)
|
||||||
19
conductor/product-guidelines.md
Normal file
19
conductor/product-guidelines.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Product Guidelines
|
||||||
|
|
||||||
|
## Brand Voice and Tone
|
||||||
|
- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic.
|
||||||
|
- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety.
|
||||||
|
- **Community-Oriented:** Encourage open-source participation and community support.
|
||||||
|
|
||||||
|
## UX Principles
|
||||||
|
- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network.
|
||||||
|
- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles.
|
||||||
|
- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure.
|
||||||
|
|
||||||
|
## Prose Style
|
||||||
|
- **Clarity over cleverness:** Use plain English.
|
||||||
|
- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export").
|
||||||
|
- **Consistent Terminology:**
|
||||||
|
- Use "Node" for devices on the network.
|
||||||
|
- Use "Channel" for communication groups.
|
||||||
|
- Use "Direct Message" for 1-to-1 communication.
|
||||||
26
conductor/product.md
Normal file
26
conductor/product.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Initial Concept
|
||||||
|
A tool for using Android with open-source mesh radios.
|
||||||
|
|
||||||
|
# Product Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios.
|
||||||
|
|
||||||
|
## Target Audience
|
||||||
|
- Off-grid communication enthusiasts and hobbyists
|
||||||
|
- Outdoor adventurers needing reliable communication without cellular networks
|
||||||
|
- Emergency response and disaster relief teams
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT)
|
||||||
|
- Decentralized text messaging across the mesh network
|
||||||
|
- Unified cross-platform notifications for messages and node events
|
||||||
|
- Adaptive node and contact management
|
||||||
|
- Offline map rendering and device positioning
|
||||||
|
- Device configuration and firmware updates
|
||||||
|
- Unified cross-platform debugging and packet inspection
|
||||||
|
|
||||||
|
## Key Architecture Goals
|
||||||
|
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
|
||||||
|
- Ensure offline-first functionality and resilient data persistence (Room 3 KMP)
|
||||||
|
- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform
|
||||||
38
conductor/tech-stack.md
Normal file
38
conductor/tech-stack.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Tech Stack
|
||||||
|
|
||||||
|
## Programming Language
|
||||||
|
- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`.
|
||||||
|
|
||||||
|
## Frontend Frameworks
|
||||||
|
- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop.
|
||||||
|
- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android.
|
||||||
|
|
||||||
|
## Background & Services
|
||||||
|
- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`.
|
||||||
|
- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module.
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
|
||||||
|
|
||||||
|
## Database & Storage
|
||||||
|
- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android).
|
||||||
|
- **Jetpack DataStore:** Shared preferences.
|
||||||
|
|
||||||
|
## Networking & Transport
|
||||||
|
- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
|
||||||
|
- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
|
||||||
|
- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target.
|
||||||
|
- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library.
|
||||||
|
- **Coroutines & Flows:** For asynchronous programming and state management.
|
||||||
|
|
||||||
|
## Testing (KMP)
|
||||||
|
- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
|
||||||
|
- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
|
||||||
|
- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
|
||||||
|
- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`).
|
||||||
|
- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`).
|
||||||
|
- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
|
||||||
|
- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.
|
||||||
5
conductor/tracks.md
Normal file
5
conductor/tracks.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Project Tracks
|
||||||
|
|
||||||
|
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||||
|
|
||||||
|
---
|
||||||
333
conductor/workflow.md
Normal file
333
conductor/workflow.md
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
# Project Workflow
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
|
||||||
|
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
|
||||||
|
3. **Test-Driven Development:** Write unit tests before implementing functionality
|
||||||
|
4. **High Code Coverage:** Aim for >80% code coverage for all modules
|
||||||
|
5. **User Experience First:** Every decision should prioritize user experience
|
||||||
|
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
|
||||||
|
|
||||||
|
## Task Workflow
|
||||||
|
|
||||||
|
All tasks follow a strict lifecycle:
|
||||||
|
|
||||||
|
### Standard Task Workflow
|
||||||
|
|
||||||
|
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
|
||||||
|
|
||||||
|
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
|
||||||
|
|
||||||
|
3. **Write Failing Tests (Red Phase):**
|
||||||
|
- Create a new test file for the feature or bug fix.
|
||||||
|
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
|
||||||
|
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
|
||||||
|
|
||||||
|
4. **Implement to Pass Tests (Green Phase):**
|
||||||
|
- Write the minimum amount of application code necessary to make the failing tests pass.
|
||||||
|
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
|
||||||
|
|
||||||
|
5. **Refactor (Optional but Recommended):**
|
||||||
|
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
|
||||||
|
- Rerun tests to ensure they still pass after refactoring.
|
||||||
|
|
||||||
|
6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
|
||||||
|
```bash
|
||||||
|
pytest --cov=app --cov-report=html
|
||||||
|
```
|
||||||
|
Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
|
||||||
|
|
||||||
|
7. **Document Deviations:** If implementation differs from tech stack:
|
||||||
|
- **STOP** implementation
|
||||||
|
- Update `tech-stack.md` with new design
|
||||||
|
- Add dated note explaining the change
|
||||||
|
- Resume implementation
|
||||||
|
|
||||||
|
8. **Commit Code Changes:**
|
||||||
|
- Stage all code changes related to the task.
|
||||||
|
- Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
|
||||||
|
- Perform the commit.
|
||||||
|
|
||||||
|
9. **Attach Task Summary with Git Notes:**
|
||||||
|
- **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
|
||||||
|
- **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
|
||||||
|
- **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
|
||||||
|
```bash
|
||||||
|
# The note content from the previous step is passed via the -m flag.
|
||||||
|
git notes add -m "<note content>" <commit_hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Get and Record Task Commit SHA:**
|
||||||
|
- **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
|
||||||
|
- **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
|
||||||
|
|
||||||
|
11. **Commit Plan Update:**
|
||||||
|
- **Action:** Stage the modified `plan.md` file.
|
||||||
|
- **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
|
||||||
|
|
||||||
|
### Phase Completion Verification and Checkpointing Protocol
|
||||||
|
|
||||||
|
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
|
||||||
|
|
||||||
|
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
|
||||||
|
|
||||||
|
2. **Ensure Test Coverage for Phase Changes:**
|
||||||
|
- **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
|
||||||
|
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` to get a precise list of all files modified during this phase.
|
||||||
|
- **Step 2.3: Verify and Create Tests:** For each file in the list:
|
||||||
|
- **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
|
||||||
|
- For each remaining code file, verify a corresponding test file exists.
|
||||||
|
- If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
|
||||||
|
|
||||||
|
3. **Execute Automated Tests with Proactive Debugging:**
|
||||||
|
- Before execution, you **must** announce the exact shell command you will use to run the tests.
|
||||||
|
- **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
|
||||||
|
- Execute the announced command.
|
||||||
|
- If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
|
||||||
|
|
||||||
|
4. **Propose a Detailed, Actionable Manual Verification Plan:**
|
||||||
|
- **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
|
||||||
|
- You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
|
||||||
|
- The plan you present to the user **must** follow this format:
|
||||||
|
|
||||||
|
**For a Frontend Change:**
|
||||||
|
```
|
||||||
|
The automated tests have passed. For manual verification, please follow these steps:
|
||||||
|
|
||||||
|
**Manual Verification Steps:**
|
||||||
|
1. **Start the development server with the command:** `npm run dev`
|
||||||
|
2. **Open your browser to:** `http://localhost:3000`
|
||||||
|
3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For a Backend Change:**
|
||||||
|
```
|
||||||
|
The automated tests have passed. For manual verification, please follow these steps:
|
||||||
|
|
||||||
|
**Manual Verification Steps:**
|
||||||
|
1. **Ensure the server is running.**
|
||||||
|
2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
|
||||||
|
3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Await Explicit User Feedback:**
|
||||||
|
- After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
|
||||||
|
- **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
|
||||||
|
|
||||||
|
6. **Create Checkpoint Commit:**
|
||||||
|
- Stage all changes. If no changes occurred in this step, proceed with an empty commit.
|
||||||
|
- Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
|
||||||
|
|
||||||
|
7. **Attach Auditable Verification Report using Git Notes:**
|
||||||
|
- **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
|
||||||
|
- **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
|
||||||
|
|
||||||
|
8. **Get and Record Phase Checkpoint SHA:**
|
||||||
|
- **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
|
||||||
|
- **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: <sha>]`.
|
||||||
|
- **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
|
||||||
|
|
||||||
|
9. **Commit Plan Update:**
|
||||||
|
- **Action:** Stage the modified `plan.md` file.
|
||||||
|
- **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
|
||||||
|
|
||||||
|
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
Before marking any task complete, verify:
|
||||||
|
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] Code coverage meets requirements (>80%)
|
||||||
|
- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
|
||||||
|
- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
|
||||||
|
- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
|
||||||
|
- [ ] No linting or static analysis errors (using the project's configured tools)
|
||||||
|
- [ ] Works correctly on mobile (if applicable)
|
||||||
|
- [ ] Documentation updated if needed
|
||||||
|
- [ ] No security vulnerabilities introduced
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
|
||||||
|
# e.g., for a Node.js project: npm install
|
||||||
|
# e.g., for a Go project: go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daily Development
|
||||||
|
```bash
|
||||||
|
# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
|
||||||
|
# e.g., for a Node.js project: npm run dev, npm test, npm run lint
|
||||||
|
# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
```bash
|
||||||
|
# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
|
||||||
|
# e.g., for a Node.js project: npm run check
|
||||||
|
# e.g., for a Go project: make check (if a Makefile exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
- Every module must have corresponding tests.
|
||||||
|
- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
|
||||||
|
- Mock external dependencies.
|
||||||
|
- Test both success and failure cases.
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
- Test complete user flows
|
||||||
|
- Verify database transactions
|
||||||
|
- Test authentication and authorization
|
||||||
|
- Check form submissions
|
||||||
|
|
||||||
|
### Mobile Testing
|
||||||
|
- Test on actual iPhone when possible
|
||||||
|
- Use Safari developer tools
|
||||||
|
- Test touch interactions
|
||||||
|
- Verify responsive layouts
|
||||||
|
- Check performance on 3G/4G
|
||||||
|
|
||||||
|
## Code Review Process
|
||||||
|
|
||||||
|
### Self-Review Checklist
|
||||||
|
Before requesting review:
|
||||||
|
|
||||||
|
1. **Functionality**
|
||||||
|
- Feature works as specified
|
||||||
|
- Edge cases handled
|
||||||
|
- Error messages are user-friendly
|
||||||
|
|
||||||
|
2. **Code Quality**
|
||||||
|
- Follows style guide
|
||||||
|
- DRY principle applied
|
||||||
|
- Clear variable/function names
|
||||||
|
- Appropriate comments
|
||||||
|
|
||||||
|
3. **Testing**
|
||||||
|
- Unit tests comprehensive
|
||||||
|
- Integration tests pass
|
||||||
|
- Coverage adequate (>80%)
|
||||||
|
|
||||||
|
4. **Security**
|
||||||
|
- No hardcoded secrets
|
||||||
|
- Input validation present
|
||||||
|
- SQL injection prevented
|
||||||
|
- XSS protection in place
|
||||||
|
|
||||||
|
5. **Performance**
|
||||||
|
- Database queries optimized
|
||||||
|
- Images optimized
|
||||||
|
- Caching implemented where needed
|
||||||
|
|
||||||
|
6. **Mobile Experience**
|
||||||
|
- Touch targets adequate (44x44px)
|
||||||
|
- Text readable without zooming
|
||||||
|
- Performance acceptable on mobile
|
||||||
|
- Interactions feel native
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
### Message Format
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation only
|
||||||
|
- `style`: Formatting, missing semicolons, etc.
|
||||||
|
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test`: Adding missing tests
|
||||||
|
- `chore`: Maintenance tasks
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(auth): Add remember me functionality"
|
||||||
|
git commit -m "fix(posts): Correct excerpt generation for short posts"
|
||||||
|
git commit -m "test(comments): Add tests for emoji reaction limits"
|
||||||
|
git commit -m "style(mobile): Improve button touch targets"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
A task is complete when:
|
||||||
|
|
||||||
|
1. All code implemented to specification
|
||||||
|
2. Unit tests written and passing
|
||||||
|
3. Code coverage meets project requirements
|
||||||
|
4. Documentation complete (if applicable)
|
||||||
|
5. Code passes all configured linting and static analysis checks
|
||||||
|
6. Works beautifully on mobile (if applicable)
|
||||||
|
7. Implementation notes added to `plan.md`
|
||||||
|
8. Changes committed with proper message
|
||||||
|
9. Git note with task summary attached to the commit
|
||||||
|
|
||||||
|
## Emergency Procedures
|
||||||
|
|
||||||
|
### Critical Bug in Production
|
||||||
|
1. Create hotfix branch from main
|
||||||
|
2. Write failing test for bug
|
||||||
|
3. Implement minimal fix
|
||||||
|
4. Test thoroughly including mobile
|
||||||
|
5. Deploy immediately
|
||||||
|
6. Document in plan.md
|
||||||
|
|
||||||
|
### Data Loss
|
||||||
|
1. Stop all write operations
|
||||||
|
2. Restore from latest backup
|
||||||
|
3. Verify data integrity
|
||||||
|
4. Document incident
|
||||||
|
5. Update backup procedures
|
||||||
|
|
||||||
|
### Security Breach
|
||||||
|
1. Rotate all secrets immediately
|
||||||
|
2. Review access logs
|
||||||
|
3. Patch vulnerability
|
||||||
|
4. Notify affected users (if any)
|
||||||
|
5. Document and update security procedures
|
||||||
|
|
||||||
|
## Deployment Workflow
|
||||||
|
|
||||||
|
### Pre-Deployment Checklist
|
||||||
|
- [ ] All tests passing
|
||||||
|
- [ ] Coverage >80%
|
||||||
|
- [ ] No linting errors
|
||||||
|
- [ ] Mobile testing complete
|
||||||
|
- [ ] Environment variables configured
|
||||||
|
- [ ] Database migrations ready
|
||||||
|
- [ ] Backup created
|
||||||
|
|
||||||
|
### Deployment Steps
|
||||||
|
1. Merge feature branch to main
|
||||||
|
2. Tag release with version
|
||||||
|
3. Push to deployment service
|
||||||
|
4. Run database migrations
|
||||||
|
5. Verify deployment
|
||||||
|
6. Test critical paths
|
||||||
|
7. Monitor for errors
|
||||||
|
|
||||||
|
### Post-Deployment
|
||||||
|
1. Monitor analytics
|
||||||
|
2. Check error logs
|
||||||
|
3. Gather user feedback
|
||||||
|
4. Plan next iteration
|
||||||
|
|
||||||
|
## Continuous Improvement
|
||||||
|
|
||||||
|
- Review workflow weekly
|
||||||
|
- Update based on pain points
|
||||||
|
- Document lessons learned
|
||||||
|
- Optimize for user happiness
|
||||||
|
- Keep things simple and maintainable
|
||||||
|
|
@ -21,8 +21,8 @@ VERSION_CODE_OFFSET=29314197
|
||||||
# Application and SDK versions
|
# Application and SDK versions
|
||||||
APPLICATION_ID=com.geeksville.mesh
|
APPLICATION_ID=com.geeksville.mesh
|
||||||
MIN_SDK=26
|
MIN_SDK=26
|
||||||
TARGET_SDK=37
|
TARGET_SDK=36
|
||||||
COMPILE_SDK=37
|
COMPILE_SDK=36
|
||||||
|
|
||||||
# Base version name for local development and fallback
|
# Base version name for local development and fallback
|
||||||
# On CI, this is overridden by the Git tag
|
# On CI, this is overridden by the Git tag
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# ============================================================================
|
|
||||||
# Meshtastic — Shared ProGuard / R8 rules
|
|
||||||
# ============================================================================
|
|
||||||
# Cross-platform keep and dontwarn rules applied to BOTH the Android app
|
|
||||||
# release build (R8) and the Desktop distribution (ProGuard). Host-specific
|
|
||||||
# rules live in the per-module proguard-rules.pro file.
|
|
||||||
#
|
|
||||||
# Rule of thumb: anything describing a library shared between Android and
|
|
||||||
# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable,
|
|
||||||
# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries,
|
|
||||||
# Markdown renderer, QRCode, Compose Multiplatform resources, core modules)
|
|
||||||
# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android
|
|
||||||
# framework, JDK-version quirks, flavor specifics) stays in the host file.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# ---- Attributes -------------------------------------------------------------
|
|
||||||
|
|
||||||
# Preserve line numbers for meaningful stack traces, plus metadata needed for
|
|
||||||
# reflective serializer/DI/Room lookups.
|
|
||||||
-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations
|
|
||||||
|
|
||||||
# ---- Kotlin / Coroutines ----------------------------------------------------
|
|
||||||
# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules
|
|
||||||
# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep
|
|
||||||
# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No
|
|
||||||
# explicit wildcards needed here.
|
|
||||||
|
|
||||||
# ---- Koin DI (reflection-based injection) -----------------------------------
|
|
||||||
|
|
||||||
# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
|
|
||||||
# replacing Koin's InstanceCreationException in stack traces, making crashes
|
|
||||||
# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph.
|
|
||||||
-keep class org.koin.** { *; }
|
|
||||||
-dontwarn org.koin.**
|
|
||||||
|
|
||||||
# Keep Koin-annotated modules/components so Koin Annotations (KSP) output
|
|
||||||
# survives tree-shaking.
|
|
||||||
-keep @org.koin.core.annotation.Module class * { *; }
|
|
||||||
-keep @org.koin.core.annotation.ComponentScan class * { *; }
|
|
||||||
-keep @org.koin.core.annotation.Single class * { *; }
|
|
||||||
-keep @org.koin.core.annotation.Factory class * { *; }
|
|
||||||
-keep @org.koin.core.annotation.KoinViewModel class * { *; }
|
|
||||||
|
|
||||||
# ---- kotlinx-serialization --------------------------------------------------
|
|
||||||
|
|
||||||
-keep class kotlinx.serialization.** { *; }
|
|
||||||
-dontwarn kotlinx.serialization.**
|
|
||||||
|
|
||||||
# Keep @Serializable classes and their generated $serializer companions
|
|
||||||
-keepclassmembers @kotlinx.serialization.Serializable class ** {
|
|
||||||
static ** Companion;
|
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
|
||||||
}
|
|
||||||
-keep class **.$serializer { *; }
|
|
||||||
-keepclassmembers class **.$serializer { *; }
|
|
||||||
-keepclasseswithmembers class ** {
|
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Wire Protobuf ----------------------------------------------------------
|
|
||||||
|
|
||||||
# Wire generates an ADAPTER static field on every Message subclass accessed
|
|
||||||
# reflectively during encoding/decoding. Keep those fields and the
|
|
||||||
# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve
|
|
||||||
# the runtime itself.
|
|
||||||
-keepclassmembers class * extends com.squareup.wire.Message {
|
|
||||||
public static *** ADAPTER;
|
|
||||||
}
|
|
||||||
-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; }
|
|
||||||
|
|
||||||
# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs
|
|
||||||
# when compiling for non-Android JVM targets; harmless on Android).
|
|
||||||
-dontwarn android.os.Parcel**
|
|
||||||
-dontwarn android.os.Parcelable**
|
|
||||||
|
|
||||||
# ---- Room KMP (room3) -------------------------------------------------------
|
|
||||||
|
|
||||||
# Preserve generated database constructors (Room uses reflection to instantiate)
|
|
||||||
-keep class * extends androidx.room3.RoomDatabase { <init>(); }
|
|
||||||
-keep class * implements androidx.room3.RoomDatabaseConstructor { *; }
|
|
||||||
|
|
||||||
# Keep the expect/actual MeshtasticDatabaseConstructor + database surface
|
|
||||||
-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; }
|
|
||||||
-keep class org.meshtastic.core.database.MeshtasticDatabase { *; }
|
|
||||||
|
|
||||||
# Room's own consumer rules (from androidx.room3) keep DAOs, entities,
|
|
||||||
# generated _Impl classes, and TypeConverters referenced from the database.
|
|
||||||
|
|
||||||
# ---- SQLite bundled --------------------------------------------------------
|
|
||||||
# androidx.sqlite ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Ktor (ServiceLoader + plugin discovery) --------------------------------
|
|
||||||
|
|
||||||
# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory
|
|
||||||
# implementations reflectively via ServiceLoader).
|
|
||||||
-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
|
|
||||||
|
|
||||||
# ---- Coil 3 (image loading) -------------------------------------------------
|
|
||||||
# coil3 ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Kable BLE --------------------------------------------------------------
|
|
||||||
# com.juul.kable ships consumer rules; if release builds fail with missing
|
|
||||||
# Kable classes, restore a narrow keep for the specific reflection-loaded type.
|
|
||||||
|
|
||||||
# ---- Compose Multiplatform resources ----------------------------------------
|
|
||||||
|
|
||||||
# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.).
|
|
||||||
# Without these the fdroid flavor has crashed at startup with a misleading
|
|
||||||
# URLDecodeException due to R8 exception-class merging.
|
|
||||||
-keep class org.jetbrains.compose.resources.** { *; }
|
|
||||||
-keep class org.meshtastic.core.resources.Res { *; }
|
|
||||||
-keepclassmembers class org.meshtastic.core.resources.Res$* { *; }
|
|
||||||
|
|
||||||
# ---- AboutLibraries ---------------------------------------------------------
|
|
||||||
# com.mikepenz.aboutlibraries ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Multiplatform Markdown Renderer ----------------------------------------
|
|
||||||
# com.mikepenz.markdown ships consumer rules.
|
|
||||||
|
|
||||||
# ---- QR Code Kotlin ---------------------------------------------------------
|
|
||||||
|
|
||||||
-keep class io.github.g0dkar.qrcode.** { *; }
|
|
||||||
-dontwarn io.github.g0dkar.qrcode.**
|
|
||||||
-keep class qrcode.** { *; }
|
|
||||||
-dontwarn qrcode.**
|
|
||||||
|
|
||||||
# ---- Kermit logging ---------------------------------------------------------
|
|
||||||
# co.touchlab.kermit ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Okio -------------------------------------------------------------------
|
|
||||||
# okio ships consumer rules.
|
|
||||||
|
|
||||||
# ---- DataStore --------------------------------------------------------------
|
|
||||||
# androidx.datastore ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Paging -----------------------------------------------------------------
|
|
||||||
# androidx.paging ships consumer rules.
|
|
||||||
|
|
||||||
# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) -----------------
|
|
||||||
# androidx.lifecycle and androidx.navigation3 ship consumer rules.
|
|
||||||
|
|
||||||
# ---- Meshtastic shared model ------------------------------------------------
|
|
||||||
# core.model types are reached via static references from Koin-wired graphs,
|
|
||||||
# Room entities, and kotlinx-serialization @Serializable companions — all of
|
|
||||||
# which have their own keep rules above.
|
|
||||||
|
|
||||||
# ---- Compose Runtime & Animation --------------------------------------------
|
|
||||||
|
|
||||||
# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that
|
|
||||||
# are referenced indirectly through compiler-generated state machines. Applies
|
|
||||||
# to BOTH R8 (Android app) and ProGuard (desktop distribution).
|
|
||||||
#
|
|
||||||
# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on
|
|
||||||
# Composer.<clinit>() / ComposerImpl.<clinit>() and -assumevalues on
|
|
||||||
# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full
|
|
||||||
# mode on Android, ProGuard with optimize.set(true) on desktop) these call
|
|
||||||
# sites can be rewritten even when the target classes are kept, causing the
|
|
||||||
# recomposer / frame-clock / animation state machines to silently freeze on
|
|
||||||
# the first frame. -dontoptimize (set per-host) is the primary defence; these
|
|
||||||
# keep rules are a safety net against future toolchain changes. See #5146.
|
|
||||||
-keep class androidx.compose.runtime.** { *; }
|
|
||||||
-keep class androidx.compose.ui.** { *; }
|
|
||||||
-keep class androidx.compose.animation.core.** { *; }
|
|
||||||
-keep class androidx.compose.animation.** { *; }
|
|
||||||
-keep class androidx.compose.foundation.** { *; }
|
|
||||||
-keep class androidx.compose.material3.** { *; }
|
|
||||||
|
|
@ -33,9 +33,9 @@ dependencies {
|
||||||
implementation(projects.core.ui)
|
implementation(projects.core.ui)
|
||||||
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.compose.multiplatform.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.compose.multiplatform.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
implementation(libs.compose.multiplatform.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
|
|
||||||
|
|
@ -52,6 +52,6 @@ dependencies {
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testRuntimeOnly(libs.junit.vintage.engine)
|
testRuntimeOnly(libs.junit.vintage.engine)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.compose.multiplatform.ui.test)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,21 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.barcode
|
package org.meshtastic.core.barcode
|
||||||
|
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.runComposeUiTest
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34])
|
@Config(sdk = [34])
|
||||||
class BarcodeScannerTest {
|
class BarcodeScannerTest {
|
||||||
|
|
||||||
@Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } }
|
@get:Rule val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRememberBarcodeScanner() {
|
||||||
|
composeTestRule.setContent { rememberBarcodeScanner { _ -> } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,13 @@ kotlin {
|
||||||
implementation(libs.jetbrains.lifecycle.runtime)
|
implementation(libs.jetbrains.lifecycle.runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
|
||||||
implementation(projects.core.testing)
|
val androidHostTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.junit)
|
||||||
|
implementation(libs.androidx.lifecycle.testing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import org.koin.core.annotation.Named
|
import org.koin.core.annotation.Named
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.di.CoroutineDispatchers
|
import org.meshtastic.core.di.CoroutineDispatchers
|
||||||
|
|
@ -50,7 +49,7 @@ class AndroidBluetoothRepository(
|
||||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
|
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
|
||||||
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||||
|
|
||||||
private val deviceCache = mutableMapOf<String, MeshtasticBleDevice>()
|
private val deviceCache = mutableMapOf<String, DirectBleDevice>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
||||||
|
|
@ -87,7 +86,7 @@ class AndroidBluetoothRepository(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
suspendCancellableCoroutine<Unit> { cont ->
|
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
|
||||||
val receiver =
|
val receiver =
|
||||||
object : android.content.BroadcastReceiver() {
|
object : android.content.BroadcastReceiver() {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
|
|
@ -181,15 +180,14 @@ class AndroidBluetoothRepository(
|
||||||
// user renamed the device in firmware since the cache was populated.
|
// user renamed the device in firmware since the cache was populated.
|
||||||
deviceCache.keys.retainAll(bondedAddresses)
|
deviceCache.keys.retainAll(bondedAddresses)
|
||||||
return bonded.map { device ->
|
return bonded.map { device ->
|
||||||
val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) }
|
deviceCache
|
||||||
// If the name changed (firmware rename, etc.), replace the cached entry and return the new one.
|
.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||||
if (cached.name != device.name) {
|
.also { cached ->
|
||||||
val updated = MeshtasticBleDevice(device.address, device.name)
|
// Refresh name if it changed (firmware rename, etc.)
|
||||||
deviceCache[device.address] = updated
|
if (cached.name != device.name) {
|
||||||
updated
|
deviceCache[device.address] = DirectBleDevice(device.address, device.name)
|
||||||
} else {
|
}
|
||||||
cached
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,15 @@ import co.touchlab.kermit.Logger
|
||||||
import com.juul.kable.AndroidPeripheral
|
import com.juul.kable.AndroidPeripheral
|
||||||
import com.juul.kable.Peripheral
|
import com.juul.kable.Peripheral
|
||||||
import com.juul.kable.PeripheralBuilder
|
import com.juul.kable.PeripheralBuilder
|
||||||
import com.juul.kable.PooledThreadingStrategy
|
|
||||||
import com.juul.kable.toIdentifier
|
import com.juul.kable.toIdentifier
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared thread pool for Kable BLE connections.
|
|
||||||
*
|
|
||||||
* [PooledThreadingStrategy] reuses handler threads across reconnect cycles, avoiding the overhead of creating a new
|
|
||||||
* thread per connection attempt that [OnDemandThreadingStrategy][com.juul.kable.OnDemandThreadingStrategy] incurs. Idle
|
|
||||||
* threads are evicted after 1 minute (default).
|
|
||||||
*
|
|
||||||
* A single app-wide instance is used because Kable recommends exactly one pool per application.
|
|
||||||
*/
|
|
||||||
private val sharedThreadingStrategy = PooledThreadingStrategy()
|
|
||||||
|
|
||||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||||
// Bonded devices without a fresh advertisement must use autoConnect = true. Otherwise,
|
// If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
|
||||||
// Android's direct connect algorithm often fails with GATT 133 or times out, especially
|
// we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
|
||||||
// if the device uses random resolvable addresses. Scanned devices (advertisement != null)
|
// immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
|
||||||
// use direct connection (autoConnect = false) for faster initial connects.
|
// If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
|
||||||
autoConnectIf(autoConnect)
|
autoConnectIf(autoConnect)
|
||||||
|
|
||||||
threadingStrategy = sharedThreadingStrategy
|
|
||||||
|
|
||||||
onServicesDiscovered {
|
onServicesDiscovered {
|
||||||
try {
|
try {
|
||||||
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
|
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,14 @@ package org.meshtastic.core.ble
|
||||||
import com.juul.kable.Peripheral
|
import com.juul.kable.Peripheral
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */
|
|
||||||
internal data class ActiveConnection(val peripheral: Peripheral, val address: String)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
|
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
|
||||||
* dynamically created UI devices (scanned vs bonded) and the actual connection.
|
* dynamically created UI devices (scanned vs bonded) and the actual connection.
|
||||||
*
|
*
|
||||||
* [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous
|
* Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers.
|
||||||
* two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated
|
|
||||||
* non-atomically.
|
|
||||||
*/
|
*/
|
||||||
internal object ActiveBleConnection {
|
internal object ActiveBleConnection {
|
||||||
@Volatile var active: ActiveConnection? = null
|
@Volatile var activePeripheral: Peripheral? = null
|
||||||
|
|
||||||
|
@Volatile var activeAddress: String? = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ package org.meshtastic.core.ble
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
@ -50,8 +49,8 @@ interface BleConnection {
|
||||||
/** Connects to the given [BleDevice]. */
|
/** Connects to the given [BleDevice]. */
|
||||||
suspend fun connect(device: BleDevice)
|
suspend fun connect(device: BleDevice)
|
||||||
|
|
||||||
/** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */
|
/** Connects to the given [BleDevice] and waits for a terminal state. */
|
||||||
suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState
|
suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState
|
||||||
|
|
||||||
/** Disconnects from the current device. */
|
/** Disconnects from the current device. */
|
||||||
suspend fun disconnect()
|
suspend fun disconnect()
|
||||||
|
|
@ -78,17 +77,6 @@ interface BleService {
|
||||||
/** Observes notifications/indications from the characteristic. */
|
/** Observes notifications/indications from the characteristic. */
|
||||||
fun observe(characteristic: BleCharacteristic): Flow<ByteArray>
|
fun observe(characteristic: BleCharacteristic): Flow<ByteArray>
|
||||||
|
|
||||||
/**
|
|
||||||
* Observes notifications/indications from the characteristic with an [onSubscription] action that fires **after**
|
|
||||||
* notifications are enabled (CCCD written).
|
|
||||||
*
|
|
||||||
* The [onSubscription] is re-invoked on every reconnect while the returned [Flow] is active. The default
|
|
||||||
* implementation invokes [onSubscription] eagerly on flow start so non-Kable implementations still signal
|
|
||||||
* readiness.
|
|
||||||
*/
|
|
||||||
fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit): Flow<ByteArray> =
|
|
||||||
observe(characteristic).onStart { onSubscription() }
|
|
||||||
|
|
||||||
/** Reads the characteristic value once. */
|
/** Reads the characteristic value once. */
|
||||||
suspend fun read(characteristic: BleCharacteristic): ByteArray
|
suspend fun read(characteristic: BleCharacteristic): ByteArray
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,53 +17,16 @@
|
||||||
package org.meshtastic.core.ble
|
package org.meshtastic.core.ble
|
||||||
|
|
||||||
/** Represents the state of a BLE connection. */
|
/** Represents the state of a BLE connection. */
|
||||||
sealed interface BleConnectionState {
|
sealed class BleConnectionState {
|
||||||
|
/** The peripheral is disconnected. */
|
||||||
/**
|
object Disconnected : BleConnectionState()
|
||||||
* The peripheral is disconnected.
|
|
||||||
*
|
|
||||||
* @param reason why the disconnect occurred. [DisconnectReason.Unknown] when the platform doesn't provide status
|
|
||||||
* information (e.g. JavaScript) or when the disconnect was synthesised locally without a GATT callback.
|
|
||||||
*/
|
|
||||||
data class Disconnected(val reason: DisconnectReason = DisconnectReason.Unknown) : BleConnectionState
|
|
||||||
|
|
||||||
/** The peripheral is connecting. */
|
/** The peripheral is connecting. */
|
||||||
data object Connecting : BleConnectionState
|
object Connecting : BleConnectionState()
|
||||||
|
|
||||||
/** The peripheral is connected. */
|
/** The peripheral is connected. */
|
||||||
data object Connected : BleConnectionState
|
object Connected : BleConnectionState()
|
||||||
|
|
||||||
/** The peripheral is disconnecting. */
|
/** The peripheral is disconnecting. */
|
||||||
data object Disconnecting : BleConnectionState
|
object Disconnecting : BleConnectionState()
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Platform-agnostic reason for a BLE disconnect.
|
|
||||||
*
|
|
||||||
* Mapped from Kable's [com.juul.kable.State.Disconnected.Status] in `KableStateMapping`.
|
|
||||||
*/
|
|
||||||
sealed interface DisconnectReason {
|
|
||||||
/** Cause is unknown or the platform did not report one. */
|
|
||||||
data object Unknown : DisconnectReason
|
|
||||||
|
|
||||||
/** The local app/central initiated the disconnect. */
|
|
||||||
data object LocalDisconnect : DisconnectReason
|
|
||||||
|
|
||||||
/** The remote peripheral (firmware) initiated the disconnect. */
|
|
||||||
data object RemoteDisconnect : DisconnectReason
|
|
||||||
|
|
||||||
/** A connection attempt failed to establish. */
|
|
||||||
data object ConnectionFailed : DisconnectReason
|
|
||||||
|
|
||||||
/** The BLE link supervision timed out (device went out of range). */
|
|
||||||
data object Timeout : DisconnectReason
|
|
||||||
|
|
||||||
/** The connection was explicitly cancelled. */
|
|
||||||
data object Cancelled : DisconnectReason
|
|
||||||
|
|
||||||
/** An encryption or authentication failure occurred. */
|
|
||||||
data object EncryptionFailed : DisconnectReason
|
|
||||||
|
|
||||||
/** Platform-specific status code that doesn't map to a known reason. */
|
|
||||||
data class PlatformSpecific(val code: Int) : DisconnectReason
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +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("MatchingDeclarationName") // File groups the classifier function and its result type.
|
|
||||||
|
|
||||||
package org.meshtastic.core.ble
|
|
||||||
|
|
||||||
import com.juul.kable.GattRequestRejectedException
|
|
||||||
import com.juul.kable.GattStatusException
|
|
||||||
import com.juul.kable.NotConnectedException
|
|
||||||
import com.juul.kable.UnmetRequirementException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classification of a BLE-layer exception for the transport layer to act on.
|
|
||||||
*
|
|
||||||
* @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
|
|
||||||
* Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
|
|
||||||
* grants, transient GATT errors). Reserved for future use.
|
|
||||||
* @property gattStatus the platform GATT status code when available (Android-specific).
|
|
||||||
* @property message a human-readable description of the failure.
|
|
||||||
*/
|
|
||||||
data class BleExceptionInfo(val isPermanent: Boolean, val gattStatus: Int? = null, val message: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inspects this [Throwable] and returns a [BleExceptionInfo] if it is a known Kable exception, or `null` if it is
|
|
||||||
* unrelated to the BLE layer.
|
|
||||||
*
|
|
||||||
* This keeps Kable type knowledge inside `core:ble` so that `core:network` (and other consumers) can classify BLE
|
|
||||||
* exceptions without depending on Kable directly.
|
|
||||||
*/
|
|
||||||
fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
|
|
||||||
is GattStatusException ->
|
|
||||||
BleExceptionInfo(
|
|
||||||
isPermanent = false,
|
|
||||||
gattStatus = status,
|
|
||||||
message = "GATT error (status $status): $message",
|
|
||||||
)
|
|
||||||
is NotConnectedException -> BleExceptionInfo(isPermanent = false, message = "Not connected")
|
|
||||||
is GattRequestRejectedException ->
|
|
||||||
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
|
|
||||||
is UnmetRequirementException ->
|
|
||||||
// Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
|
|
||||||
// device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
|
|
||||||
// retrying; UI can show a hint based on the message.
|
|
||||||
BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
@ -48,7 +48,9 @@ suspend fun <T> retryBleOperation(
|
||||||
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
|
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
|
Logger.w(e) {
|
||||||
|
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
|
||||||
|
}
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Meshtastic LLC
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.meshtastic.core.ble
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
|
||||||
|
class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
|
||||||
|
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||||
|
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
override val isBonded: Boolean = true
|
||||||
|
|
||||||
|
override val isConnected: Boolean
|
||||||
|
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||||
|
|
||||||
|
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||||
|
override suspend fun readRssi(): Int {
|
||||||
|
val peripheral = ActiveBleConnection.activePeripheral
|
||||||
|
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||||
|
peripheral.rssi()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun bond() {
|
||||||
|
// DirectBleDevice assumes we are already bonded.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateState(newState: BleConnectionState) {
|
||||||
|
_state.value = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,9 @@ package org.meshtastic.core.ble
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.juul.kable.Peripheral
|
import com.juul.kable.Peripheral
|
||||||
import com.juul.kable.PeripheralBuilder
|
|
||||||
import com.juul.kable.State
|
import com.juul.kable.State
|
||||||
import com.juul.kable.WriteType
|
import com.juul.kable.WriteType
|
||||||
import com.juul.kable.characteristicOf
|
import com.juul.kable.characteristicOf
|
||||||
import com.juul.kable.logs.Logging
|
|
||||||
import com.juul.kable.writeWithoutResponse
|
import com.juul.kable.writeWithoutResponse
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -32,6 +30,7 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -40,7 +39,6 @@ import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */
|
/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */
|
||||||
|
|
@ -52,9 +50,6 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
|
||||||
override fun observe(characteristic: BleCharacteristic) =
|
override fun observe(characteristic: BleCharacteristic) =
|
||||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
|
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
|
||||||
|
|
||||||
override fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit) =
|
|
||||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid), onSubscription)
|
|
||||||
|
|
||||||
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
||||||
peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
|
peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
|
||||||
|
|
||||||
|
|
@ -83,11 +78,8 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
|
||||||
/**
|
/**
|
||||||
* [BleConnection] implementation using Kable for cross-platform BLE communication.
|
* [BleConnection] implementation using Kable for cross-platform BLE communication.
|
||||||
*
|
*
|
||||||
* Manages peripheral lifecycle, connection state tracking, and GATT service profile access.
|
* Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking,
|
||||||
*
|
* and GATT service profile access.
|
||||||
* Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then
|
|
||||||
* fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller
|
|
||||||
* ([BleRadioTransport]) owns the macro-level retry/backoff loop.
|
|
||||||
*/
|
*/
|
||||||
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
|
|
||||||
|
|
@ -96,8 +88,10 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
private var connectionScope: CoroutineScope? = null
|
private var connectionScope: CoroutineScope? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Settle delay between a direct connect failure and the autoConnect fallback attempt. */
|
private const val INITIAL_RETRY_DELAY_MS = 1000L
|
||||||
private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds
|
private const val MAX_RETRY_DELAY_MS = 30_000L
|
||||||
|
private const val MAX_CONNECT_RETRIES = 15
|
||||||
|
private const val BACKOFF_MULTIPLIER = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||||
|
|
@ -114,32 +108,47 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
)
|
)
|
||||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||||
|
|
||||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||||
override suspend fun connect(device: BleDevice) {
|
override suspend fun connect(device: BleDevice) {
|
||||||
val meshtasticDevice = device as? MeshtasticBleDevice ?: error("Unsupported BleDevice type: ${device::class}")
|
val autoConnect = MutableStateFlow(device is DirectBleDevice)
|
||||||
var autoConnect = meshtasticDevice.advertisement == null
|
|
||||||
|
|
||||||
/** Applies logging, observation exception handling, and platform config shared by both peripheral types. */
|
|
||||||
fun PeripheralBuilder.commonConfig() {
|
|
||||||
logging {
|
|
||||||
engine = KermitLogEngine
|
|
||||||
level = Logging.Level.Events
|
|
||||||
identifier = device.address
|
|
||||||
}
|
|
||||||
observationExceptionHandler { cause ->
|
|
||||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
|
||||||
}
|
|
||||||
platformConfig(device) { autoConnect }
|
|
||||||
}
|
|
||||||
|
|
||||||
val p =
|
val p =
|
||||||
meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } }
|
when (device) {
|
||||||
?: createPeripheral(device.address) { commonConfig() }
|
is KableBleDevice ->
|
||||||
|
Peripheral(device.advertisement) {
|
||||||
|
observationExceptionHandler { cause ->
|
||||||
|
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||||
|
}
|
||||||
|
platformConfig(device) { autoConnect.value }
|
||||||
|
}
|
||||||
|
is DirectBleDevice ->
|
||||||
|
createPeripheral(device.address) {
|
||||||
|
observationExceptionHandler { cause ->
|
||||||
|
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||||
|
}
|
||||||
|
platformConfig(device) { autoConnect.value }
|
||||||
|
}
|
||||||
|
else -> error("Unsupported BleDevice type: ${device::class}")
|
||||||
|
}
|
||||||
|
|
||||||
cleanUpPeripheral(device.address)
|
// Clean up previous peripheral under NonCancellable to prevent GATT resource leaks
|
||||||
|
// if the calling coroutine is cancelled during teardown.
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
try {
|
||||||
|
peripheral?.disconnect()
|
||||||
|
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||||
|
Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
peripheral?.close()
|
||||||
|
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||||
|
Logger.w(e) { "[${device.address}] Failed to close previous peripheral" }
|
||||||
|
}
|
||||||
|
}
|
||||||
peripheral = p
|
peripheral = p
|
||||||
|
|
||||||
ActiveBleConnection.active = ActiveConnection(p, device.address)
|
ActiveBleConnection.activePeripheral = p
|
||||||
|
ActiveBleConnection.activeAddress = device.address
|
||||||
|
|
||||||
_deviceFlow.emit(device)
|
_deviceFlow.emit(device)
|
||||||
|
|
||||||
|
|
@ -153,15 +162,21 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
hasStartedConnecting = true
|
hasStartedConnecting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
meshtasticDevice.updateState(mappedState)
|
when (device) {
|
||||||
|
is KableBleDevice -> device.updateState(mappedState)
|
||||||
|
is DirectBleDevice -> device.updateState(mappedState)
|
||||||
|
}
|
||||||
|
|
||||||
_connectionState.emit(mappedState)
|
_connectionState.emit(mappedState)
|
||||||
}
|
}
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
|
|
||||||
|
var retryCount = 0
|
||||||
|
var retryDelayMs = INITIAL_RETRY_DELAY_MS
|
||||||
while (p.state.value !is State.Connected) {
|
while (p.state.value !is State.Connected) {
|
||||||
autoConnect =
|
autoConnect.value =
|
||||||
try {
|
try {
|
||||||
|
// Cancel any previous connectionScope to avoid leaking the old coroutine scope.
|
||||||
connectionScope?.let { oldScope ->
|
connectionScope?.let { oldScope ->
|
||||||
Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
|
Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
|
||||||
oldScope.coroutineContext.job.cancel()
|
oldScope.coroutineContext.job.cancel()
|
||||||
|
|
@ -170,50 +185,52 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
false
|
false
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) {
|
||||||
if (autoConnect) {
|
retryCount++
|
||||||
// autoConnect already true and still failed — don't loop forever.
|
if (retryCount > MAX_CONNECT_RETRIES) {
|
||||||
Logger.w { "[${device.address}] autoConnect attempt failed, giving up" }
|
Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" }
|
||||||
_connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed))
|
_connectionState.emit(BleConnectionState.Disconnected)
|
||||||
throw e
|
return
|
||||||
}
|
}
|
||||||
Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" }
|
Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" }
|
||||||
delay(AUTOCONNECT_FALLBACK_DELAY)
|
delay(retryDelayMs)
|
||||||
|
retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||||
override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try {
|
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
|
||||||
withTimeout(timeout) {
|
withTimeout(timeoutMs) {
|
||||||
connect(device)
|
connect(device)
|
||||||
BleConnectionState.Connected
|
BleConnectionState.Connected
|
||||||
}
|
}
|
||||||
} catch (_: TimeoutCancellationException) {
|
} catch (_: TimeoutCancellationException) {
|
||||||
// Our own timeout expired — treat as a failed attempt so callers can retry.
|
// Our own timeout expired — treat as a failed attempt so callers can retry.
|
||||||
BleConnectionState.Disconnected(DisconnectReason.Timeout)
|
BleConnectionState.Disconnected
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// External cancellation (scope closed) — must propagate.
|
// External cancellation (scope closed) — must propagate.
|
||||||
throw e
|
throw e
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)
|
BleConnectionState.Disconnected
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||||
// Emit Disconnected before cancelling stateJob so downstream collectors see the
|
// Emit Disconnected before cancelling stateJob so downstream collectors see the
|
||||||
// state transition. If we cancel stateJob first, the peripheral's state flow
|
// state transition. If we cancel stateJob first, the peripheral's state flow
|
||||||
// emission of Disconnected is never forwarded to _connectionState.
|
// emission of Disconnected is never forwarded to _connectionState.
|
||||||
_connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect))
|
_connectionState.emit(BleConnectionState.Disconnected)
|
||||||
|
|
||||||
stateJob?.cancel()
|
stateJob?.cancel()
|
||||||
stateJob = null
|
stateJob = null
|
||||||
|
peripheral?.disconnect()
|
||||||
safeClosePeripheral("disconnect")
|
peripheral?.close()
|
||||||
peripheral = null
|
peripheral = null
|
||||||
connectionScope = null
|
connectionScope = null
|
||||||
|
|
||||||
ActiveBleConnection.active = null
|
ActiveBleConnection.activePeripheral = null
|
||||||
|
ActiveBleConnection.activeAddress = null
|
||||||
|
|
||||||
_deviceFlow.emit(null)
|
_deviceFlow.emit(null)
|
||||||
}
|
}
|
||||||
|
|
@ -230,29 +247,4 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
||||||
|
|
||||||
/** Ensures the previous peripheral's GATT resources are fully released. */
|
|
||||||
private suspend fun cleanUpPeripheral(tag: String) {
|
|
||||||
withContext(NonCancellable) { safeClosePeripheral(tag) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely disconnects and closes the current [peripheral], logging any failures.
|
|
||||||
*
|
|
||||||
* Kable requires `close()` to release broadcast receivers on Android (Kable issue #359). Separate try/catch blocks
|
|
||||||
* ensure `close()` always runs even if `disconnect()` throws.
|
|
||||||
*/
|
|
||||||
@Suppress("TooGenericExceptionCaught")
|
|
||||||
private suspend fun safeClosePeripheral(tag: String) {
|
|
||||||
try {
|
|
||||||
peripheral?.disconnect()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w(e) { "[$tag] Failed to disconnect peripheral" }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
peripheral?.close()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w(e) { "[$tag] Failed to close peripheral" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,5 @@ import org.koin.core.annotation.Single
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class KableBleConnectionFactory : BleConnectionFactory {
|
class KableBleConnectionFactory : BleConnectionFactory {
|
||||||
/**
|
|
||||||
* Creates a new [KableBleConnection].
|
|
||||||
*
|
|
||||||
* [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect]
|
|
||||||
* using the device address, which provides more precise context than a factory-time tag.
|
|
||||||
*/
|
|
||||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
|
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,44 +17,32 @@
|
||||||
package org.meshtastic.core.ble
|
package org.meshtastic.core.ble
|
||||||
|
|
||||||
import com.juul.kable.Advertisement
|
import com.juul.kable.Advertisement
|
||||||
import com.juul.kable.ExperimentalApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
||||||
* Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both.
|
override val name: String?
|
||||||
*
|
get() = advertisement.name
|
||||||
* When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral construction via
|
|
||||||
* `Peripheral(advertisement)`. When created from the OS bonded device list (address only), [advertisement] is `null`
|
|
||||||
* and the peripheral is constructed via `createPeripheral(address)` with `autoConnect = true`.
|
|
||||||
*
|
|
||||||
* @param address The device's MAC address (or platform identifier string).
|
|
||||||
* @param name The device's display name, if known.
|
|
||||||
* @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices.
|
|
||||||
*/
|
|
||||||
class MeshtasticBleDevice(
|
|
||||||
override val address: String,
|
|
||||||
override val name: String? = null,
|
|
||||||
val advertisement: Advertisement? = null,
|
|
||||||
) : BleDevice {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected())
|
override val address: String
|
||||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
get() = advertisement.identifier.toString()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||||
|
override val state: StateFlow<BleConnectionState> = _state
|
||||||
|
|
||||||
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
|
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
|
||||||
override val isBonded: Boolean = true
|
override val isBonded: Boolean = true
|
||||||
|
|
||||||
override val isConnected: Boolean
|
override val isConnected: Boolean
|
||||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address
|
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||||
|
|
||||||
@OptIn(ExperimentalApi::class)
|
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||||
override suspend fun readRssi(): Int {
|
override suspend fun readRssi(): Int {
|
||||||
val active = ActiveBleConnection.active
|
val peripheral = ActiveBleConnection.activePeripheral
|
||||||
return if (active != null && active.address == address) {
|
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||||
active.peripheral.rssi()
|
peripheral.rssi()
|
||||||
} else {
|
} else {
|
||||||
advertisement?.rssi ?: 0
|
advertisement.rssi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +50,6 @@ class MeshtasticBleDevice(
|
||||||
// No-op: bonding is OS-managed on Android and not required on desktop.
|
// No-op: bonding is OS-managed on Android and not required on desktop.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates the tracked connection state. Called by [KableBleConnection] when the peripheral state changes. */
|
|
||||||
internal fun updateState(newState: BleConnectionState) {
|
internal fun updateState(newState: BleConnectionState) {
|
||||||
_state.value = newState
|
_state.value = newState
|
||||||
}
|
}
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package org.meshtastic.core.ble
|
package org.meshtastic.core.ble
|
||||||
|
|
||||||
import com.juul.kable.Scanner
|
import com.juul.kable.Scanner
|
||||||
import com.juul.kable.logs.Logging
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
@ -29,10 +28,6 @@ import kotlin.uuid.Uuid
|
||||||
class KableBleScanner : BleScanner {
|
class KableBleScanner : BleScanner {
|
||||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||||
val scanner = Scanner {
|
val scanner = Scanner {
|
||||||
logging {
|
|
||||||
engine = KermitLogEngine
|
|
||||||
level = Logging.Level.Events
|
|
||||||
}
|
|
||||||
// Use separate match blocks so each filter is evaluated independently (OR semantics).
|
// Use separate match blocks so each filter is evaluated independently (OR semantics).
|
||||||
// Combining address and service UUID in a single match{} creates an AND filter which
|
// Combining address and service UUID in a single match{} creates an AND filter which
|
||||||
// silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a
|
// silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a
|
||||||
|
|
@ -48,15 +43,7 @@ class KableBleScanner : BleScanner {
|
||||||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||||
return channelFlow {
|
return channelFlow {
|
||||||
withTimeoutOrNull(timeout) {
|
withTimeoutOrNull(timeout) {
|
||||||
scanner.advertisements.collect { advertisement ->
|
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||||
send(
|
|
||||||
MeshtasticBleDevice(
|
|
||||||
address = advertisement.identifier.toString(),
|
|
||||||
name = advertisement.name,
|
|
||||||
advertisement = advertisement,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,101 +18,110 @@ package org.meshtastic.core.ble
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||||
|
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
|
||||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [MeshtasticRadioProfile] implementation using Kable BLE characteristics.
|
* [MeshtasticRadioProfile] implementation using Kable BLE characteristics.
|
||||||
*
|
*
|
||||||
* Uses the standard Meshtastic BLE protocol: FROMNUM notifications trigger polling reads on the FROMRADIO
|
* Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` +
|
||||||
* characteristic. The firmware gates FROMNUM notifications behind `STATE_SEND_PACKETS`, so during the config handshake
|
* `FROMRADIO` polling fallback for older firmware versions.
|
||||||
* we seed the drain trigger to poll proactively.
|
|
||||||
*/
|
*/
|
||||||
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
|
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
|
||||||
|
|
||||||
private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC)
|
private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC)
|
||||||
private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC)
|
private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC)
|
||||||
|
private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC)
|
||||||
private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC)
|
private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC)
|
||||||
private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC)
|
private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TRANSIENT_RETRY_DELAY = 500.milliseconds
|
private const val TRANSIENT_RETRY_DELAY_MS = 500L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val subscriptionReady = CompletableDeferred<Unit>()
|
// replay = 1: a seed emission placed here before the collector starts is replayed to the
|
||||||
|
// collector immediately on subscription. This is what drives the initial FROMRADIO poll
|
||||||
/** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */
|
// during the config-handshake phase, where the firmware suppresses FROMNUM notifications
|
||||||
|
// (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config
|
||||||
|
// stream would be silently skipped on devices that lack FROMRADIOSYNC.
|
||||||
private val triggerDrain =
|
private val triggerDrain =
|
||||||
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||||
|
// Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
|
||||||
|
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||||
launch {
|
launch {
|
||||||
if (service.hasCharacteristic(fromNum)) {
|
try {
|
||||||
service
|
if (service.hasCharacteristic(fromRadioSync)) {
|
||||||
.observe(fromNum) {
|
service.observe(fromRadioSync).collect { send(it) }
|
||||||
Logger.d { "FROMNUM CCCD written — notifications enabled" }
|
} else {
|
||||||
subscriptionReady.complete(Unit)
|
error("fromRadioSync missing")
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fallback to legacy FROMNUM/FROMRADIO polling.
|
||||||
|
// Wire up FROMNUM notifications for steady-state packet delivery.
|
||||||
|
launch {
|
||||||
|
if (service.hasCharacteristic(fromNum)) {
|
||||||
|
service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||||
}
|
}
|
||||||
.collect { triggerDrain.tryEmit(Unit) }
|
}
|
||||||
} else {
|
// Seed the replay buffer so the collector below starts draining immediately.
|
||||||
subscriptionReady.complete(Unit)
|
// The firmware does NOT send FROMNUM notifications during the config handshake
|
||||||
}
|
// (it gates them on STATE_SEND_PACKETS). Without this seed the entire config
|
||||||
}
|
// stream would never be read on devices that lack FROMRADIOSYNC.
|
||||||
triggerDrain.tryEmit(Unit)
|
triggerDrain.tryEmit(Unit)
|
||||||
triggerDrain.collect {
|
triggerDrain.collect {
|
||||||
var keepReading = true
|
var keepReading = true
|
||||||
while (keepReading) {
|
while (keepReading) {
|
||||||
try {
|
try {
|
||||||
if (!service.hasCharacteristic(fromRadioChar)) {
|
if (!service.hasCharacteristic(fromRadioChar)) {
|
||||||
keepReading = false
|
keepReading = false
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
val packet = service.read(fromRadioChar)
|
||||||
|
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
|
||||||
|
keepReading = false
|
||||||
|
// Don't permanently stop — the next triggerDrain emission will retry.
|
||||||
|
delay(TRANSIENT_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val packet = service.read(fromRadioChar)
|
|
||||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
|
|
||||||
keepReading = false
|
|
||||||
delay(TRANSIENT_RETRY_DELAY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val logRadio: Flow<ByteArray> =
|
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||||
if (service.hasCharacteristic(logRadioChar)) {
|
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||||
service.observe(logRadioChar).catch { e ->
|
try {
|
||||||
if (e is CancellationException) throw e
|
if (service.hasCharacteristic(logRadioChar)) {
|
||||||
// logRadio is optional — swallow observation errors silently.
|
service.observe(logRadioChar).collect { send(it) }
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e: CancellationException) {
|
||||||
emptyFlow()
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// logRadio is optional, ignore if not found
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendToRadio(packet: ByteArray) {
|
override suspend fun sendToRadio(packet: ByteArray) {
|
||||||
service.write(toRadio, packet, service.preferredWriteType(toRadio))
|
service.write(toRadio, packet, service.preferredWriteType(toRadio))
|
||||||
triggerDrain.tryEmit(Unit)
|
triggerDrain.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestDrain() {
|
|
||||||
triggerDrain.tryEmit(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun awaitSubscriptionReady() {
|
|
||||||
subscriptionReady.await()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,33 +25,14 @@ import com.juul.kable.State
|
||||||
* state emitted by StateFlow upon subscription.
|
* state emitted by StateFlow upon subscription.
|
||||||
* @return the mapped [BleConnectionState], or null if the state should be ignored.
|
* @return the mapped [BleConnectionState], or null if the state should be ignored.
|
||||||
*/
|
*/
|
||||||
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? = when (this) {
|
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
|
||||||
is State.Connecting -> BleConnectionState.Connecting
|
return when (this) {
|
||||||
is State.Connected -> BleConnectionState.Connected
|
is State.Connecting -> BleConnectionState.Connecting
|
||||||
is State.Disconnecting -> BleConnectionState.Disconnecting
|
is State.Connected -> BleConnectionState.Connected
|
||||||
is State.Disconnected ->
|
is State.Disconnecting -> BleConnectionState.Disconnecting
|
||||||
if (hasStartedConnecting) BleConnectionState.Disconnected(status.toDisconnectReason()) else null
|
is State.Disconnected -> {
|
||||||
}
|
if (!hasStartedConnecting) return null
|
||||||
|
BleConnectionState.Disconnected
|
||||||
/**
|
}
|
||||||
* Maps Kable's [State.Disconnected.Status] to [DisconnectReason].
|
}
|
||||||
*
|
|
||||||
* Groups platform-specific GATT/CBError codes into broad categories that the reconnect logic can act on without leaking
|
|
||||||
* platform details.
|
|
||||||
*/
|
|
||||||
fun State.Disconnected.Status?.toDisconnectReason(): DisconnectReason = when (this) {
|
|
||||||
null -> DisconnectReason.Unknown
|
|
||||||
State.Disconnected.Status.CentralDisconnected -> DisconnectReason.LocalDisconnect
|
|
||||||
State.Disconnected.Status.PeripheralDisconnected -> DisconnectReason.RemoteDisconnect
|
|
||||||
State.Disconnected.Status.Failed,
|
|
||||||
State.Disconnected.Status.L2CapFailure,
|
|
||||||
-> DisconnectReason.ConnectionFailed
|
|
||||||
State.Disconnected.Status.Timeout,
|
|
||||||
State.Disconnected.Status.LinkManagerProtocolTimeout,
|
|
||||||
-> DisconnectReason.Timeout
|
|
||||||
State.Disconnected.Status.Cancelled -> DisconnectReason.Cancelled
|
|
||||||
State.Disconnected.Status.EncryptionTimedOut -> DisconnectReason.EncryptionFailed
|
|
||||||
State.Disconnected.Status.ConnectionLimitReached -> DisconnectReason.ConnectionFailed
|
|
||||||
State.Disconnected.Status.UnknownDevice -> DisconnectReason.ConnectionFailed
|
|
||||||
is State.Disconnected.Status.Unknown -> DisconnectReason.PlatformSpecific(status)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +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.core.ble
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import com.juul.kable.logs.LogEngine
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridges Kable's internal logging to [Kermit][Logger] so BLE lifecycle events (connect, disconnect, subscribe, GATT
|
|
||||||
* operations) appear in the standard app logs rather than going to [System.out] via Kable's default
|
|
||||||
* [com.juul.kable.logs.SystemLogEngine].
|
|
||||||
*/
|
|
||||||
internal object KermitLogEngine : LogEngine {
|
|
||||||
override fun verbose(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.v(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun debug(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.d(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun info(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.i(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun warn(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.w(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun error(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.e(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun assert(throwable: Throwable?, tag: String, message: String) {
|
|
||||||
Logger.e(throwable) { "[$tag] $message" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -38,6 +38,8 @@ object MeshtasticBleConstants {
|
||||||
/** Characteristic for receiving log notifications from the radio. */
|
/** Characteristic for receiving log notifications from the radio. */
|
||||||
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||||
|
|
||||||
|
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
|
||||||
|
|
||||||
// --- OTA Characteristics ---
|
// --- OTA Characteristics ---
|
||||||
|
|
||||||
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
|
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue