mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Compare commits
74 commits
v2.7.14-in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21d8af9ae | ||
|
|
a90cb2d89e | ||
|
|
7492a33cf8 | ||
|
|
2b47da3b61 | ||
|
|
3322257cfd | ||
|
|
99e7407a90 | ||
|
|
9dd57725f2 | ||
|
|
2c1984ace5 | ||
|
|
94856d257f | ||
|
|
84fe24467f | ||
|
|
68a414b75b | ||
|
|
4257e7b7e4 | ||
|
|
14e86b90f1 | ||
|
|
ef0e159abb | ||
|
|
61d7f6fef3 | ||
|
|
a273dc6623 | ||
|
|
c866f60b59 | ||
|
|
10bc58d417 | ||
|
|
dd74e501f3 | ||
|
|
56cbc3670d | ||
|
|
15a7c19b74 | ||
|
|
b979663e24 | ||
|
|
9f3fe865e3 | ||
|
|
90f6e21a9c | ||
|
|
cdeb1ac532 | ||
|
|
adfe3bfed1 | ||
|
|
a97f704300 | ||
|
|
df3b5365f9 | ||
|
|
a6a889430b | ||
|
|
65b885a073 | ||
|
|
17e69c6d4c | ||
|
|
872c566ef1 | ||
|
|
3a2f2fc56b | ||
|
|
50896d455b | ||
|
|
a580cd0467 | ||
|
|
8e5d99410c | ||
|
|
0f900fe7d7 | ||
|
|
9ac02cf851 | ||
|
|
878905aea3 | ||
|
|
dea364dd17 | ||
|
|
c7d2a76851 | ||
|
|
f72b91328d | ||
|
|
d0057752f6 | ||
|
|
84621acb04 | ||
|
|
96419f3251 | ||
|
|
60ff495037 | ||
|
|
401f59489a | ||
|
|
a2763bdfeb | ||
|
|
72b981f73b | ||
|
|
50ade01e55 | ||
|
|
79ed0a865a | ||
|
|
bf0deef708 | ||
|
|
fa63a4ac50 | ||
|
|
f48fc61729 | ||
|
|
099aea2d81 | ||
|
|
c6f58cc799 | ||
|
|
27055290e2 | ||
|
|
3aadd29e67 | ||
|
|
9acdf5309f | ||
|
|
99378c9291 | ||
|
|
3c7e1266f8 | ||
|
|
743851b0b5 | ||
|
|
e46a8296cb | ||
|
|
27367e9064 | ||
|
|
28be6933c8 | ||
|
|
92166f0fa2 | ||
|
|
8e7c4f54a3 | ||
|
|
938a951737 | ||
|
|
76386e419c | ||
|
|
b13f9bf989 | ||
|
|
8a06157ff4 | ||
|
|
75e2177da7 | ||
|
|
61f90352c4 | ||
|
|
087fbbfb45 |
462 changed files with 8690 additions and 6403 deletions
|
|
@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt"
|
||||||
- Never use plain `androidx.compose` dependencies in `commonMain`.
|
- Never use plain `androidx.compose` dependencies in `commonMain`.
|
||||||
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
|
- 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()`.
|
- 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.
|
- 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
Normal file
12
.github/lsp.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"lspServers": {
|
||||||
|
"kotlin": {
|
||||||
|
"command": "kotlin-language-server",
|
||||||
|
"args": [],
|
||||||
|
"fileExtensions": {
|
||||||
|
".kt": "kotlin",
|
||||||
|
".kts": "kotlin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
.github/renovate.json
vendored
226
.github/renovate.json
vendored
|
|
@ -49,236 +49,24 @@
|
||||||
"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 all AndroidX dependencies (excluding more specific AndroidX groups)",
|
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
|
||||||
"groupName": "AndroidX (General)",
|
"groupName": "compose-multiplatform",
|
||||||
"groupSlug": "androidx-general",
|
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"/^androidx\\./",
|
"/^org\\.jetbrains\\.compose/",
|
||||||
"!/^androidx\\.room/",
|
"androidx.compose.runtime:runtime-tracing",
|
||||||
"!/^androidx\\.lifecycle/",
|
"androidx.compose.ui:ui-test-manifest"
|
||||||
"!/^androidx\\.navigation/",
|
|
||||||
"!/^androidx\\.datastore/",
|
|
||||||
"!/^androidx\\.compose\\.material3\\.adaptive/",
|
|
||||||
"!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
|
|
||||||
"!/^androidx\\.test\\.espresso/",
|
|
||||||
"!/^androidx\\.test\\.ext/",
|
|
||||||
"!/^androidx\\.hilt/"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Group JetBrains Compose Multiplatform plugin and libraries (separate versioning from AndroidX Compose)",
|
"description": "Restrict sensitive infrastructure to manual minor updates",
|
||||||
"groupName": "Compose Multiplatform (JetBrains)",
|
|
||||||
"groupSlug": "compose-multiplatform",
|
|
||||||
"matchPackageNames": [
|
|
||||||
"/^org\\.jetbrains\\.compose/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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/"
|
|
||||||
],
|
|
||||||
"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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
run: ./gradlew dokkaGeneratePublicationHtml
|
run: ./gradlew dokkaGeneratePublicationHtml
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: build/dokka/html
|
path: build/dokka/html
|
||||||
|
|
||||||
|
|
|
||||||
14
.github/workflows/models_pr_triage.yml
vendored
14
.github/workflows/models_pr_triage.yml
vendored
|
|
@ -44,13 +44,16 @@ 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: ${{ github.event.pull_request.title }}
|
Title: ${{ env.PR_TITLE }}
|
||||||
Body: ${{ github.event.pull_request.body }}
|
Body: ${{ env.PR_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.
|
||||||
|
|
@ -94,6 +97,9 @@ 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: |
|
||||||
|
|
@ -105,8 +111,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: ${{ github.event.pull_request.title }}
|
Title: ${{ env.PR_TITLE }}
|
||||||
Body: ${{ github.event.pull_request.body }}
|
Body: ${{ env.PR_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
|
||||||
|
|
||||||
|
|
|
||||||
7
.github/workflows/pull-request.yml
vendored
7
.github/workflows/pull-request.yml
vendored
|
|
@ -3,10 +3,6 @@ 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
|
||||||
|
|
@ -74,8 +70,7 @@ jobs:
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed_extra_roots = {'baselineprofile'}
|
allowed_extra_roots = {'baselineprofile'}
|
||||||
excluded_roots = {'mesh_service_example'}
|
expected_roots = module_roots | allowed_extra_roots
|
||||||
expected_roots = (module_roots | allowed_extra_roots) - excluded_roots
|
|
||||||
|
|
||||||
filter_paths = {
|
filter_paths = {
|
||||||
path.split('/')[0]
|
path.split('/')[0]
|
||||||
|
|
|
||||||
2
.github/workflows/reusable-check.yml
vendored
2
.github/workflows/reusable-check.yml
vendored
|
|
@ -213,7 +213,7 @@ jobs:
|
||||||
files: "**/build/test-results/**/*.xml"
|
files: "**/build/test-results/**/*.xml"
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && inputs.run_coverage }}
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,3 +55,4 @@ wireless-install.sh
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
.agent_plans/
|
.agent_plans/
|
||||||
.agent_refs/
|
.agent_refs/
|
||||||
|
.agent_artifacts/
|
||||||
|
|
|
||||||
295
.pr5167.diff
Normal file
295
.pr5167.diff
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000..2a27b96906
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
||||||
|
@@ -0,0 +1,39 @@
|
||||||
|
+/*
|
||||||
|
+ * Copyright (c) 2026 Meshtastic LLC
|
||||||
|
+ *
|
||||||
|
+ * This program is free software: you can redistribute it and/or modify
|
||||||
|
+ * it under the terms of the GNU General Public License as published by
|
||||||
|
+ * the Free Software Foundation, either version 3 of the License, or
|
||||||
|
+ * (at your option) any later version.
|
||||||
|
+ *
|
||||||
|
+ * This program is distributed in the hope that it will be useful,
|
||||||
|
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
+ * GNU General Public License for more details.
|
||||||
|
+ *
|
||||||
|
+ * You should have received a copy of the GNU General Public License
|
||||||
|
+ * along with this program. If not, see <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,16 +1,7 @@
|
||||||
# Skill: Code Review
|
# Skill: Code Review
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
Perform comprehensive and precise code reviews for the `Meshtastic-Android` project. This skill ensures that incoming changes adhere strictly to the project's architecture guidelines, Kotlin Multiplatform (KMP) conventions, Modern Android Development (MAD) standards, and Jetpack Compose Multiplatform (CMP) best practices.
|
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
|
||||||
|
|
||||||
## Context & Prerequisites
|
|
||||||
The `Meshtastic-Android` codebase is a highly modernized Kotlin Multiplatform (KMP) application designed for off-grid, decentralized mesh networks.
|
|
||||||
- **Language:** Kotlin (primary), JDK 21 required.
|
|
||||||
- **Architecture:** KMP core with Android and Desktop host shells.
|
|
||||||
- **UI:** Jetpack Compose Multiplatform (CMP) and Material 3 Adaptive.
|
|
||||||
- **Navigation:** JetBrains Navigation 3 (Scene-based).
|
|
||||||
- **DI:** Koin Annotations (with K2 compiler plugin).
|
|
||||||
- **Async & I/O:** Kotlin Coroutines, Flow, Okio, Ktor.
|
|
||||||
|
|
||||||
## Code Review Checklist
|
## Code Review Checklist
|
||||||
|
|
||||||
|
|
@ -22,14 +13,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi
|
||||||
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
|
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
|
||||||
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
|
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
|
||||||
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
|
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
|
||||||
- `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` or `expect`/`actual`
|
- `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`.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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)
|
### 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.
|
- [ ] **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`.
|
- [ ] **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`.
|
- [ ] **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.
|
- [ ] **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).
|
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
|
||||||
|
|
@ -45,8 +37,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi
|
||||||
|
|
||||||
### 5. Networking, DB & I/O
|
### 5. Networking, DB & I/O
|
||||||
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
|
- [ ] **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.
|
- [ ] **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 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.
|
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
|
||||||
|
|
||||||
### 6. Dependency Catalog Aliases
|
### 6. Dependency Catalog Aliases
|
||||||
|
|
@ -56,11 +50,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi
|
||||||
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
|
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
|
||||||
|
|
||||||
### 7. Testing
|
### 7. Testing
|
||||||
- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
|
- [ ] **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`.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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
|
## Review Output Guidelines
|
||||||
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
|
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").
|
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").
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,31 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
|
||||||
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
|
- **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.
|
- **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).
|
- **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`).
|
- **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.
|
- **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:**
|
- **Workflow to Add a String:**
|
||||||
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
|
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
|
||||||
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
|
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
|
||||||
|
|
@ -25,6 +48,13 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
|
||||||
- **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.
|
- **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.
|
- **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
|
## Reference Anchors
|
||||||
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
|
- **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`
|
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,9 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
|
||||||
### 6. Verify Locally
|
### 6. Verify Locally
|
||||||
- Run the baseline checks (see `testing-ci` skill):
|
- Run the baseline checks (see `testing-ci` skill):
|
||||||
```bash
|
```bash
|
||||||
./gradlew spotlessCheck detekt assembleDebug test allTests
|
./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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
|
||||||
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
|
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
|
||||||
|
|
||||||
## 3. Core Libraries & Constraints
|
## 3. Core Libraries & Constraints
|
||||||
- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly.
|
- **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:**
|
- **Standard Library Replacements:**
|
||||||
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
|
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
|
||||||
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
|
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
|
||||||
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
|
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
|
||||||
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
|
- **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**.
|
- **BLE:** Route through `core:ble` using **Kable**.
|
||||||
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
|
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
|
||||||
|
|
||||||
|
|
@ -38,6 +40,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
|
||||||
## 6. I/O & Serialization
|
## 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.
|
- **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 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
|
## 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.
|
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,27 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
|
||||||
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
|
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
|
||||||
|
|
||||||
### Anti-Patterns
|
### Anti-Patterns
|
||||||
- **A1 Module Compile Safety:** Do **not** enable A1 `compileSafety`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) because of our decoupled Clean Architecture design (interfaces in one module, implemented in another).
|
- **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.
|
- **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
|
## Navigation 3
|
||||||
|
|
||||||
### Guidelines
|
### Guidelines
|
||||||
|
|
@ -32,6 +50,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
|
||||||
|
|
||||||
## Reference Anchors
|
## Reference Anchors
|
||||||
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
|
- **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`
|
- **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`
|
- **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`
|
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
|
||||||
|
|
|
||||||
79
.skills/new-branch/SKILL.md
Normal file
79
.skills/new-branch/SKILL.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Skill: New Branch Bootstrap
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
|
||||||
|
whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
|
||||||
|
branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
|
||||||
|
|
||||||
|
This replaces the ad-hoc prose that used to be retyped at the start of every session.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
- Starting any new feature, fix, chore, or refactor.
|
||||||
|
- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
|
||||||
|
- Reproducing a CI failure from a clean baseline.
|
||||||
|
|
||||||
|
## Preconditions (verify before branching)
|
||||||
|
1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
|
||||||
|
2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
|
||||||
|
`meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
|
||||||
|
3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
|
||||||
|
workspace bootstrap rules.
|
||||||
|
4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
|
||||||
|
(required for `google` flavor builds).
|
||||||
|
|
||||||
|
## Standard Recipe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fetch latest upstream
|
||||||
|
git fetch upstream --prune --tags
|
||||||
|
|
||||||
|
# 2. Create the branch from upstream/main (never from a local stale main)
|
||||||
|
git switch -c <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,21 +1,13 @@
|
||||||
# Skill: Project Overview & Codebase Map
|
# Skill: Project Overview & Codebase Map
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
High-level project context, module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
|
Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
|
||||||
|
|
||||||
## 1. Project Vision & Architecture
|
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
|
||||||
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 expansion to iOS and Desktop while maintaining a high-performance native Android experience.
|
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
|
||||||
|
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
|
||||||
|
|
||||||
- **Language:** Kotlin (primary), AIDL.
|
## Codebase Map
|
||||||
- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED.
|
|
||||||
- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
|
|
||||||
- **Flavors:**
|
|
||||||
- `fdroid`: Open source only, no tracking/analytics.
|
|
||||||
- `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production").
|
|
||||||
- **KMP Modules:** Most `core:*` modules declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
|
|
||||||
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
|
|
||||||
|
|
||||||
## 2. Codebase Map
|
|
||||||
|
|
||||||
| Directory | Description |
|
| Directory | Description |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
|
|
@ -47,13 +39,12 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
|
| `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. |
|
| `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`. |
|
| `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`. |
|
||||||
| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app. See `core/api/README.md` for the current integration guide. |
|
|
||||||
|
|
||||||
## 3. Namespacing
|
## Namespacing
|
||||||
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
|
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
|
||||||
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
|
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
|
||||||
|
|
||||||
## 4. Environment Setup
|
## Environment Setup
|
||||||
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
|
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
|
||||||
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
|
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
|
||||||
```properties
|
```properties
|
||||||
|
|
@ -62,7 +53,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
datadogClientToken=dummy_token
|
datadogClientToken=dummy_token
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Workspace Bootstrap (MUST run before any build)
|
## 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.
|
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:
|
1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
|
||||||
|
|
@ -81,17 +72,12 @@ Agents **MUST** perform these steps automatically at the start of every session
|
||||||
git submodule update --init
|
git submodule update --init
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Troubleshooting
|
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
|
||||||
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
|
```bash
|
||||||
- **Missing Secrets:** Check `local.properties` (see Environment Setup above).
|
[ -f local.properties ] || cp secrets.defaults.properties local.properties
|
||||||
- **JDK Version:** JDK 21 is required.
|
```
|
||||||
- **SDK location not found:** See Workspace Bootstrap step 1 above.
|
|
||||||
- **Proto generation failures:** See Workspace Bootstrap step 2 above.
|
|
||||||
- **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`).
|
|
||||||
|
|
||||||
## Reference Anchors
|
## Troubleshooting
|
||||||
- **KMP Migration Status:** `docs/kmp-status.md`
|
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
|
||||||
- **Roadmap:** `docs/roadmap.md`
|
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
|
||||||
- **Architecture Decision Records:** `docs/decisions/`
|
- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.
|
||||||
- **Version Catalog:** `gradle/libs.versions.toml`
|
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,14 @@ Guidelines and commands for verifying code changes locally and understanding the
|
||||||
|
|
||||||
## 1) Baseline local verification order
|
## 1) Baseline local verification order
|
||||||
|
|
||||||
Run in this order for routine changes to ensure code formatting, analysis, and basic compilation:
|
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew clean
|
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
|
||||||
./gradlew spotlessCheck
|
|
||||||
./gradlew spotlessApply
|
|
||||||
./gradlew detekt
|
|
||||||
./gradlew assembleDebug
|
|
||||||
./gradlew 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`:**
|
> **Why `test allTests` and not just `test`:**
|
||||||
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
|
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
|
||||||
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
|
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
|
||||||
|
|
@ -51,7 +48,7 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
|
||||||
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
|
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-core`: `allTests` for all `core:*` KMP modules.
|
||||||
- `shard-feature`: `allTests` for all `feature:*` 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`).
|
- `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.
|
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.
|
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`).
|
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
|
||||||
|
|
@ -86,11 +83,3 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
|
||||||
- **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.).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
## 5) Shell & Tooling Conventions
|
|
||||||
- **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.
|
|
||||||
- **Text Search:** Prefer `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase.
|
|
||||||
|
|
||||||
## 6) Agent/Developer Guidance
|
|
||||||
- Start with the smallest set that validates your touched area.
|
|
||||||
- If unable to run full validation locally, report exactly what ran and what remains.
|
|
||||||
- Keep documentation synced in `AGENTS.md` and `.skills/` directories.
|
|
||||||
|
|
|
||||||
54
AGENTS.md
54
AGENTS.md
|
|
@ -18,6 +18,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
|
||||||
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
|
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
|
||||||
- `.skills/implement-feature/` - Step-by-step feature workflow.
|
- `.skills/implement-feature/` - Step-by-step feature workflow.
|
||||||
- `.skills/code-review/` - PR validation checklist.
|
- `.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.
|
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
|
||||||
</context_and_memory>
|
</context_and_memory>
|
||||||
|
|
||||||
|
|
@ -25,11 +26,16 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
|
||||||
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
|
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
|
||||||
1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
|
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.
|
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.
|
- **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.
|
- **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).
|
- **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:
|
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
|
||||||
`./gradlew clean spotlessCheck spotlessApply detekt assembleDebug test allTests`
|
```
|
||||||
|
./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>
|
</process>
|
||||||
|
|
||||||
<agent_tools>
|
<agent_tools>
|
||||||
|
|
@ -52,13 +58,51 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
|
||||||
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
|
- `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.
|
- `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/`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed.
|
Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
|
||||||
</documentation_sync>
|
</documentation_sync>
|
||||||
|
|
||||||
<rules>
|
<rules>
|
||||||
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
|
- **No 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`.
|
- **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.
|
- **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 (e.g., no float formatting in `stringResource`).
|
- **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.
|
- **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>
|
</rules>
|
||||||
|
|
||||||
|
<copilot_cli_workflow>
|
||||||
|
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
|
||||||
|
section.
|
||||||
|
|
||||||
|
- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet"
|
||||||
|
prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*,
|
||||||
|
*"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub
|
||||||
|
cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive
|
||||||
|
session on work that can run unattended.
|
||||||
|
- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"*
|
||||||
|
on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep
|
||||||
|
research across GitHub and the web with better source grounding than an ad-hoc prompt.
|
||||||
|
- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation
|
||||||
|
plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to
|
||||||
|
plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent
|
||||||
|
from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to
|
||||||
|
`.agent_plans/` (git-ignored) for multi-module refactors.
|
||||||
|
- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle
|
||||||
|
quality passes, offer `/share` to export the findings to a gist or markdown file. These
|
||||||
|
reports are valuable artifacts — don't let them die in session history.
|
||||||
|
- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts
|
||||||
|
file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration.
|
||||||
|
Avoid re-issuing the same prompt verbatim.
|
||||||
|
- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main"
|
||||||
|
or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe.
|
||||||
|
</copilot_cli_workflow>
|
||||||
|
|
||||||
|
<git_and_prs>
|
||||||
|
- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
|
||||||
|
- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
|
||||||
|
- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
|
||||||
|
</git_and_prs>
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@
|
||||||
|
|
||||||
- **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.
|
- **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.
|
- **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).
|
- **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.
|
||||||
|
|
|
||||||
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.8.8)
|
addressable (2.9.0)
|
||||||
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.1213.0)
|
aws-partitions (1.1240.0)
|
||||||
aws-sdk-core (3.242.0)
|
aws-sdk-core (3.245.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.121.0)
|
aws-sdk-kms (1.123.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.213.0)
|
aws-sdk-s3 (1.219.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
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.0.1)
|
bigdecimal (4.1.2)
|
||||||
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.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.232.2)
|
fastlane (2.233.0)
|
||||||
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.0.0)
|
fastlane-sirp (>= 1.1.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,10 +122,9 @@ 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.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.95.0)
|
google-apis-androidpublisher_v3 (0.99.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.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)
|
||||||
|
|
@ -139,15 +138,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.59.0)
|
google-apis-storage_v1 (0.61.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.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.58.0)
|
google-cloud-storage (1.59.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)
|
||||||
|
|
@ -169,13 +168,13 @@ GEM
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.19.4)
|
||||||
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.19.1)
|
multi_json (1.20.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)
|
||||||
|
|
@ -185,13 +184,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.2)
|
public_suffix (7.0.5)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
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.1.2)
|
retriable (3.4.1)
|
||||||
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)
|
||||||
|
|
@ -205,7 +204,6 @@ 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
31
SOUL.md
|
|
@ -1,31 +0,0 @@
|
||||||
# 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 `.skills/` directory.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -171,8 +171,6 @@ configure<ApplicationExtension> {
|
||||||
} else {
|
} else {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +264,6 @@ 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)
|
||||||
|
|
@ -282,7 +279,6 @@ 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)
|
||||||
|
|
|
||||||
84
app/proguard-rules.pro
vendored
84
app/proguard-rules.pro
vendored
|
|
@ -1,61 +1,45 @@
|
||||||
# Add project specific ProGuard rules here.
|
# ============================================================================
|
||||||
# You can control the set of applied configuration files using the
|
# Meshtastic Android — ProGuard / R8 rules for release minification
|
||||||
# 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.
|
||||||
#
|
#
|
||||||
# For more details, see
|
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room,
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3,
|
||||||
|
# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
|
||||||
|
# config/proguard/shared-rules.pro and are wired in by the
|
||||||
|
# AndroidApplicationConventionPlugin. This file holds only Android-specific
|
||||||
|
# rules and R8-only directives.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
# ---- General ----------------------------------------------------------------
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
# Open-source — no need to obfuscate
|
||||||
# debugging stack traces.
|
-dontobfuscate
|
||||||
-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# Disable R8 optimization passes. Tree-shaking (unused code removal) still
|
||||||
# hide the original source file name.
|
# runs — only method-body rewrites and call-site transformations are suppressed.
|
||||||
#-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
|
||||||
|
|
||||||
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
|
# Dump the full merged R8 configuration (app rules + all library consumer rules)
|
||||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
# for auditing. Inspect this file after a release build to see what libraries inject.
|
||||||
|
-printconfiguration build/outputs/mapping/r8-merged-config.txt
|
||||||
|
|
||||||
# Needed for protobufs
|
# ---- Networking (transitive references from Ktor on Android) ----------------
|
||||||
-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
|
||||||
-dontwarn java.lang.reflect.**
|
# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
|
||||||
-dontwarn com.google.errorprone.annotations.**
|
# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.
|
||||||
|
|
||||||
# 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.** { *; }
|
|
||||||
|
|
|
||||||
|
|
@ -861,15 +861,9 @@ 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)) } },
|
||||||
) {
|
) {
|
||||||
Text(
|
val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
|
||||||
modifier = Modifier.padding(16.dp),
|
val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
|
||||||
text =
|
Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
|
||||||
stringResource(
|
|
||||||
Res.string.map_cache_info,
|
|
||||||
cacheCapacity / (1024.0 * 1024.0),
|
|
||||||
currentCacheUsage / (1024.0 * 1024.0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ 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
|
||||||
|
|
@ -160,7 +159,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ 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 kotlinx.coroutines.Dispatchers
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
@ -45,6 +49,7 @@ 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
|
||||||
|
|
@ -77,6 +82,8 @@ 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,
|
||||||
|
|
@ -404,7 +411,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) {
|
||||||
|
|
@ -412,32 +419,33 @@ class MapViewModel(
|
||||||
|
|
||||||
if (persistedLayerFiles != null) {
|
if (persistedLayerFiles != null) {
|
||||||
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
||||||
val loadedItems = persistedLayerFiles.mapNotNull { file ->
|
val loadedItems =
|
||||||
if (file.isFile) {
|
persistedLayerFiles.mapNotNull { file ->
|
||||||
val layerType =
|
if (file.isFile) {
|
||||||
when (file.extension.lowercase()) {
|
val layerType =
|
||||||
"kml",
|
when (file.extension.lowercase()) {
|
||||||
"kmz",
|
"kml",
|
||||||
-> LayerType.KML
|
"kmz",
|
||||||
"geojson",
|
-> LayerType.KML
|
||||||
"json",
|
"geojson",
|
||||||
-> LayerType.GEOJSON
|
"json",
|
||||||
else -> null
|
-> LayerType.GEOJSON
|
||||||
}
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
layerType?.let {
|
layerType?.let {
|
||||||
val uri = Uri.fromFile(file)
|
val uri = Uri.fromFile(file)
|
||||||
MapLayerItem(
|
MapLayerItem(
|
||||||
name = file.nameWithoutExtension,
|
name = file.nameWithoutExtension,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||||
layerType = it,
|
layerType = it,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val networkItems =
|
val networkItems =
|
||||||
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
||||||
|
|
@ -550,7 +558,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")
|
||||||
|
|
@ -621,7 +629,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()) {
|
||||||
|
|
@ -636,11 +644,15 @@ 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 url = java.net.URL(uriToLoad.toString())
|
val response = httpClient.get(uriToLoad.toString())
|
||||||
java.io.BufferedInputStream(url.openStream())
|
if (!response.status.isSuccess()) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +36,10 @@ class GoogleMapsKoinModule {
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
@Named("GoogleMapsDataStore")
|
@Named("GoogleMapsDataStore")
|
||||||
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
|
||||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
PreferenceDataStoreFactory.create(
|
||||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
|
||||||
)
|
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/local_stats_widget_info" />
|
android:resource="@xml/widget_local_stats_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- allow for plugin discovery -->
|
<!-- allow for plugin discovery -->
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"alpha": [
|
"alpha": [
|
||||||
|
{
|
||||||
|
"id": "v2.7.22.96dd647",
|
||||||
|
"title": "Meshtastic Firmware 2.7.22.96dd647 Alpha",
|
||||||
|
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647",
|
||||||
|
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json",
|
||||||
|
"release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "v2.7.21.1370b23",
|
"id": "v2.7.21.1370b23",
|
||||||
"title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
|
"title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
|
||||||
|
|
@ -177,13 +184,6 @@
|
||||||
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7",
|
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7",
|
||||||
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip",
|
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip",
|
||||||
"release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7"
|
"release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "v2.6.7.2d6181f",
|
|
||||||
"title": "Meshtastic Firmware 2.6.7.2d6181f Alpha",
|
|
||||||
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f",
|
|
||||||
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip",
|
|
||||||
"release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ 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
|
||||||
|
|
@ -57,8 +58,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
|
||||||
|
|
@ -91,6 +92,8 @@ 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.
|
||||||
|
|
@ -124,6 +127,8 @@ 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) {
|
||||||
|
|
@ -141,7 +146,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
AppCompositionLocals {
|
AppCompositionLocals {
|
||||||
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
|
||||||
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"
|
||||||
|
|
@ -164,6 +169,16 @@ 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(
|
||||||
|
|
@ -255,6 +270,11 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +296,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.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createShareIntent(message: String): PendingIntent {
|
private fun createShareIntent(message: String): PendingIntent {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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
|
||||||
|
|
@ -36,9 +37,8 @@ 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.core.context.startKoin
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
import org.meshtastic.app.di.AppKoinModule
|
import org.meshtastic.app.di.AndroidKoinApp
|
||||||
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,16 +57,15 @@ open class MeshUtilApplication :
|
||||||
Application(),
|
Application(),
|
||||||
Configuration.Provider {
|
Configuration.Provider {
|
||||||
|
|
||||||
private val applicationScope = CoroutineScope(Dispatchers.Default)
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ContextServices.app = this
|
ContextServices.app = this
|
||||||
|
|
||||||
startKoin {
|
startKoin<AndroidKoinApp> {
|
||||||
androidContext(this@MeshUtilApplication)
|
androidContext(this@MeshUtilApplication)
|
||||||
workManagerFactory()
|
workManagerFactory()
|
||||||
modules(AppKoinModule().module())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule periodic MeshLog cleanup
|
// Schedule periodic MeshLog cleanup
|
||||||
|
|
|
||||||
|
|
@ -14,16 +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.core.common.util
|
package org.meshtastic.app.di
|
||||||
|
|
||||||
import kotlin.test.Test
|
import org.koin.core.annotation.KoinApplication
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class MeshtasticUriTest {
|
/**
|
||||||
@Test
|
* Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when
|
||||||
fun testParseAndToString() {
|
* [org.koin.plugin.module.dsl.startKoin] is called with this type parameter.
|
||||||
val uriString = "content://com.example.provider/file.txt"
|
*/
|
||||||
val uri = MeshtasticUri.parse(uriString)
|
@KoinApplication(modules = [AppKoinModule::class])
|
||||||
assertEquals(uriString, uri.toString())
|
object AndroidKoinApp
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,6 +24,8 @@ 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
|
||||||
|
|
@ -31,19 +33,25 @@ 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
|
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 {
|
||||||
|
|
@ -64,7 +72,12 @@ class NetworkModule {
|
||||||
buildConfigProvider: BuildConfigProvider,
|
buildConfigProvider: BuildConfigProvider,
|
||||||
): ImageLoader = ImageLoader.Builder(context = application)
|
): ImageLoader = ImageLoader.Builder(context = application)
|
||||||
.components {
|
.components {
|
||||||
add(KtorNetworkFetcherFactory(httpClient = httpClient))
|
add(
|
||||||
|
KtorNetworkFetcherFactory(
|
||||||
|
httpClient = httpClient,
|
||||||
|
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(SvgDecoder.Factory(scaleToDensity = true))
|
add(SvgDecoder.Factory(scaleToDensity = true))
|
||||||
}
|
}
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
|
|
@ -77,6 +90,7 @@ 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()
|
||||||
|
|
||||||
|
|
@ -84,6 +98,16 @@ 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) {
|
||||||
logger = KermitHttpLogger
|
logger = KermitHttpLogger
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ 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
|
||||||
|
|
@ -60,4 +61,19 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ 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 Meshtastic LLC
|
* Copyright (c) 2025-2026 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,7 +14,6 @@
|
||||||
* 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
|
||||||
|
|
@ -26,7 +25,6 @@ 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")
|
||||||
|
|
@ -39,15 +37,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
extensions.configure<ApplicationExtension> {
|
extensions.configure<ApplicationExtension> {
|
||||||
configureKotlinAndroid(this)
|
configureKotlinAndroid(this)
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig { vectorDrawables.useSupportLibrary = true }
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
animationsDisabled = true
|
|
||||||
unitTests.isReturnDefaultValues = true
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
|
|
@ -55,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
rootProject.file("config/proguard/shared-rules.pro"),
|
||||||
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
|
@ -67,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures { buildConfig = true }
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
configureTestOptions()
|
configureTestOptions()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,6 @@ 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),
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,18 @@ 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 {
|
||||||
// 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("compose-multiplatform-material3"))
|
|
||||||
|
|
||||||
implementation(libs.library("compose-multiplatform-ui"))
|
implementation(libs.library("compose-multiplatform-ui"))
|
||||||
implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
|
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,12 @@ 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.getByName("commonMain").dependencies {
|
sourceSets.matching { it.name == "commonMain" }.configureEach {
|
||||||
implementation(libs.library("compose-multiplatform-runtime"))
|
dependencies {
|
||||||
// API because consuming modules will usually need the resource types
|
implementation(libs.library("compose-multiplatform-runtime"))
|
||||||
api(libs.library("compose-multiplatform-resources"))
|
// API because consuming modules will usually need the resource types
|
||||||
|
api(libs.library("compose-multiplatform-resources"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
configureComposeCompiler()
|
configureComposeCompiler()
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
* 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
|
||||||
|
|
@ -39,8 +37,6 @@ 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,11 +29,12 @@ 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 heavily utilizes dependency inversion across KMP modules. Koin's A1
|
// Meshtastic uses dependency inversion across KMP modules — interfaces in
|
||||||
// per-module safety checks strictly enforce that all dependencies must be explicitly
|
// commonMain, implementations wired at the composition root. Koin's compileSafety
|
||||||
// provided or included locally. This breaks decoupled Clean Architecture designs.
|
// flag enables A1 per-module checks that treat every module as self-contained,
|
||||||
// We disable compile safety globally to properly rely on Koin's A3 full-graph
|
// which breaks this pattern. There is no separate flag for A3 full-graph
|
||||||
// validation which perfectly handles inverted dependencies at the composition root.
|
// validation. Until Koin exposes granular safety levels we keep this disabled;
|
||||||
|
// runtime graph verification is handled by KoinVerificationTest instead.
|
||||||
compileSafety.set(false)
|
compileSafety.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 listOf("api", "model", "proto")) {
|
val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
|
||||||
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,6 +72,23 @@ 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()
|
||||||
|
|
@ -190,11 +207,25 @@ 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 (project.name in listOf("api", "model", "proto")) 17 else 21
|
val javaVersion = if (isPublishedModule) 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)
|
||||||
|
|
@ -208,14 +239,7 @@ 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(
|
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
|
||||||
"-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")
|
||||||
}
|
}
|
||||||
|
|
@ -230,21 +254,13 @@ 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(
|
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
|
||||||
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
freeCompilerArgs.add("-jvm-default=no-compatibility")
|
||||||
"-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.0")
|
id("com.gradle.develocity") version("4.4.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,6 @@ component_management:
|
||||||
name: Desktop
|
name: Desktop
|
||||||
paths:
|
paths:
|
||||||
- desktop/**
|
- desktop/**
|
||||||
- component_id: example
|
|
||||||
name: Example
|
|
||||||
paths:
|
|
||||||
- mesh_service_example/**
|
|
||||||
|
|
||||||
ignore:
|
ignore:
|
||||||
- "**/build/**"
|
- "**/build/**"
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# 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/)
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Project Tracks
|
|
||||||
|
|
||||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
# 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
|
|
||||||
166
config/proguard/shared-rules.pro
Normal file
166
config/proguard/shared-rules.pro
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# ============================================================================
|
||||||
|
# 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.** { *; }
|
||||||
|
|
@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
|
||||||
/**
|
/**
|
||||||
* Classification of a BLE-layer exception for the transport layer to act on.
|
* Classification of a BLE-layer exception for the transport layer to act on.
|
||||||
*
|
*
|
||||||
* @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
|
* @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 gattStatus the platform GATT status code when available (Android-specific).
|
||||||
* @property message a human-readable description of the failure.
|
* @property message a human-readable description of the failure.
|
||||||
*/
|
*/
|
||||||
|
|
@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
|
||||||
is GattRequestRejectedException ->
|
is GattRequestRejectedException ->
|
||||||
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
|
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
|
||||||
is UnmetRequirementException ->
|
is UnmetRequirementException ->
|
||||||
BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
|
// 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
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,7 @@ 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) {
|
Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
|
||||||
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
|
|
||||||
}
|
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
|
||||||
- **Time**: Utilities for handling timestamps and durations.
|
- **Time**: Utilities for handling timestamps and durations.
|
||||||
- **Exceptions**: Standardized exception types for common error scenarios.
|
- **Exceptions**: Standardized exception types for common error scenarios.
|
||||||
|
|
||||||
### 2. `ByteUtils.kt`
|
### 2. `MetricFormatter.kt`
|
||||||
Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
|
Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces.
|
||||||
|
|
||||||
### 3. `BuildConfigProvider.kt`
|
### 3. `BuildConfigProvider.kt`
|
||||||
An interface for accessing build-time configuration in a multiplatform-friendly way.
|
An interface for accessing build-time configuration in a multiplatform-friendly way.
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ kotlin {
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.datetime)
|
api(libs.kotlinx.datetime)
|
||||||
api(libs.okio)
|
api(libs.okio)
|
||||||
|
api(libs.uri.kmp)
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
}
|
}
|
||||||
androidMain.dependencies { api(libs.androidx.core.ktx) }
|
androidMain.dependencies { api(libs.androidx.core.ktx) }
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.meshtastic.core.common.util
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
actual class CommonUri(private val uri: Uri) {
|
|
||||||
actual val host: String?
|
|
||||||
get() = uri.host
|
|
||||||
|
|
||||||
actual val fragment: String?
|
|
||||||
get() = uri.fragment
|
|
||||||
|
|
||||||
actual val pathSegments: List<String>
|
|
||||||
get() = uri.pathSegments
|
|
||||||
|
|
||||||
actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
|
|
||||||
|
|
||||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
|
|
||||||
uri.getBooleanQueryParameter(key, defaultValue)
|
|
||||||
|
|
||||||
actual override fun toString(): String = uri.toString()
|
|
||||||
|
|
||||||
actual companion object {
|
|
||||||
actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toUri(): Uri = uri
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun CommonUri.toPlatformUri(): Any = this.toUri()
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.meshtastic.core.common
|
|
||||||
|
|
||||||
/** Utility function to make it easy to declare byte arrays */
|
|
||||||
fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
|
|
||||||
|
|
||||||
fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
|
|
||||||
|
|
||||||
private const val BYTE_MASK = 0xff
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2026 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
||||||
|
|
@ -17,13 +17,14 @@
|
||||||
package org.meshtastic.core.common.util
|
package org.meshtastic.core.common.util
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
|
* Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
|
||||||
* modules without coupling them to the android.net.Uri class.
|
* blank, or sentinel values (`"N"`, `"NULL"`).
|
||||||
*/
|
*/
|
||||||
data class MeshtasticUri(val uriString: String) {
|
fun normalizeAddress(addr: String?): String {
|
||||||
override fun toString(): String = uriString
|
val u = addr?.trim()?.uppercase()
|
||||||
|
return when {
|
||||||
companion object {
|
u.isNullOrBlank() -> "DEFAULT"
|
||||||
fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
|
u == "N" || u == "NULL" -> "DEFAULT"
|
||||||
|
else -> u.replace(":", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,22 +16,14 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.common.util
|
package org.meshtastic.core.common.util
|
||||||
|
|
||||||
/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
|
import com.eygraber.uri.Uri
|
||||||
expect class CommonUri {
|
|
||||||
val host: String?
|
|
||||||
val fragment: String?
|
|
||||||
val pathSegments: List<String>
|
|
||||||
|
|
||||||
fun getQueryParameter(key: String): String?
|
/**
|
||||||
|
* Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
|
||||||
fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
|
*
|
||||||
|
* This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
|
||||||
override fun toString(): String
|
* identically on Android, JVM, and iOS without platform stubs.
|
||||||
|
*
|
||||||
companion object {
|
* On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
|
||||||
fun parse(uriString: String): CommonUri
|
*/
|
||||||
}
|
typealias CommonUri = Uri
|
||||||
}
|
|
||||||
|
|
||||||
/** Extension to convert platform Uri to CommonUri in Android source sets. */
|
|
||||||
expect fun CommonUri.toPlatformUri(): Any
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package org.meshtastic.core.common.util
|
package org.meshtastic.core.common.util
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
object Exceptions {
|
object Exceptions {
|
||||||
/** Set by the application to provide a custom crash reporting implementation. */
|
/** Set by the application to provide a custom crash reporting implementation. */
|
||||||
|
|
@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Suspend-compatible variant of [ignoreException]. */
|
/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
|
||||||
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
|
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
|
||||||
try {
|
try {
|
||||||
inner()
|
inner()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
Logger.w(ex) { "Ignoring exception" }
|
Logger.w(ex) { "Ignoring exception" }
|
||||||
|
|
@ -69,3 +72,41 @@ fun exceptionReporter(inner: () -> Unit) {
|
||||||
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead
|
||||||
|
* of [runCatching] in coroutine contexts.
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
inline fun <T> safeCatching(block: () -> T): Result<T> = try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
inline fun <T, R> T.safeCatching(block: T.() -> R): Result<R> = try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources'
|
||||||
|
* lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured
|
||||||
|
* concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and
|
||||||
|
* the caller only needs a best-effort fallback.
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
inline fun <T> safeCatchingAll(block: () -> T): Result<T> = try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result.failure(t)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,114 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.common.util
|
package org.meshtastic.core.common.util
|
||||||
|
|
||||||
/** Multiplatform string formatting helper. */
|
/**
|
||||||
expect fun formatString(pattern: String, vararg args: Any?): String
|
* Pure-Kotlin multiplatform string formatting.
|
||||||
|
*
|
||||||
|
* Implements the subset of Java's `String.format()` patterns used in this codebase:
|
||||||
|
* - `%s`, `%d` — positional or sequential string/integer
|
||||||
|
* - `%N$s`, `%N$d` — explicit positional string/integer
|
||||||
|
* - `%N$.Nf`, `%.Nf` — float with decimal precision
|
||||||
|
* - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
|
||||||
|
* - `%%` — literal percent
|
||||||
|
*/
|
||||||
|
@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements")
|
||||||
|
fun formatString(pattern: String, vararg args: Any?): String = buildString {
|
||||||
|
var i = 0
|
||||||
|
var autoIndex = 0
|
||||||
|
while (i < pattern.length) {
|
||||||
|
if (pattern[i] != '%') {
|
||||||
|
append(pattern[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++ // skip '%'
|
||||||
|
if (i >= pattern.length) break
|
||||||
|
|
||||||
|
// Literal %%
|
||||||
|
if (pattern[i] == '%') {
|
||||||
|
append('%')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional positional index (N$)
|
||||||
|
var explicitIndex: Int? = null
|
||||||
|
val startPos = i
|
||||||
|
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||||
|
if (i < pattern.length && pattern[i] == '$' && i > startPos) {
|
||||||
|
explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
|
||||||
|
i++ // skip '$'
|
||||||
|
} else {
|
||||||
|
i = startPos // rewind — digits are part of width/precision, not positional index
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional flags (zero-pad)
|
||||||
|
var zeroPad = false
|
||||||
|
if (i < pattern.length && pattern[i] == '0') {
|
||||||
|
zeroPad = true
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional width
|
||||||
|
var width: Int? = null
|
||||||
|
val widthStart = i
|
||||||
|
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||||
|
if (i > widthStart) {
|
||||||
|
width = pattern.substring(widthStart, i).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional precision (.N)
|
||||||
|
var precision: Int? = null
|
||||||
|
if (i < pattern.length && pattern[i] == '.') {
|
||||||
|
i++ // skip '.'
|
||||||
|
val precStart = i
|
||||||
|
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||||
|
if (i > precStart) {
|
||||||
|
precision = pattern.substring(precStart, i).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse conversion character
|
||||||
|
if (i >= pattern.length) break
|
||||||
|
val conversion = pattern[i]
|
||||||
|
i++
|
||||||
|
|
||||||
|
val argIndex = explicitIndex ?: autoIndex++
|
||||||
|
val arg = args.getOrNull(argIndex)
|
||||||
|
|
||||||
|
when (conversion) {
|
||||||
|
's' -> append(arg?.toString() ?: "null")
|
||||||
|
'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
|
||||||
|
'f' -> {
|
||||||
|
val value = (arg as? Number)?.toDouble() ?: 0.0
|
||||||
|
val places = precision ?: DEFAULT_FLOAT_PRECISION
|
||||||
|
append(NumberFormatter.format(value, places))
|
||||||
|
}
|
||||||
|
'x',
|
||||||
|
'X',
|
||||||
|
-> {
|
||||||
|
val value = (arg as? Number)?.toLong() ?: 0L
|
||||||
|
// Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
|
||||||
|
val masked = if (arg is Int) value and INT_MASK else value
|
||||||
|
var hex = masked.toString(HEX_RADIX)
|
||||||
|
if (conversion == 'X') hex = hex.uppercase()
|
||||||
|
val padChar = if (zeroPad) '0' else ' '
|
||||||
|
val padWidth = width ?: 0
|
||||||
|
append(hex.padStart(padWidth, padChar))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Unknown conversion — reproduce original token
|
||||||
|
append('%')
|
||||||
|
if (explicitIndex != null) append("${explicitIndex + 1}$")
|
||||||
|
if (zeroPad) append('0')
|
||||||
|
if (width != null) append(width)
|
||||||
|
if (precision != null) append(".$precision")
|
||||||
|
append(conversion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_FLOAT_PRECISION = 6
|
||||||
|
private const val HEX_RADIX = 16
|
||||||
|
private const val INT_MASK = 0xFFFFFFFFL
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
|
||||||
* @param value original string value.
|
* @param value original string value.
|
||||||
* @return optimized string value.
|
* @return optimized string value.
|
||||||
*/
|
*/
|
||||||
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
|
fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
|
||||||
val stringBuilder = StringBuilder()
|
for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
|
||||||
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
|
|
||||||
return stringBuilder.toString()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* 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.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
|
||||||
|
* NodeItem, and metric screens.
|
||||||
|
*
|
||||||
|
* All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
|
||||||
|
* for a mesh networking app where consistency matters.
|
||||||
|
*/
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
|
object MetricFormatter {
|
||||||
|
|
||||||
|
fun temperature(celsius: Float, isFahrenheit: Boolean): String {
|
||||||
|
val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
|
||||||
|
val unit = if (isFahrenheit) "°F" else "°C"
|
||||||
|
return "${NumberFormatter.format(value, 1)}$unit"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
|
||||||
|
|
||||||
|
fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
|
||||||
|
"${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
|
||||||
|
|
||||||
|
fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
|
||||||
|
|
||||||
|
fun percent(value: Int): String = "$value%"
|
||||||
|
|
||||||
|
fun humidity(value: Float): String = percent(value, 0)
|
||||||
|
|
||||||
|
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
|
||||||
|
|
||||||
|
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
|
||||||
|
|
||||||
|
fun rssi(value: Int): String = "$value dBm"
|
||||||
|
|
||||||
|
fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String =
|
||||||
|
"${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s"
|
||||||
|
|
||||||
|
fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String =
|
||||||
|
"${NumberFormatter.format(millimeters, decimalPlaces)} mm"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FAHRENHEIT_SCALE = 1.8f
|
||||||
|
private const val FAHRENHEIT_OFFSET = 32
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* 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.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class AddressUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullReturnsDefault() {
|
||||||
|
assertEquals("DEFAULT", normalizeAddress(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blankReturnsDefault() {
|
||||||
|
assertEquals("DEFAULT", normalizeAddress(""))
|
||||||
|
assertEquals("DEFAULT", normalizeAddress(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sentinelNReturnsDefault() {
|
||||||
|
assertEquals("DEFAULT", normalizeAddress("N"))
|
||||||
|
assertEquals("DEFAULT", normalizeAddress("n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sentinelNullReturnsDefault() {
|
||||||
|
assertEquals("DEFAULT", normalizeAddress("NULL"))
|
||||||
|
assertEquals("DEFAULT", normalizeAddress("null"))
|
||||||
|
assertEquals("DEFAULT", normalizeAddress("Null"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripsColons() {
|
||||||
|
assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uppercases() {
|
||||||
|
assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trimsWhitespace() {
|
||||||
|
assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alreadyNormalizedPassesThrough() {
|
||||||
|
assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mixedCaseWithColons() {
|
||||||
|
assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,27 +18,26 @@ package org.meshtastic.core.common.util
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class CommonUriTest {
|
class CommonUriTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testParse() {
|
fun testParseAndToString() {
|
||||||
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment")
|
val uriString = "content://com.example.provider/file.txt"
|
||||||
assertEquals("meshtastic.org", uri.host)
|
val uri = CommonUri.parse(uriString)
|
||||||
assertEquals("fragment", uri.fragment)
|
assertEquals(uriString, uri.toString())
|
||||||
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
|
|
||||||
assertEquals("value1", uri.getQueryParameter("param1"))
|
|
||||||
assertTrue(uri.getBooleanQueryParameter("param2", false))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBooleanParameters() {
|
fun testQueryParameters() {
|
||||||
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
|
val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
|
||||||
assertTrue(uri.getBooleanQueryParameter("t1", false))
|
assertEquals("meshtastic.org", uri.host)
|
||||||
assertTrue(uri.getBooleanQueryParameter("t2", false))
|
assertEquals("key=value&complete=true", uri.fragment)
|
||||||
assertTrue(uri.getBooleanQueryParameter("t3", false))
|
}
|
||||||
assertTrue(!uri.getBooleanQueryParameter("f1", true))
|
|
||||||
assertTrue(!uri.getBooleanQueryParameter("f2", true))
|
@Test
|
||||||
|
fun testFileUri() {
|
||||||
|
val uri = CommonUri.parse("file:///tmp/export.csv")
|
||||||
|
assertEquals("file", uri.scheme)
|
||||||
|
assertEquals("/tmp/export.csv", uri.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,4 +93,48 @@ class FormatStringTest {
|
||||||
fun sequentialFloatSubstitution() {
|
fun sequentialFloatSubstitution() {
|
||||||
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
|
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hex format tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lowercaseHex() {
|
||||||
|
assertEquals("ff", formatString("%x", 255))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uppercaseHex() {
|
||||||
|
assertEquals("FF", formatString("%X", 255))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun zeroPaddedHex() {
|
||||||
|
assertEquals("000000ff", formatString("%08x", 255))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun zeroPaddedHexNodeId() {
|
||||||
|
assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hexZeroValue() {
|
||||||
|
assertEquals("00000000", formatString("%08x", 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun positionalHex() {
|
||||||
|
assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge case tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trailingPercent() {
|
||||||
|
assertEquals("hello", formatString("hello%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun outOfBoundsArgIndex() {
|
||||||
|
assertEquals("null", formatString("%3\$s", "only_one"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
* 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.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class MetricFormatterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun temperatureCelsius() {
|
||||||
|
assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun temperatureFahrenheit() {
|
||||||
|
assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun temperatureNegative() {
|
||||||
|
assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voltage() {
|
||||||
|
assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voltageOneDecimal() {
|
||||||
|
assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun current() {
|
||||||
|
assertEquals("150.3 mA", MetricFormatter.current(150.3f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun percentFloat() {
|
||||||
|
assertEquals("85.5%", MetricFormatter.percent(85.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun percentInt() {
|
||||||
|
assertEquals("85%", MetricFormatter.percent(85))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun humidity() {
|
||||||
|
assertEquals("65%", MetricFormatter.humidity(65.4f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pressure() {
|
||||||
|
assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snr() {
|
||||||
|
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rssi() {
|
||||||
|
assertEquals("-90 dBm", MetricFormatter.rssi(-90))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun temperatureFreezingFahrenheit() {
|
||||||
|
assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun temperatureBoilingFahrenheit() {
|
||||||
|
assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voltageZero() {
|
||||||
|
assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun currentZero() {
|
||||||
|
assertEquals("0.0 mA", MetricFormatter.current(0.0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun percentZero() {
|
||||||
|
assertEquals("0%", MetricFormatter.percent(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun percentHundred() {
|
||||||
|
assertEquals("100%", MetricFormatter.percent(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rssiZero() {
|
||||||
|
assertEquals("0 dBm", MetricFormatter.rssi(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snrNegative() {
|
||||||
|
assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windSpeed() {
|
||||||
|
assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windSpeedZero() {
|
||||||
|
assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rainfall() {
|
||||||
|
assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rainfallZero() {
|
||||||
|
assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,130 +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.common.util
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apple (iOS) implementation of string formatting.
|
|
||||||
*
|
|
||||||
* Implements a subset of Java's `String.format()` patterns used in this codebase:
|
|
||||||
* - `%s`, `%d` — positional or sequential string/integer
|
|
||||||
* - `%N$s`, `%N$d` — explicit positional string/integer
|
|
||||||
* - `%N$.Nf`, `%.Nf` — float with decimal precision
|
|
||||||
* - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
|
|
||||||
* - `%%` — literal percent
|
|
||||||
*
|
|
||||||
* This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
|
|
||||||
*/
|
|
||||||
actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
|
|
||||||
var i = 0
|
|
||||||
var autoIndex = 0
|
|
||||||
while (i < pattern.length) {
|
|
||||||
if (pattern[i] != '%') {
|
|
||||||
append(pattern[i])
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
i++ // skip '%'
|
|
||||||
if (i >= pattern.length) break
|
|
||||||
|
|
||||||
// Literal %%
|
|
||||||
if (pattern[i] == '%') {
|
|
||||||
append('%')
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional positional index (N$)
|
|
||||||
var explicitIndex: Int? = null
|
|
||||||
val startPos = i
|
|
||||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
|
||||||
if (i < pattern.length && pattern[i] == '$' && i > startPos) {
|
|
||||||
explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
|
|
||||||
i++ // skip '$'
|
|
||||||
} else {
|
|
||||||
i = startPos // rewind — digits are part of width/precision, not positional index
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional flags (zero-pad)
|
|
||||||
var zeroPad = false
|
|
||||||
if (i < pattern.length && pattern[i] == '0') {
|
|
||||||
zeroPad = true
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional width
|
|
||||||
var width: Int? = null
|
|
||||||
val widthStart = i
|
|
||||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
|
||||||
if (i > widthStart) {
|
|
||||||
width = pattern.substring(widthStart, i).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional precision (.N)
|
|
||||||
var precision: Int? = null
|
|
||||||
if (i < pattern.length && pattern[i] == '.') {
|
|
||||||
i++ // skip '.'
|
|
||||||
val precStart = i
|
|
||||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
|
||||||
if (i > precStart) {
|
|
||||||
precision = pattern.substring(precStart, i).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse conversion character
|
|
||||||
if (i >= pattern.length) break
|
|
||||||
val conversion = pattern[i]
|
|
||||||
i++
|
|
||||||
|
|
||||||
val argIndex = explicitIndex ?: autoIndex++
|
|
||||||
val arg = args.getOrNull(argIndex)
|
|
||||||
|
|
||||||
when (conversion) {
|
|
||||||
's' -> append(arg?.toString() ?: "null")
|
|
||||||
'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
|
|
||||||
'f' -> {
|
|
||||||
val value = (arg as? Number)?.toDouble() ?: 0.0
|
|
||||||
val places = precision ?: DEFAULT_FLOAT_PRECISION
|
|
||||||
append(NumberFormatter.format(value, places))
|
|
||||||
}
|
|
||||||
'x',
|
|
||||||
'X',
|
|
||||||
-> {
|
|
||||||
val value = (arg as? Number)?.toLong() ?: 0L
|
|
||||||
// Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
|
|
||||||
val masked = if (arg is Int) value and INT_MASK else value
|
|
||||||
var hex = masked.toString(HEX_RADIX)
|
|
||||||
if (conversion == 'X') hex = hex.uppercase()
|
|
||||||
val padChar = if (zeroPad) '0' else ' '
|
|
||||||
val padWidth = width ?: 0
|
|
||||||
append(hex.padStart(padWidth, padChar))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// Unknown conversion — reproduce original token
|
|
||||||
append('%')
|
|
||||||
if (explicitIndex != null) append("${explicitIndex + 1}$")
|
|
||||||
if (zeroPad) append('0')
|
|
||||||
if (width != null) append(width)
|
|
||||||
if (precision != null) append(".$precision")
|
|
||||||
append(conversion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val DEFAULT_FLOAT_PRECISION = 6
|
|
||||||
private const val HEX_RADIX = 16
|
|
||||||
private const val INT_MASK = 0xFFFFFFFFL
|
|
||||||
|
|
@ -22,20 +22,6 @@ actual object BuildUtils {
|
||||||
actual val sdkInt: Int = 0
|
actual val sdkInt: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List<String>) {
|
|
||||||
actual fun getQueryParameter(key: String): String? = null
|
|
||||||
|
|
||||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
|
|
||||||
|
|
||||||
actual override fun toString(): String = ""
|
|
||||||
|
|
||||||
actual companion object {
|
|
||||||
actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun CommonUri.toPlatformUri(): Any = Any()
|
|
||||||
|
|
||||||
actual object DateFormatter {
|
actual object DateFormatter {
|
||||||
actual fun formatRelativeTime(timestampMillis: Long): String = ""
|
actual fun formatRelativeTime(timestampMillis: Long): String = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +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.common.util
|
|
||||||
|
|
||||||
/** JVM/Android implementation of string formatting. */
|
|
||||||
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.meshtastic.core.common.util
|
|
||||||
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
actual class CommonUri(private val uri: URI) {
|
|
||||||
private val queryParameters: Map<String, List<String>> by lazy { parseQueryParameters(uri.rawQuery) }
|
|
||||||
|
|
||||||
actual val host: String?
|
|
||||||
get() = uri.host
|
|
||||||
|
|
||||||
actual val fragment: String?
|
|
||||||
get() = uri.fragment
|
|
||||||
|
|
||||||
actual val pathSegments: List<String>
|
|
||||||
get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
|
|
||||||
|
|
||||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
|
|
||||||
val value = getQueryParameter(key) ?: return defaultValue
|
|
||||||
return value != "false" && value != "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
actual override fun toString(): String = uri.toString()
|
|
||||||
|
|
||||||
actual companion object {
|
|
||||||
actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toUri(): URI = uri
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun CommonUri.toPlatformUri(): Any = this.toUri()
|
|
||||||
|
|
@ -17,9 +17,6 @@
|
||||||
package org.meshtastic.core.common.util
|
package org.meshtastic.core.common.util
|
||||||
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.URLDecoder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
@ -76,7 +73,7 @@ actual object DateFormatter {
|
||||||
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
|
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
|
||||||
|
|
||||||
actual fun formatDateTimeShort(timestampMillis: Long): String =
|
actual fun formatDateTimeShort(timestampMillis: Long): String =
|
||||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
|
shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
|
|
@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun parseQueryParameters(rawQuery: String?): Map<String, List<String>> = rawQuery
|
|
||||||
?.split('&')
|
|
||||||
?.filter { it.isNotBlank() }
|
|
||||||
?.groupBy(
|
|
||||||
keySelector = { segment ->
|
|
||||||
val key = segment.substringBefore('=', missingDelimiterValue = segment)
|
|
||||||
URLDecoder.decode(key, StandardCharsets.UTF_8.name())
|
|
||||||
},
|
|
||||||
valueTransform = { segment ->
|
|
||||||
val value = segment.substringAfter('=', missingDelimiterValue = "")
|
|
||||||
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
|
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
|
||||||
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,63}${'$'}")
|
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,63}${'$'}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class DeviceHardwareJsonDataSourceImpl(private val application: Application) : D
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
|
exceptionsWithDebugInfo = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class FirmwareReleaseJsonDataSourceImpl(private val application: Application) :
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
|
exceptionsWithDebugInfo = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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.data.manager
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.atomicfu.atomic
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import org.meshtastic.core.repository.PacketHandler
|
||||||
|
import org.meshtastic.proto.Heartbeat
|
||||||
|
import org.meshtastic.proto.ToRadio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized heartbeat sender for the data layer.
|
||||||
|
*
|
||||||
|
* Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
|
||||||
|
* per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
|
||||||
|
*
|
||||||
|
* This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
|
||||||
|
* with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
|
||||||
|
*/
|
||||||
|
@Single
|
||||||
|
class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
|
||||||
|
private val nonce = atomic(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues a heartbeat with a unique nonce.
|
||||||
|
*
|
||||||
|
* @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
fun sendHeartbeat(tag: String = "handshake") {
|
||||||
|
try {
|
||||||
|
val n = nonce.incrementAndGet()
|
||||||
|
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
|
||||||
|
Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import okio.ByteString.Companion.toByteString
|
import okio.ByteString.Companion.toByteString
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
|
import org.meshtastic.core.common.util.safeCatching
|
||||||
import org.meshtastic.core.repository.HistoryManager
|
import org.meshtastic.core.repository.HistoryManager
|
||||||
import org.meshtastic.core.repository.MeshPrefs
|
import org.meshtastic.core.repository.MeshPrefs
|
||||||
import org.meshtastic.core.repository.PacketHandler
|
import org.meshtastic.core.repository.PacketHandler
|
||||||
|
|
@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
|
||||||
"lastRequest=$lastRequest window=$window max=$max",
|
"lastRequest=$lastRequest window=$window max=$max",
|
||||||
)
|
)
|
||||||
|
|
||||||
runCatching {
|
safeCatching {
|
||||||
packetHandler.sendToRadio(
|
packetHandler.sendToRadio(
|
||||||
MeshPacket(
|
MeshPacket(
|
||||||
from = myNodeNum,
|
from = myNodeNum,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager
|
||||||
import org.meshtastic.core.common.util.handledLaunch
|
import org.meshtastic.core.common.util.handledLaunch
|
||||||
import org.meshtastic.core.common.util.ignoreExceptionSuspend
|
import org.meshtastic.core.common.util.ignoreExceptionSuspend
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
|
import org.meshtastic.core.common.util.safeCatching
|
||||||
import org.meshtastic.core.model.DataPacket
|
import org.meshtastic.core.model.DataPacket
|
||||||
import org.meshtastic.core.model.MeshUser
|
import org.meshtastic.core.model.MeshUser
|
||||||
import org.meshtastic.core.model.MessageStatus
|
import org.meshtastic.core.model.MessageStatus
|
||||||
|
|
@ -44,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository
|
||||||
import org.meshtastic.core.repository.PlatformAnalytics
|
import org.meshtastic.core.repository.PlatformAnalytics
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
import org.meshtastic.core.repository.UiPrefs
|
||||||
import org.meshtastic.proto.AdminMessage
|
import org.meshtastic.proto.AdminMessage
|
||||||
import org.meshtastic.proto.Channel
|
import org.meshtastic.proto.Channel
|
||||||
import org.meshtastic.proto.Config
|
import org.meshtastic.proto.Config
|
||||||
|
|
@ -62,6 +64,7 @@ class MeshActionHandlerImpl(
|
||||||
private val dataHandler: Lazy<MeshDataHandler>,
|
private val dataHandler: Lazy<MeshDataHandler>,
|
||||||
private val analytics: PlatformAnalytics,
|
private val analytics: PlatformAnalytics,
|
||||||
private val meshPrefs: MeshPrefs,
|
private val meshPrefs: MeshPrefs,
|
||||||
|
private val uiPrefs: UiPrefs,
|
||||||
private val databaseManager: DatabaseManager,
|
private val databaseManager: DatabaseManager,
|
||||||
private val notificationManager: NotificationManager,
|
private val notificationManager: NotificationManager,
|
||||||
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
||||||
|
|
@ -93,7 +96,7 @@ class MeshActionHandlerImpl(
|
||||||
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
|
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
|
||||||
is ServiceAction.SendContact -> {
|
is ServiceAction.SendContact -> {
|
||||||
val accepted =
|
val accepted =
|
||||||
runCatching {
|
safeCatching {
|
||||||
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
|
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
|
||||||
}
|
}
|
||||||
.getOrDefault(false)
|
.getOrDefault(false)
|
||||||
|
|
@ -206,7 +209,7 @@ class MeshActionHandlerImpl(
|
||||||
|
|
||||||
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
|
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
|
||||||
if (destNum != myNodeNum) {
|
if (destNum != myNodeNum) {
|
||||||
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
|
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
|
||||||
val currentPosition =
|
val currentPosition =
|
||||||
when {
|
when {
|
||||||
provideLocation && position.isValid() -> position
|
provideLocation && position.isValid() -> position
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,17 @@ import co.touchlab.kermit.Logger
|
||||||
import kotlinx.atomicfu.atomic
|
import kotlinx.atomicfu.atomic
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import okio.IOException
|
|
||||||
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.common.util.handledLaunch
|
import org.meshtastic.core.common.util.handledLaunch
|
||||||
import org.meshtastic.core.model.ConnectionState
|
import org.meshtastic.core.model.ConnectionState
|
||||||
|
import org.meshtastic.core.model.DeviceVersion
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
import org.meshtastic.core.repository.HandshakeConstants
|
import org.meshtastic.core.repository.HandshakeConstants
|
||||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||||
import org.meshtastic.core.repository.MeshConnectionManager
|
import org.meshtastic.core.repository.MeshConnectionManager
|
||||||
import org.meshtastic.core.repository.NodeManager
|
import org.meshtastic.core.repository.NodeManager
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.repository.PacketHandler
|
|
||||||
import org.meshtastic.core.repository.PlatformAnalytics
|
import org.meshtastic.core.repository.PlatformAnalytics
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
|
@ -39,9 +38,7 @@ import org.meshtastic.core.repository.ServiceRepository
|
||||||
import org.meshtastic.proto.DeviceMetadata
|
import org.meshtastic.proto.DeviceMetadata
|
||||||
import org.meshtastic.proto.FileInfo
|
import org.meshtastic.proto.FileInfo
|
||||||
import org.meshtastic.proto.HardwareModel
|
import org.meshtastic.proto.HardwareModel
|
||||||
import org.meshtastic.proto.Heartbeat
|
|
||||||
import org.meshtastic.proto.NodeInfo
|
import org.meshtastic.proto.NodeInfo
|
||||||
import org.meshtastic.proto.ToRadio
|
|
||||||
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
|
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
|
||||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||||
|
|
||||||
|
|
@ -56,7 +53,7 @@ class MeshConfigFlowManagerImpl(
|
||||||
private val serviceBroadcasts: ServiceBroadcasts,
|
private val serviceBroadcasts: ServiceBroadcasts,
|
||||||
private val analytics: PlatformAnalytics,
|
private val analytics: PlatformAnalytics,
|
||||||
private val commandSender: CommandSender,
|
private val commandSender: CommandSender,
|
||||||
private val packetHandler: PacketHandler,
|
private val heartbeatSender: DataLayerHeartbeatSender,
|
||||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||||
) : MeshConfigFlowManager {
|
) : MeshConfigFlowManager {
|
||||||
private val wantConfigDelay = 100L
|
private val wantConfigDelay = 100L
|
||||||
|
|
@ -90,10 +87,8 @@ class MeshConfigFlowManagerImpl(
|
||||||
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
|
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
|
||||||
* `config_complete_id` arrives.
|
* `config_complete_id` arrives.
|
||||||
*/
|
*/
|
||||||
data class ReceivingNodeInfo(
|
data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List<NodeInfo> = emptyList()) :
|
||||||
val myNodeInfo: SharedMyNodeInfo,
|
HandshakeState()
|
||||||
val nodes: MutableList<NodeInfo> = mutableListOf(),
|
|
||||||
) : HandshakeState()
|
|
||||||
|
|
||||||
/** Both stages finished. The app is fully connected. */
|
/** Both stages finished. The app is fully connected. */
|
||||||
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
|
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
|
||||||
|
|
@ -139,28 +134,31 @@ class MeshConfigFlowManagerImpl(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn if firmware is below the absolute minimum supported version.
|
||||||
|
// The UI layer already enforces this via FirmwareVersionCheck, so we just log here
|
||||||
|
// for diagnostics rather than hard-disconnecting.
|
||||||
|
finalizedInfo.firmwareVersion?.let { fwVersion ->
|
||||||
|
if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
|
||||||
|
Logger.w {
|
||||||
|
"Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " +
|
||||||
|
"protocol incompatibilities may occur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
|
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
|
||||||
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
|
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||||
connectionManager.value.onRadioConfigLoaded()
|
connectionManager.value.onRadioConfigLoaded()
|
||||||
|
|
||||||
scope.handledLaunch {
|
scope.handledLaunch {
|
||||||
delay(wantConfigDelay)
|
delay(wantConfigDelay)
|
||||||
sendHeartbeat()
|
heartbeatSender.sendHeartbeat("inter-stage")
|
||||||
delay(wantConfigDelay)
|
delay(wantConfigDelay)
|
||||||
Logger.i { "Requesting NodeInfo (Stage 2)" }
|
Logger.i { "Requesting NodeInfo (Stage 2)" }
|
||||||
connectionManager.value.startNodeInfoOnly()
|
connectionManager.value.startNodeInfoOnly()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendHeartbeat() {
|
|
||||||
try {
|
|
||||||
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
|
|
||||||
Logger.d { "Heartbeat sent between nonce stages" }
|
|
||||||
} catch (ex: IOException) {
|
|
||||||
Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
|
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
|
||||||
Logger.i { "NodeInfo complete (Stage 2)" }
|
Logger.i { "NodeInfo complete (Stage 2)" }
|
||||||
|
|
||||||
|
|
@ -168,16 +166,12 @@ class MeshConfigFlowManagerImpl(
|
||||||
|
|
||||||
// Transition state immediately (synchronously) to prevent duplicate handling.
|
// Transition state immediately (synchronously) to prevent duplicate handling.
|
||||||
// The async work below (DB writes, broadcasts) proceeds without the guard.
|
// The async work below (DB writes, broadcasts) proceeds without the guard.
|
||||||
|
// Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
|
||||||
|
// Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
|
||||||
handshakeState = HandshakeState.Complete(myNodeInfo = info)
|
handshakeState = HandshakeState.Complete(myNodeInfo = info)
|
||||||
|
|
||||||
// Snapshot and clear immediately so that a concurrent stall-guard retry (which
|
|
||||||
// resends want_config_id and causes the firmware to restart the node_info burst)
|
|
||||||
// starts accumulating into a fresh list rather than doubling this batch.
|
|
||||||
val nodesToProcess = state.nodes.toList()
|
|
||||||
state.nodes.clear()
|
|
||||||
|
|
||||||
val entities =
|
val entities =
|
||||||
nodesToProcess.mapNotNull { nodeInfo ->
|
state.nodes.mapNotNull { nodeInfo ->
|
||||||
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
|
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
|
||||||
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
|
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
|
||||||
?: run {
|
?: run {
|
||||||
|
|
@ -242,7 +236,7 @@ class MeshConfigFlowManagerImpl(
|
||||||
override fun handleNodeInfo(info: NodeInfo) {
|
override fun handleNodeInfo(info: NodeInfo) {
|
||||||
val state = handshakeState
|
val state = handshakeState
|
||||||
if (state is HandshakeState.ReceivingNodeInfo) {
|
if (state is HandshakeState.ReceivingNodeInfo) {
|
||||||
state.nodes.add(info)
|
handshakeState = state.copy(nodes = state.nodes + info)
|
||||||
} else {
|
} else {
|
||||||
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
|
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
|
||||||
import org.meshtastic.proto.Config
|
import org.meshtastic.proto.Config
|
||||||
import org.meshtastic.proto.Telemetry
|
import org.meshtastic.proto.Telemetry
|
||||||
import org.meshtastic.proto.ToRadio
|
import org.meshtastic.proto.ToRadio
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
|
|
@ -84,6 +85,7 @@ class MeshConnectionManagerImpl(
|
||||||
private val packetRepository: PacketRepository,
|
private val packetRepository: PacketRepository,
|
||||||
private val workerManager: MeshWorkerManager,
|
private val workerManager: MeshWorkerManager,
|
||||||
private val appWidgetUpdater: AppWidgetUpdater,
|
private val appWidgetUpdater: AppWidgetUpdater,
|
||||||
|
private val heartbeatSender: DataLayerHeartbeatSender,
|
||||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||||
) : MeshConnectionManager {
|
) : MeshConnectionManager {
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,6 +94,7 @@ class MeshConnectionManagerImpl(
|
||||||
*/
|
*/
|
||||||
private val connectionMutex = Mutex()
|
private val connectionMutex = Mutex()
|
||||||
|
|
||||||
|
private var preHandshakeJob: Job? = null
|
||||||
private var sleepTimeout: Job? = null
|
private var sleepTimeout: Job? = null
|
||||||
private var locationRequestsJob: Job? = null
|
private var locationRequestsJob: Job? = null
|
||||||
private var handshakeTimeout: Job? = null
|
private var handshakeTimeout: Job? = null
|
||||||
|
|
@ -172,6 +175,8 @@ class MeshConnectionManagerImpl(
|
||||||
|
|
||||||
sleepTimeout?.cancel()
|
sleepTimeout?.cancel()
|
||||||
sleepTimeout = null
|
sleepTimeout = null
|
||||||
|
preHandshakeJob?.cancel()
|
||||||
|
preHandshakeJob = null
|
||||||
handshakeTimeout?.cancel()
|
handshakeTimeout?.cancel()
|
||||||
handshakeTimeout = null
|
handshakeTimeout = null
|
||||||
|
|
||||||
|
|
@ -192,16 +197,26 @@ class MeshConnectionManagerImpl(
|
||||||
serviceRepository.setConnectionState(ConnectionState.Connecting)
|
serviceRepository.setConnectionState(ConnectionState.Connecting)
|
||||||
}
|
}
|
||||||
serviceBroadcasts.broadcastConnection()
|
serviceBroadcasts.broadcastConnection()
|
||||||
Logger.i { "Starting mesh handshake (Stage 1)" }
|
|
||||||
connectTimeMsec = nowMillis
|
connectTimeMsec = nowMillis
|
||||||
startConfigOnly()
|
|
||||||
|
// Send a wake-up heartbeat before the config request. The firmware may be in a
|
||||||
|
// power-saving state where the NimBLE callback context needs warming up. The 100ms
|
||||||
|
// delay ensures the heartbeat BLE write is enqueued before the want_config_id
|
||||||
|
// (sendToRadio is fire-and-forget through async coroutine launches).
|
||||||
|
preHandshakeJob =
|
||||||
|
scope.handledLaunch {
|
||||||
|
heartbeatSender.sendHeartbeat("pre-handshake")
|
||||||
|
delay(PRE_HANDSHAKE_SETTLE_MS)
|
||||||
|
Logger.i { "Starting mesh handshake (Stage 1)" }
|
||||||
|
startConfigOnly()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
|
private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
|
||||||
handshakeTimeout?.cancel()
|
handshakeTimeout?.cancel()
|
||||||
handshakeTimeout =
|
handshakeTimeout =
|
||||||
scope.handledLaunch {
|
scope.handledLaunch {
|
||||||
delay(HANDSHAKE_TIMEOUT)
|
delay(timeout)
|
||||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||||
// Attempt one retry. Note: the firmware silently drops identical consecutive
|
// Attempt one retry. Note: the firmware silently drops identical consecutive
|
||||||
// writes (per-connection dedup). If the first want_config_id was received and
|
// writes (per-connection dedup). If the first want_config_id was received and
|
||||||
|
|
@ -277,19 +292,19 @@ class MeshConnectionManagerImpl(
|
||||||
|
|
||||||
override fun startConfigOnly() {
|
override fun startConfigOnly() {
|
||||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
||||||
startHandshakeStallGuard(1, action)
|
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startNodeInfoOnly() {
|
override fun startNodeInfoOnly() {
|
||||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
||||||
startHandshakeStallGuard(2, action)
|
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRadioConfigLoaded() {
|
override fun onRadioConfigLoaded() {
|
||||||
scope.handledLaunch {
|
scope.handledLaunch {
|
||||||
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
|
val queuedPackets = packetRepository.getQueuedPackets()
|
||||||
queuedPackets.forEach { packet ->
|
queuedPackets.forEach { packet ->
|
||||||
try {
|
try {
|
||||||
workerManager.enqueueSendMessage(packet.id)
|
workerManager.enqueueSendMessage(packet.id)
|
||||||
|
|
@ -381,7 +396,23 @@ class MeshConnectionManagerImpl(
|
||||||
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
|
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
|
||||||
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
|
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
|
||||||
|
|
||||||
private val HANDSHAKE_TIMEOUT = 30.seconds
|
/**
|
||||||
|
* Delay between the pre-handshake heartbeat and the want_config_id send.
|
||||||
|
*
|
||||||
|
* Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
|
||||||
|
* config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
|
||||||
|
* negligible connection latency.
|
||||||
|
*/
|
||||||
|
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
|
||||||
|
|
||||||
|
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
|
||||||
|
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
|
||||||
|
* nodes.
|
||||||
|
*/
|
||||||
|
private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
|
||||||
|
|
||||||
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
||||||
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds
|
||||||
import org.meshtastic.core.model.MeshLog
|
import org.meshtastic.core.model.MeshLog
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
import org.meshtastic.core.model.util.isLora
|
import org.meshtastic.core.model.util.isLora
|
||||||
|
import org.meshtastic.core.model.util.toOneLineString
|
||||||
|
import org.meshtastic.core.model.util.toPIIString
|
||||||
import org.meshtastic.core.repository.FromRadioPacketHandler
|
import org.meshtastic.core.repository.FromRadioPacketHandler
|
||||||
import org.meshtastic.core.repository.MeshLogRepository
|
import org.meshtastic.core.repository.MeshLogRepository
|
||||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||||
|
|
@ -96,7 +98,7 @@ class MeshMessageProcessorImpl(
|
||||||
}
|
}
|
||||||
.onFailure { _ ->
|
.onFailure { _ ->
|
||||||
Logger.e(primaryException) {
|
Logger.e(primaryException) {
|
||||||
"Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
|
"Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,11 +127,11 @@ class MeshMessageProcessorImpl(
|
||||||
proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString()
|
proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString()
|
||||||
proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
|
proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
|
||||||
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
|
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
|
||||||
proto.my_info != null -> "MyInfo" to proto.my_info.toString()
|
proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString()
|
||||||
proto.node_info != null -> "NodeInfo" to proto.node_info.toString()
|
proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString()
|
||||||
proto.config != null -> "Config" to proto.config.toString()
|
proto.config != null -> "Config" to proto.config!!.toOneLineString()
|
||||||
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString()
|
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString()
|
||||||
proto.channel != null -> "Channel" to proto.channel.toString()
|
proto.channel != null -> "Channel" to proto.channel!!.toOneLineString()
|
||||||
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
|
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,28 @@ import co.touchlab.kermit.Logger
|
||||||
import co.touchlab.kermit.Severity
|
import co.touchlab.kermit.Severity
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
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.model.MqttConnectionState
|
||||||
|
import org.meshtastic.core.model.MqttProbeStatus
|
||||||
import org.meshtastic.core.network.repository.MQTTRepository
|
import org.meshtastic.core.network.repository.MQTTRepository
|
||||||
|
import org.meshtastic.core.network.repository.resolveEndpoint
|
||||||
import org.meshtastic.core.repository.MqttManager
|
import org.meshtastic.core.repository.MqttManager
|
||||||
import org.meshtastic.core.repository.PacketHandler
|
import org.meshtastic.core.repository.PacketHandler
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
|
import org.meshtastic.mqtt.ConnectionState
|
||||||
|
import org.meshtastic.mqtt.MqttClient
|
||||||
|
import org.meshtastic.mqtt.MqttException
|
||||||
|
import org.meshtastic.mqtt.ProbeResult
|
||||||
|
import org.meshtastic.mqtt.probe
|
||||||
import org.meshtastic.proto.MqttClientProxyMessage
|
import org.meshtastic.proto.MqttClientProxyMessage
|
||||||
import org.meshtastic.proto.ToRadio
|
import org.meshtastic.proto.ToRadio
|
||||||
|
|
||||||
|
|
@ -40,18 +53,30 @@ class MqttManagerImpl(
|
||||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||||
) : MqttManager {
|
) : MqttManager {
|
||||||
private var mqttMessageFlow: Job? = null
|
private var mqttMessageFlow: Job? = null
|
||||||
|
private val proxyActive = MutableStateFlow(false)
|
||||||
|
|
||||||
|
override val mqttConnectionState: StateFlow<MqttConnectionState> =
|
||||||
|
combine(proxyActive, mqttRepository.connectionState) { active, libState ->
|
||||||
|
if (!active) MqttConnectionState.Inactive else libState.toAppState()
|
||||||
|
}
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive)
|
||||||
|
|
||||||
override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) {
|
override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) {
|
||||||
if (mqttMessageFlow?.isActive == true) return
|
if (mqttMessageFlow?.isActive == true) return
|
||||||
if (enabled && proxyToClientEnabled) {
|
if (enabled && proxyToClientEnabled) {
|
||||||
|
proxyActive.value = true
|
||||||
mqttMessageFlow =
|
mqttMessageFlow =
|
||||||
mqttRepository.proxyMessageFlow
|
mqttRepository.proxyMessageFlow
|
||||||
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
|
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
|
||||||
.catch { throwable ->
|
.catch { throwable ->
|
||||||
serviceRepository.setErrorMessage(
|
proxyActive.value = false
|
||||||
text = "MqttClientProxy failed: $throwable",
|
val message =
|
||||||
severity = Severity.Warn,
|
when (throwable) {
|
||||||
)
|
is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)"
|
||||||
|
is MqttException.ConnectionLost -> "MQTT: connection lost"
|
||||||
|
else -> "MQTT proxy failed: ${throwable.message}"
|
||||||
|
}
|
||||||
|
serviceRepository.setErrorMessage(text = message, severity = Severity.Warn)
|
||||||
}
|
}
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +88,7 @@ class MqttManagerImpl(
|
||||||
mqttMessageFlow?.cancel()
|
mqttMessageFlow?.cancel()
|
||||||
mqttMessageFlow = null
|
mqttMessageFlow = null
|
||||||
}
|
}
|
||||||
|
proxyActive.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
|
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
|
||||||
|
|
@ -79,4 +105,57 @@ class MqttManagerImpl(
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ConnectionState.toAppState(): MqttConnectionState = when (this) {
|
||||||
|
is ConnectionState.Connecting -> MqttConnectionState.Connecting
|
||||||
|
is ConnectionState.Connected -> MqttConnectionState.Connected
|
||||||
|
is ConnectionState.Reconnecting ->
|
||||||
|
MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message)
|
||||||
|
is ConnectionState.Disconnected ->
|
||||||
|
reason?.let { MqttConnectionState.Disconnected(reason = it.message) }
|
||||||
|
?: MqttConnectionState.Disconnected.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun probe(
|
||||||
|
address: String,
|
||||||
|
tlsEnabled: Boolean,
|
||||||
|
username: String?,
|
||||||
|
password: String?,
|
||||||
|
): MqttProbeStatus {
|
||||||
|
val endpoint = resolveEndpoint(address, tlsEnabled)
|
||||||
|
val result =
|
||||||
|
MqttClient.probe(endpoint = endpoint) {
|
||||||
|
val user = username?.takeUnless { it.isEmpty() }
|
||||||
|
val pass = password?.takeUnless { it.isEmpty() }
|
||||||
|
if (user != null) this.username = user
|
||||||
|
if (pass != null) password(pass)
|
||||||
|
}
|
||||||
|
return result.toAppStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) {
|
||||||
|
is ProbeResult.Success -> {
|
||||||
|
val info = serverInfo
|
||||||
|
val summary =
|
||||||
|
buildList {
|
||||||
|
info.assignedClientIdentifier?.let { add("client=$it") }
|
||||||
|
info.maximumQosOrdinal?.let { add("maxQoS=$it") }
|
||||||
|
info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") }
|
||||||
|
}
|
||||||
|
.joinToString(", ")
|
||||||
|
.ifEmpty { null }
|
||||||
|
MqttProbeStatus.Success(serverInfo = summary)
|
||||||
|
}
|
||||||
|
is ProbeResult.Rejected ->
|
||||||
|
MqttProbeStatus.Rejected(
|
||||||
|
reasonCode = reasonCode.value,
|
||||||
|
reason = message,
|
||||||
|
serverReference = serverReference,
|
||||||
|
)
|
||||||
|
is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message)
|
||||||
|
is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message)
|
||||||
|
is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message)
|
||||||
|
is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs)
|
||||||
|
is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
@ -73,6 +75,11 @@ class PacketHandlerImpl(
|
||||||
private val queueMutex = Mutex()
|
private val queueMutex = Mutex()
|
||||||
private val queuedPackets = mutableListOf<MeshPacket>()
|
private val queuedPackets = mutableListOf<MeshPacket>()
|
||||||
|
|
||||||
|
// Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
|
||||||
|
// calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
|
||||||
|
// a single consumer coroutine enqueues packets under queueMutex in arrival order.
|
||||||
|
private val outboundChannel = Channel<MeshPacket>(Channel.UNLIMITED)
|
||||||
|
|
||||||
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
|
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
|
||||||
// and the queue processor's finally block to prevent restarting a stopped queue.
|
// and the queue processor's finally block to prevent restarting a stopped queue.
|
||||||
private var queueStopped = false
|
private var queueStopped = false
|
||||||
|
|
@ -80,6 +87,20 @@ class PacketHandlerImpl(
|
||||||
private val responseMutex = Mutex()
|
private val responseMutex = Mutex()
|
||||||
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
|
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
|
||||||
|
// entry point, preserving FIFO across rapid concurrent callers.
|
||||||
|
scope.launch {
|
||||||
|
outboundChannel.consumeAsFlow().collect { packet ->
|
||||||
|
queueMutex.withLock {
|
||||||
|
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
|
||||||
|
queuedPackets.add(packet)
|
||||||
|
startPacketQueueLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun sendToRadio(p: ToRadio) {
|
override fun sendToRadio(p: ToRadio) {
|
||||||
Logger.d { "Sending to radio ${p.toPIIString()}" }
|
Logger.d { "Sending to radio ${p.toPIIString()}" }
|
||||||
val b = p.encode()
|
val b = p.encode()
|
||||||
|
|
@ -104,13 +125,9 @@ class PacketHandlerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendToRadio(packet: MeshPacket) {
|
override fun sendToRadio(packet: MeshPacket) {
|
||||||
scope.launch {
|
// Non-suspend entry point — order-preserving via unbounded channel drained by
|
||||||
queueMutex.withLock {
|
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
|
||||||
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
|
outboundChannel.trySend(packet)
|
||||||
queuedPackets.add(packet)
|
|
||||||
startPacketQueueLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
|
import org.meshtastic.core.common.util.safeCatching
|
||||||
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
|
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
|
||||||
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
|
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
|
||||||
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
|
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
|
||||||
|
|
@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch from remote API
|
// 2. Fetch from remote API
|
||||||
runCatching {
|
safeCatching {
|
||||||
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
|
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
|
||||||
val remoteHardware = remoteDataSource.getAllDeviceHardware()
|
val remoteHardware = remoteDataSource.getAllDeviceHardware()
|
||||||
Logger.d {
|
Logger.d {
|
||||||
|
|
@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
|
||||||
hwModel: Int,
|
hwModel: Int,
|
||||||
target: String?,
|
target: String?,
|
||||||
quirks: List<BootloaderOtaQuirk>,
|
quirks: List<BootloaderOtaQuirk>,
|
||||||
): Result<DeviceHardware?> = runCatching {
|
): Result<DeviceHardware?> = safeCatching {
|
||||||
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
|
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
|
||||||
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
||||||
Logger.d {
|
Logger.d {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
|
import org.meshtastic.core.common.util.safeCatching
|
||||||
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
|
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
|
||||||
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
|
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
|
||||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||||
|
|
@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
|
||||||
*/
|
*/
|
||||||
private suspend fun updateCacheFromSources() {
|
private suspend fun updateCacheFromSources() {
|
||||||
val remoteFetchSuccess =
|
val remoteFetchSuccess =
|
||||||
runCatching {
|
safeCatching {
|
||||||
Logger.d { "Fetching fresh firmware releases from remote API." }
|
Logger.d { "Fetching fresh firmware releases from remote API." }
|
||||||
val networkReleases = remoteDataSource.getFirmwareReleases()
|
val networkReleases = remoteDataSource.getFirmwareReleases()
|
||||||
|
|
||||||
|
|
@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
|
||||||
// If remote fetch failed, try the JSON fallback as a last resort.
|
// If remote fetch failed, try the JSON fallback as a last resort.
|
||||||
if (!remoteFetchSuccess) {
|
if (!remoteFetchSuccess) {
|
||||||
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
|
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
|
||||||
runCatching {
|
safeCatching {
|
||||||
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
|
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
|
||||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
|
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
|
||||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
|
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import kotlinx.coroutines.withContext
|
||||||
import okio.ByteString.Companion.toByteString
|
import okio.ByteString.Companion.toByteString
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.database.DatabaseProvider
|
import org.meshtastic.core.database.DatabaseProvider
|
||||||
|
import org.meshtastic.core.database.dao.NodeInfoDao
|
||||||
|
import org.meshtastic.core.database.entity.PacketEntity
|
||||||
import org.meshtastic.core.database.entity.toReaction
|
import org.meshtastic.core.database.entity.toReaction
|
||||||
import org.meshtastic.core.di.CoroutineDispatchers
|
import org.meshtastic.core.di.CoroutineDispatchers
|
||||||
import org.meshtastic.core.model.ContactSettings
|
import org.meshtastic.core.model.ContactSettings
|
||||||
|
|
@ -108,7 +110,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||||
dao.upsertContactSettings(listOf(updated))
|
dao.upsertContactSettings(listOf(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getQueuedPackets(): List<DataPacket>? =
|
override suspend fun getQueuedPackets(): List<DataPacket> =
|
||||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
|
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
|
||||||
|
|
||||||
suspend fun insertRoomPacket(packet: RoomPacket) =
|
suspend fun insertRoomPacket(packet: RoomPacket) =
|
||||||
|
|
@ -154,13 +156,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||||
else -> dao.getMessagesFrom(contact)
|
else -> dao.getMessagesFrom(contact)
|
||||||
}
|
}
|
||||||
flow.mapLatest { packets ->
|
flow.mapLatest { packets ->
|
||||||
|
val cachedGetNode = memoize(getNode)
|
||||||
|
val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct()
|
||||||
|
val replyMap = batchGetPacketsByIds(replyIds)
|
||||||
packets.map { packet ->
|
packets.map { packet ->
|
||||||
val message = packet.toMessage(getNode)
|
val message = packet.toMessage(cachedGetNode)
|
||||||
message.replyId
|
val replyId = message.replyId?.takeIf { it != 0 }
|
||||||
.takeIf { it != null && it != 0 }
|
val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode)
|
||||||
?.let { getPacketByPacketIdInternal(it) }
|
if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
|
||||||
?.let { originalPacket -> originalPacket.toMessage(getNode) }
|
|
||||||
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,13 +180,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||||
)
|
)
|
||||||
.flow
|
.flow
|
||||||
.map { pagingData ->
|
.map { pagingData ->
|
||||||
|
val cachedGetNode = memoize(getNode)
|
||||||
|
val replyCache = mutableMapOf<Int, PacketEntity?>()
|
||||||
pagingData.map { packet ->
|
pagingData.map { packet ->
|
||||||
val message = packet.toMessage(getNode)
|
val message = packet.toMessage(cachedGetNode)
|
||||||
message.replyId
|
val replyId = message.replyId?.takeIf { it != 0 }
|
||||||
.takeIf { it != null && it != 0 }
|
val originalMessage =
|
||||||
?.let { getPacketByPacketIdInternal(it) }
|
replyId
|
||||||
?.let { originalPacket -> originalPacket.toMessage(getNode) }
|
?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
|
||||||
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
|
?.toMessage(cachedGetNode)
|
||||||
|
if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,13 +210,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||||
)
|
)
|
||||||
.flow
|
.flow
|
||||||
.map { pagingData ->
|
.map { pagingData ->
|
||||||
|
val cachedGetNode = memoize(getNode)
|
||||||
|
val replyCache = mutableMapOf<Int, PacketEntity?>()
|
||||||
pagingData.map { packet ->
|
pagingData.map { packet ->
|
||||||
val message = packet.toMessage(getNode)
|
val message = packet.toMessage(cachedGetNode)
|
||||||
message.replyId
|
val replyId = message.replyId?.takeIf { it != 0 }
|
||||||
.takeIf { it != null && it != 0 }
|
val originalMessage =
|
||||||
?.let { getPacketByPacketIdInternal(it) }
|
replyId
|
||||||
?.let { originalPacket -> originalPacket.toMessage(getNode) }
|
?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
|
||||||
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
|
?.toMessage(cachedGetNode)
|
||||||
|
if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +239,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||||
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
|
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
|
||||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
|
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
|
||||||
|
|
||||||
|
private suspend fun batchGetPacketsByIds(ids: List<Int>): Map<Int, PacketEntity> = if (ids.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
val dao = dbManager.currentDb.value.packetDao()
|
||||||
|
ids.chunked(NodeInfoDao.MAX_BIND_PARAMS)
|
||||||
|
.flatMap { dao.getPacketsByPacketIds(it) }
|
||||||
|
.associateBy { it.packet.packetId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun memoize(getNode: suspend (String?) -> Node): suspend (String?) -> Node {
|
||||||
|
val cache = mutableMapOf<String?, Node>()
|
||||||
|
return { id -> cache.getOrPut(id) { getNode(id) } }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun insert(
|
override suspend fun insert(
|
||||||
packet: DataPacket,
|
packet: DataPacket,
|
||||||
myNodeNum: Int,
|
myNodeNum: Int,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository
|
||||||
import org.meshtastic.core.repository.PlatformAnalytics
|
import org.meshtastic.core.repository.PlatformAnalytics
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
import org.meshtastic.core.repository.UiPrefs
|
||||||
import org.meshtastic.proto.AdminMessage
|
import org.meshtastic.proto.AdminMessage
|
||||||
import org.meshtastic.proto.Channel
|
import org.meshtastic.proto.Channel
|
||||||
import org.meshtastic.proto.Config
|
import org.meshtastic.proto.Config
|
||||||
|
|
@ -68,6 +69,7 @@ class MeshActionHandlerImplTest {
|
||||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||||
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
|
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
|
||||||
|
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
|
||||||
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
|
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
|
||||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||||
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
|
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
|
||||||
|
|
@ -100,6 +102,7 @@ class MeshActionHandlerImplTest {
|
||||||
dataHandler = lazy { dataHandler },
|
dataHandler = lazy { dataHandler },
|
||||||
analytics = analytics,
|
analytics = analytics,
|
||||||
meshPrefs = meshPrefs,
|
meshPrefs = meshPrefs,
|
||||||
|
uiPrefs = uiPrefs,
|
||||||
databaseManager = databaseManager,
|
databaseManager = databaseManager,
|
||||||
notificationManager = notificationManager,
|
notificationManager = notificationManager,
|
||||||
messageProcessor = lazy { messageProcessor },
|
messageProcessor = lazy { messageProcessor },
|
||||||
|
|
@ -356,7 +359,7 @@ class MeshActionHandlerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
|
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
|
||||||
handler = createHandler(testScope)
|
handler = createHandler(testScope)
|
||||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||||
|
|
||||||
val validPosition = Position(37.7749, -122.4194, 10)
|
val validPosition = Position(37.7749, -122.4194, 10)
|
||||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||||
|
|
@ -367,7 +370,7 @@ class MeshActionHandlerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
|
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
|
||||||
handler = createHandler(testScope)
|
handler = createHandler(testScope)
|
||||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||||
|
|
||||||
val invalidPosition = Position(0.0, 0.0, 0)
|
val invalidPosition = Position(0.0, 0.0, 0)
|
||||||
|
|
@ -380,7 +383,7 @@ class MeshActionHandlerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
|
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
|
||||||
handler = createHandler(testScope)
|
handler = createHandler(testScope)
|
||||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
|
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
|
||||||
|
|
||||||
val validPosition = Position(37.7749, -122.4194, 10)
|
val validPosition = Position(37.7749, -122.4194, 10)
|
||||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
import dev.mokkery.MockMode
|
import dev.mokkery.MockMode
|
||||||
|
import dev.mokkery.answering.calls
|
||||||
import dev.mokkery.answering.returns
|
import dev.mokkery.answering.returns
|
||||||
import dev.mokkery.every
|
import dev.mokkery.every
|
||||||
import dev.mokkery.matcher.any
|
import dev.mokkery.matcher.any
|
||||||
|
|
@ -97,7 +98,7 @@ class MeshConfigFlowManagerImplTest {
|
||||||
serviceBroadcasts = serviceBroadcasts,
|
serviceBroadcasts = serviceBroadcasts,
|
||||||
analytics = analytics,
|
analytics = analytics,
|
||||||
commandSender = commandSender,
|
commandSender = commandSender,
|
||||||
packetHandler = packetHandler,
|
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
|
||||||
scope = testScope,
|
scope = testScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest {
|
||||||
verify { connectionManager.startNodeInfoOnly() }
|
verify { connectionManager.startNodeInfoOnly() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
|
||||||
|
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||||
|
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||||
|
{ call ->
|
||||||
|
sentPackets.add(call.arg(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.handleMyInfo(protoMyNodeInfo)
|
||||||
|
advanceUntilIdle()
|
||||||
|
manager.handleLocalMetadata(metadata)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
sentPackets.clear() // Clear any packets from prior phases
|
||||||
|
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val heartbeats = sentPackets.filter { it.heartbeat != null }
|
||||||
|
assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
heartbeats[0].heartbeat!!.nonce != 0,
|
||||||
|
"Inter-stage heartbeat should have a non-zero nonce",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
|
||||||
|
val oldMetadata =
|
||||||
|
DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
|
||||||
|
manager.handleMyInfo(protoMyNodeInfo)
|
||||||
|
advanceUntilIdle()
|
||||||
|
manager.handleLocalMetadata(oldMetadata)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Handshake should still progress despite old firmware
|
||||||
|
verify { connectionManager.onRadioConfigLoaded() }
|
||||||
|
verify { connectionManager.startNodeInfoOnly() }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
|
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
|
||||||
manager.handleMyInfo(protoMyNodeInfo)
|
manager.handleMyInfo(protoMyNodeInfo)
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ class MeshConnectionManagerImplTest {
|
||||||
packetRepository,
|
packetRepository,
|
||||||
workerManager,
|
workerManager,
|
||||||
appWidgetUpdater,
|
appWidgetUpdater,
|
||||||
|
DataLayerHeartbeatSender(packetHandler),
|
||||||
scope,
|
scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,6 +149,59 @@ class MeshConnectionManagerImplTest {
|
||||||
verify { serviceBroadcasts.broadcastConnection() }
|
verify { serviceBroadcasts.broadcastConnection() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
|
||||||
|
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||||
|
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||||
|
{ call ->
|
||||||
|
sentPackets.add(call.arg(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = createManager(backgroundScope)
|
||||||
|
radioConnectionState.value = ConnectionState.Connected
|
||||||
|
// Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
|
||||||
|
advanceTimeBy(200)
|
||||||
|
|
||||||
|
// First ToRadio should be a heartbeat, second should be want_config_id
|
||||||
|
assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
|
||||||
|
val heartbeat = sentPackets[0]
|
||||||
|
val wantConfig = sentPackets[1]
|
||||||
|
|
||||||
|
assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
|
||||||
|
assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
|
||||||
|
assertEquals(
|
||||||
|
org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
|
||||||
|
wantConfig.want_config_id,
|
||||||
|
"Second packet should be want_config_id with CONFIG_NONCE",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
|
||||||
|
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||||
|
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||||
|
{ call ->
|
||||||
|
sentPackets.add(call.arg(0))
|
||||||
|
}
|
||||||
|
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||||
|
|
||||||
|
manager = createManager(backgroundScope)
|
||||||
|
radioConnectionState.value = ConnectionState.Connected
|
||||||
|
// Advance only 50ms — within the 100ms settle window
|
||||||
|
advanceTimeBy(50)
|
||||||
|
|
||||||
|
// Should have sent only the heartbeat so far, not want_config_id
|
||||||
|
assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
|
||||||
|
|
||||||
|
// Disconnect before the settle delay completes — should cancel the pending config start
|
||||||
|
radioConnectionState.value = ConnectionState.Disconnected
|
||||||
|
advanceTimeBy(200)
|
||||||
|
|
||||||
|
// The want_config_id should NOT have been sent because the job was cancelled
|
||||||
|
val configPackets = sentPackets.filter { it.want_config_id != null }
|
||||||
|
assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Disconnected state stops services`() = runTest(testDispatcher) {
|
fun `Disconnected state stops services`() = runTest(testDispatcher) {
|
||||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,7 @@ import androidx.room3.Room
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.test.runTest
|
||||||
import okio.ByteString.Companion.toByteString
|
import okio.ByteString.Companion.toByteString
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -59,7 +59,7 @@ class MigrationTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun createDb(): Unit = runBlocking {
|
fun createDb(): Unit = runTest {
|
||||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
database =
|
database =
|
||||||
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
|
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
|
||||||
|
|
@ -77,7 +77,7 @@ class MigrationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
|
fun testMigrateChannelsByPSK_duplicatePSK() = runTest {
|
||||||
// PSK \"AQ==\" is base64 for single byte 0x01
|
// PSK \"AQ==\" is base64 for single byte 0x01
|
||||||
val pskBytes = byteArrayOf(0x01).toByteString()
|
val pskBytes = byteArrayOf(0x01).toByteString()
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ class MigrationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMigrateChannelsByPSK_reorder() = runBlocking {
|
fun testMigrateChannelsByPSK_reorder() = runTest {
|
||||||
val pskA = byteArrayOf(0x01).toByteString()
|
val pskA = byteArrayOf(0x01).toByteString()
|
||||||
val pskB = byteArrayOf(0x02).toByteString()
|
val pskB = byteArrayOf(0x02).toByteString()
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ class MigrationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
|
fun testMigrateChannelsByPSK_disambiguateByName() = runTest {
|
||||||
val pskA = byteArrayOf(0x01).toByteString()
|
val pskA = byteArrayOf(0x01).toByteString()
|
||||||
|
|
||||||
insertPacket(channel = 0, text = "Msg A1")
|
insertPacket(channel = 0, text = "Msg A1")
|
||||||
|
|
@ -141,7 +141,7 @@ class MigrationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
|
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest {
|
||||||
val pskA = byteArrayOf(0x01).toByteString()
|
val pskA = byteArrayOf(0x01).toByteString()
|
||||||
|
|
||||||
insertPacket(channel = 0, text = "Msg A")
|
insertPacket(channel = 0, text = "Msg A")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package org.meshtastic.core.database
|
package org.meshtastic.core.database
|
||||||
|
|
||||||
import okio.ByteString.Companion.encodeUtf8
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
import org.meshtastic.core.common.util.normalizeAddress
|
||||||
|
|
||||||
object DatabaseConstants {
|
object DatabaseConstants {
|
||||||
const val DB_PREFIX: String = "meshtastic_database"
|
const val DB_PREFIX: String = "meshtastic_database"
|
||||||
|
|
@ -40,17 +41,6 @@ object DatabaseConstants {
|
||||||
const val ADDRESS_ANON_EDGE_LEN: Int = 2
|
const val ADDRESS_ANON_EDGE_LEN: Int = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
fun normalizeAddress(addr: String?): String {
|
|
||||||
val u = addr?.trim()?.uppercase()
|
|
||||||
val normalized =
|
|
||||||
when {
|
|
||||||
u.isNullOrBlank() -> "DEFAULT"
|
|
||||||
u == "N" || u == "NULL" -> "DEFAULT"
|
|
||||||
else -> u.replace(":", "")
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
|
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
|
||||||
|
|
||||||
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
|
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ open class DatabaseManager(
|
||||||
|
|
||||||
victims.forEach { name ->
|
victims.forEach { name ->
|
||||||
runCatching {
|
runCatching {
|
||||||
|
// runCatching intentional: best-effort cleanup must not abort on cancellation
|
||||||
closeCachedDatabase(name)
|
closeCachedDatabase(name)
|
||||||
deleteDatabase(name)
|
deleteDatabase(name)
|
||||||
datastore.edit { it.remove(lastUsedKey(name)) }
|
datastore.edit { it.remove(lastUsedKey(name)) }
|
||||||
|
|
@ -266,6 +267,7 @@ open class DatabaseManager(
|
||||||
|
|
||||||
if (fs.exists(legacyPath)) {
|
if (fs.exists(legacyPath)) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
// runCatching intentional: best-effort cleanup must not abort on cancellation
|
||||||
closeCachedDatabase(legacy)
|
closeCachedDatabase(legacy)
|
||||||
deleteDatabase(legacy)
|
deleteDatabase(legacy)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||||
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
|
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
|
||||||
AutoMigration(from = 35, to = 36),
|
AutoMigration(from = 35, to = 36),
|
||||||
AutoMigration(from = 36, to = 37),
|
AutoMigration(from = 36, to = 37),
|
||||||
|
AutoMigration(from = 37, to = 38),
|
||||||
],
|
],
|
||||||
version = 37,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,15 @@
|
||||||
package org.meshtastic.core.database.dao
|
package org.meshtastic.core.database.dao
|
||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
|
||||||
import androidx.room3.OnConflictStrategy
|
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
import org.meshtastic.core.database.entity.DeviceHardwareEntity
|
import org.meshtastic.core.database.entity.DeviceHardwareEntity
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface DeviceHardwareDao {
|
interface DeviceHardwareDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
|
||||||
suspend fun insert(deviceHardware: DeviceHardwareEntity)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Upsert suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
|
||||||
suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
|
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
|
||||||
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>
|
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,14 @@
|
||||||
package org.meshtastic.core.database.dao
|
package org.meshtastic.core.database.dao
|
||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
|
||||||
import androidx.room3.OnConflictStrategy
|
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
|
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
|
||||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface FirmwareReleaseDao {
|
interface FirmwareReleaseDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
|
||||||
suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
|
|
||||||
|
|
||||||
@Query("DELETE FROM firmware_release")
|
@Query("DELETE FROM firmware_release")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
|
||||||
@Dao
|
@Dao
|
||||||
interface MeshLogDao {
|
interface MeshLogDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
|
@Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem")
|
||||||
fun getAllLogs(maxItem: Int): Flow<List<MeshLog>>
|
fun getAllLogs(maxItem: Int): Flow<List<MeshLog>>
|
||||||
|
|
||||||
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
|
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
|
||||||
fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>>
|
fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,7 +40,7 @@ interface MeshLogDao {
|
||||||
"""
|
"""
|
||||||
SELECT * FROM log
|
SELECT * FROM log
|
||||||
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
|
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
|
||||||
ORDER BY received_date DESC LIMIT 0,:maxItem
|
ORDER BY received_date DESC LIMIT :maxItem
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow<List<MeshLog>>
|
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow<List<MeshLog>>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@
|
||||||
package org.meshtastic.core.database.dao
|
package org.meshtastic.core.database.dao
|
||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
|
||||||
import androidx.room3.MapColumn
|
import androidx.room3.MapColumn
|
||||||
import androidx.room3.OnConflictStrategy
|
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Transaction
|
import androidx.room3.Transaction
|
||||||
import androidx.room3.Upsert
|
import androidx.room3.Upsert
|
||||||
|
|
@ -37,6 +35,9 @@ interface NodeInfoDao {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KEY_SIZE = 32
|
const val KEY_SIZE = 32
|
||||||
|
|
||||||
|
/** SQLite has a limit of ~999 bind parameters per query. */
|
||||||
|
const val MAX_BIND_PARAMS = 999
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -168,8 +169,7 @@ interface NodeInfoDao {
|
||||||
@Query("SELECT * FROM my_node")
|
@Query("SELECT * FROM my_node")
|
||||||
fun getMyNodeInfo(): Flow<MyNodeEntity?>
|
fun getMyNodeInfo(): Flow<MyNodeEntity?>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
|
||||||
suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
|
|
||||||
|
|
||||||
@Query("DELETE FROM my_node")
|
@Query("DELETE FROM my_node")
|
||||||
suspend fun clearMyNodeInfo()
|
suspend fun clearMyNodeInfo()
|
||||||
|
|
@ -284,9 +284,15 @@ interface NodeInfoDao {
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun getNodeByNum(num: Int): NodeWithRelations?
|
suspend fun getNodeByNum(num: Int): NodeWithRelations?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM nodes WHERE num IN (:nodeNums)")
|
||||||
|
suspend fun getNodeEntitiesByNums(nodeNums: List<Int>): List<NodeEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
|
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
|
||||||
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
|
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)")
|
||||||
|
suspend fun findNodesByPublicKeys(publicKeys: List<ByteString>): List<NodeEntity>
|
||||||
|
|
||||||
@Upsert suspend fun doUpsert(node: NodeEntity)
|
@Upsert suspend fun doUpsert(node: NodeEntity)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
|
|
@ -295,17 +301,82 @@ interface NodeInfoDao {
|
||||||
doUpsert(verifiedNode)
|
doUpsert(verifiedNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Upsert suspend fun putAll(nodes: List<NodeEntity>)
|
||||||
suspend fun putAll(nodes: List<NodeEntity>)
|
|
||||||
|
|
||||||
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
|
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
|
||||||
suspend fun setNodeNotes(num: Int, notes: String)
|
suspend fun setNodeNotes(num: Int, notes: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
|
||||||
|
* queries instead of N individual queries, then processes each node in memory.
|
||||||
|
*/
|
||||||
|
@Suppress("NestedBlockDepth")
|
||||||
|
private suspend fun getVerifiedNodesForUpsert(incomingNodes: List<NodeEntity>): List<NodeEntity> {
|
||||||
|
// Prepare all incoming nodes (populate denormalized fields)
|
||||||
|
incomingNodes.forEach { node ->
|
||||||
|
node.publicKey = node.user.public_key
|
||||||
|
if (node.user.hw_model != HardwareModel.UNSET) {
|
||||||
|
node.longName = node.user.long_name
|
||||||
|
node.shortName = node.user.short_name
|
||||||
|
} else {
|
||||||
|
node.longName = null
|
||||||
|
node.shortName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch fetch all existing nodes by num (chunked for SQLite bind-param limit)
|
||||||
|
val existingNodesMap =
|
||||||
|
incomingNodes
|
||||||
|
.map { it.num }
|
||||||
|
.chunked(MAX_BIND_PARAMS)
|
||||||
|
.flatMap { getNodeEntitiesByNums(it) }
|
||||||
|
.associateBy { it.num }
|
||||||
|
|
||||||
|
// Partition into updates vs. inserts and resolve existing nodes in-memory
|
||||||
|
val result = mutableListOf<NodeEntity>()
|
||||||
|
val newNodes = mutableListOf<NodeEntity>()
|
||||||
|
for (incoming in incomingNodes) {
|
||||||
|
val existing = existingNodesMap[incoming.num]
|
||||||
|
if (existing != null) {
|
||||||
|
result.add(handleExistingNodeUpsertValidation(existing, incoming))
|
||||||
|
} else {
|
||||||
|
newNodes.add(incoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch validate new nodes' public keys (one query instead of N)
|
||||||
|
val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
|
||||||
|
val pkConflicts =
|
||||||
|
if (publicKeysToCheck.isNotEmpty()) {
|
||||||
|
publicKeysToCheck
|
||||||
|
.chunked(MAX_BIND_PARAMS)
|
||||||
|
.flatMap { findNodesByPublicKeys(it) }
|
||||||
|
.associateBy { it.publicKey }
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (newNode in newNodes) {
|
||||||
|
if ((newNode.publicKey?.size ?: 0) > 0) {
|
||||||
|
val conflicting = pkConflicts[newNode.publicKey]
|
||||||
|
if (conflicting != null && conflicting.num != newNode.num) {
|
||||||
|
result.add(conflicting)
|
||||||
|
} else {
|
||||||
|
result.add(newNode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.add(newNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
|
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
|
||||||
clearMyNodeInfo()
|
clearMyNodeInfo()
|
||||||
setMyNodeInfo(mi)
|
setMyNodeInfo(mi)
|
||||||
putAll(nodes.map { getVerifiedNodeForUpsert(it) })
|
putAll(getVerifiedNodesForUpsert(nodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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