Compare commits

..

74 commits

Author SHA1 Message Date
James Rich
f21d8af9ae
fix(transport): improve BLE / TCP / USB reconnect and handshake resilience (#5196)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 17:34:16 +00:00
James Rich
a90cb2d89e
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5195)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-20 17:32:58 +00:00
Copilot
7492a33cf8
Fix node-details remove action to preserve confirmation flow (#5192)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 15:59:20 +00:00
James Rich
2b47da3b61
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5193)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-20 07:40:08 -05:00
renovate[bot]
3322257cfd
chore(deps): update plugin com.gradle.develocity to v4.4.1 (#5194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:47:09 +00:00
James Rich
99e7407a90
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5189)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-19 20:07:52 +00:00
renovate[bot]
9dd57725f2
chore(deps): update vico to v3.2.0-next.1 (#5191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 12:31:11 -05:00
renovate[bot]
2c1984ace5
chore(deps): update fastlane to v2.233.0 (#5190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 16:30:34 +00:00
James Rich
94856d257f
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5186)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-18 12:09:22 +00:00
James Rich
84fe24467f
fix(widget): drive updates via debounced state observer (#5185)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-18 04:11:32 +00:00
renovate[bot]
68a414b75b
chore(deps): update compose-multiplatform to v1.11.0-rc01 (#5184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 22:00:34 -05:00
James Rich
4257e7b7e4
chore(deps): split androidx-compose version ref from CMP (#5183)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 21:41:36 -05:00
James Rich
14e86b90f1
feat(mqtt): adopt mqttastic-client-kmp 0.2.0 — disconnect reasons + Test Connection (#5181)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 21:33:55 -05:00
James Rich
ef0e159abb
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5177)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-17 21:20:58 -05:00
James Rich
61d7f6fef3
fix(deps): pin androidx-compose runtime-tracing/ui-test to CMP version (#5179)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:46:59 -05:00
James Rich
a273dc6623
Revert "diag(r8): disable minify for release builds (animation-freeze diagnostic)" (#5176)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:07:54 -05:00
James Rich
c866f60b59
diag(r8): disable minify for release builds (animation-freeze diagnostic) (#5174)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 18:36:33 +00:00
James Rich
10bc58d417
chore(strings): remove 4 unused string resources (#5173)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:36:32 +00:00
James Rich
dd74e501f3
fix(ui): finish accessibility roles and action labels for clickable surfaces (#5170)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:33:38 +00:00
James Rich
56cbc3670d
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5163)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-17 17:13:27 +00:00
James Rich
15a7c19b74
chore(r8): remove redundant keep rules covered by consumer rules (#5172)
Co-authored-by: GitHub Copilot CLI <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:13:26 +00:00
James Rich
b979663e24
refactor: consolidate metric formatting through MetricFormatter (#5169)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:13:01 +00:00
James Rich
9f3fe865e3
test: migrate MigrationTest to runTest and add missing repository fakes (#5171)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 16:35:41 +00:00
James Rich
90f6e21a9c
fix(ui): stable LazyColumn keys, semantic roles, and content descriptions (#5168)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 16:24:18 +00:00
James Rich
cdeb1ac532
fix: redact MeshLog proto secrets and centralize Compose keep-rules (#5166)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 16:20:50 +00:00
James Rich
adfe3bfed1
refactor: use injected ioDispatcher and ApplicationCoroutineScope (#5167)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 16:18:45 +00:00
James Rich
a97f704300
feat(mqtt): migrate to MQTTastic-Client-KMP (#5165)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:19:08 +00:00
James Rich
df3b5365f9
fix(node): don't recreate Vico CartesianChartModelProducer on channel switch (#5160)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 02:40:17 +00:00
James Rich
a6a889430b
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5159)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-16 21:43:35 -05:00
renovate[bot]
65b885a073
chore(deps): update core/proto/src/main/proto digest to 4d5b500 (#5161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 21:41:36 -05:00
James Rich
17e69c6d4c
chore: review-cleanup fleet (audit + fix + hardening) (#5158) 2026-04-17 00:02:59 +00:00
James Rich
872c566ef1
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5157)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-16 20:25:12 +00:00
renovate[bot]
3a2f2fc56b
chore(deps): update kotlin to v2.3.21-rc2 (#5155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 16:33:25 +00:00
renovate[bot]
50896d455b
chore(deps): update dd.sdk.android to v3.9.0 (#5156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 16:33:13 +00:00
James Rich
a580cd0467
chore(analytics): disable Datadog Compose action tracking (#5153)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 23:09:21 +00:00
James Rich
8e5d99410c
refactor(di): adopt @KoinApplication with startKoin<T>() compiler plugin API (#5152)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 22:52:59 +00:00
renovate[bot]
0f900fe7d7
chore(deps): update core/proto/src/main/proto digest to c9067da (#5151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:12:53 -05:00
James Rich
9ac02cf851
fix(app): disable R8 optimization to fix Compose animation freeze (#5150)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 17:45:27 +00:00
James Rich
878905aea3
perf(messaging): batch node + reply lookups in message loading (#5149)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 15:48:26 +00:00
James Rich
dea364dd17
fix(app): add R8 keep rules for Compose animation/runtime/ui (#5146)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 14:30:33 +00:00
James Rich
c7d2a76851
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5145)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-15 07:48:12 -05:00
renovate[bot]
f72b91328d
chore(deps): update androidx.compose to v1.11.0-rc01 (#5144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 07:47:53 -05:00
James Rich
d0057752f6
fix(ci): remove Renovate groupings and decouple AndroidX Compose version ref (#5143)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 07:23:20 -05:00
James Rich
84621acb04
fix: align BLE connection handshake with firmware protocol expectations (#5141)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 11:55:15 +00:00
James Rich
96419f3251
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5140)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-15 06:07:21 -05:00
James Rich
60ff495037
chore(r8): clean up ProGuard rules and enable Compose Hot Reload (#5139)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 03:26:39 +00:00
James Rich
401f59489a
chore: remove deprecated mesh_service_example module (#5055) 2026-04-15 03:10:23 +00:00
James Rich
a2763bdfeb
fix(charts): apply Vico 3.1.0 best-practice audit fixes (#5138)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 02:20:33 +00:00
James Rich
72b981f73b
chore: KMP audit — commonize code, centralize utilities, eliminate dead abstractions (#5133)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 02:17:50 +00:00
James Rich
50ade01e55
docs(agents): add PR and commit hygiene guidance (#5137)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 20:49:34 -05:00
James Rich
79ed0a865a
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5128)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-15 01:14:38 +00:00
James Rich
bf0deef708
fix(icons): audit and correct icon migration regressions from #5030 #5040 #5056 (#5136)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 01:14:31 +00:00
James Rich
fa63a4ac50
feat: add high-contrast theme with accessible message bubbles (#5135)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 01:14:20 +00:00
James Rich
f48fc61729
feat(environment): add 1-Wire multi-thermometer (DS18B20) display support (#5130)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 00:03:24 +00:00
James Rich
099aea2d81
feat(desktop): add entitlements and wire MeshConnectionManager into orchestrator (#5127) 2026-04-14 15:16:10 +00:00
renovate[bot]
c6f58cc799
chore(deps): update core/proto/src/main/proto digest to 940ac38 (#5126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 09:48:25 -05:00
James Rich
27055290e2
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5125)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-14 12:37:12 +00:00
renovate[bot]
3aadd29e67
chore(deps): update core/proto/src/main/proto digest to a045501 (#5124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 07:17:49 -05:00
James Rich
9acdf5309f
refactor: modern APIs — Koin 4.2, CMP 1.11, Ktor resilience, Room @Upsert, injected dispatchers (#5119) 2026-04-14 11:41:01 +00:00
renovate[bot]
99378c9291
chore(deps): update core/proto/src/main/proto digest to 98e95ee (#5123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 06:50:59 -05:00
James Rich
3c7e1266f8
fix: truncate traceroute chart x-values to whole seconds to prevent Vico crash (#5122) 2026-04-14 11:01:03 +00:00
James Rich
743851b0b5
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5120)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-14 10:35:19 +00:00
James Rich
e46a8296cb
feat(core/ui): add safeLaunch, UiState, KMP permissions, and CMP lifecycle modernization (#5118) 2026-04-14 00:45:34 +00:00
James Rich
27367e9064
fix(build): pin Skiko version to align with Compose Multiplatform (#5117) 2026-04-13 23:32:00 +00:00
James Rich
28be6933c8
fix(proguard): disable shrinking for Compose animation classes (#5116) 2026-04-13 21:55:52 +00:00
James Rich
92166f0fa2
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5115)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-13 15:52:55 -05:00
renovate[bot]
8e7c4f54a3
chore(deps): update actions/upload-pages-artifact action to v5 (#5114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 15:24:43 -05:00
James Rich
938a951737
refactor: leverage CMP 1.11 + Lifecycle 2.11 — v2 test API, Json privacy, dropUnlessResumed nav guards (#5112) 2026-04-13 20:02:31 +00:00
James Rich
76386e419c
refactor: migrate remaining raw stateIn(WhileSubscribed) to stateInWhileSubscribed extension (#5113) 2026-04-13 20:02:06 +00:00
James Rich
b13f9bf989
fix(resources): add resourcePrefix to KMP + widget modules, rename prefixed resources (#5111) 2026-04-13 18:25:23 +00:00
James Rich
8a06157ff4
docs: remove agent cruft, condense and validate remaining docs (#5110) 2026-04-13 17:59:19 +00:00
renovate[bot]
75e2177da7
chore(deps): update com.android.tools:common to v32.1.1 (#5108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 17:16:04 +00:00
renovate[bot]
61f90352c4
chore(deps): update agp to v9.2.0-rc01 (#5107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 17:15:52 +00:00
James Rich
087fbbfb45
fix(build): overhaul R8 rules and DRY up build-logic conventions (#5109) 2026-04-13 17:11:42 +00:00
462 changed files with 8690 additions and 6403 deletions

View file

@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt"
- Never use plain `androidx.compose` dependencies in `commonMain`.
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
- Check `gradle/libs.versions.toml` before adding dependencies.
- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.

12
.github/lsp.json vendored Normal file
View file

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

228
.github/renovate.json vendored
View file

@ -49,236 +49,24 @@
"automerge": true
},
{
"description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [
"https://github.com/meshtastic/protobufs.git"
],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
"groupName": "Meshtastic Protobufs",
"groupSlug": "meshtastic-protobufs",
"automerge": true
},
{
"description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
"groupName": "AndroidX (General)",
"groupSlug": "androidx-general",
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
"groupName": "compose-multiplatform",
"matchPackageNames": [
"/^androidx\\./",
"!/^androidx\\.room/",
"!/^androidx\\.lifecycle/",
"!/^androidx\\.navigation/",
"!/^androidx\\.datastore/",
"!/^androidx\\.compose\\.material3\\.adaptive/",
"!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
"!/^androidx\\.test\\.espresso/",
"!/^androidx\\.test\\.ext/",
"!/^androidx\\.hilt/"
"/^org\\.jetbrains\\.compose/",
"androidx.compose.runtime:runtime-tracing",
"androidx.compose.ui:ui-test-manifest"
]
},
{
"description": "Group JetBrains Compose Multiplatform plugin and libraries (separate versioning from AndroidX Compose)",
"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)",
"description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
"minor"
],
@ -305,4 +93,4 @@
"automerge": false
}
]
}
}

View file

@ -66,7 +66,7 @@ jobs:
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html

View file

@ -44,13 +44,16 @@ jobs:
uses: actions/ai-inference@v2
id: quality
continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 20
prompt: |
Is this GitHub pull request spam, AI-generated slop, or low quality?
Title: ${{ github.event.pull_request.title }}
Body: ${{ github.event.pull_request.body }}
Title: ${{ env.PR_TITLE }}
Body: ${{ env.PR_BODY }}
Respond with exactly one of: spam, ai-generated, needs-review, ok
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
@ -94,6 +97,9 @@ jobs:
uses: actions/ai-inference@v2
id: classify
continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 30
prompt: |
@ -105,8 +111,8 @@ jobs:
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.
Title: ${{ github.event.pull_request.title }}
Body: ${{ github.event.pull_request.body }}
Title: ${{ env.PR_TITLE }}
Body: ${{ env.PR_BODY }}
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
model: openai/gpt-4o-mini

View file

@ -3,10 +3,6 @@ name: Pull Request CI
on:
pull_request:
branches: [ main ]
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.gitignore'
permissions:
contents: read
@ -74,8 +70,7 @@ jobs:
}
allowed_extra_roots = {'baselineprofile'}
excluded_roots = {'mesh_service_example'}
expected_roots = (module_roots | allowed_extra_roots) - excluded_roots
expected_roots = module_roots | allowed_extra_roots
filter_paths = {
path.split('/')[0]

View file

@ -213,7 +213,7 @@ jobs:
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}

1
.gitignore vendored
View file

@ -55,3 +55,4 @@ wireless-install.sh
firebase-debug.log
.agent_plans/
.agent_refs/
.agent_artifacts/

295
.pr5167.diff Normal file
View 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}" }
}
}

View file

@ -1,16 +1,7 @@
# Skill: Code Review
## 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.
## 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.
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
## Code Review Checklist
@ -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.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `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`.
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`.
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
@ -45,8 +37,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi
### 5. Networking, DB & I/O
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
### 6. Dependency Catalog Aliases
@ -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.
### 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`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
### 8. ProGuard / R8 Rules
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
## Review Output Guidelines
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").

View file

@ -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.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`).
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
```kotlin
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
stringResource(Res.string.battery_percent, formatted) // uses %1$s
```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
### String Formatting Decision Tree
Choose the right tool for the job:
| Scenario | Tool | Example |
|----------|------|---------|
| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)``"77.0°F"` |
| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
**Rules:**
1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
@ -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.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
## 4. Compose Previews
- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
## 5. Dialog & State Patterns
- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`

View file

@ -33,5 +33,9 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
### 6. Verify Locally
- Run the baseline checks (see `testing-ci` skill):
```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
```

View file

@ -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.
## 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:**
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
- **BLE:** Route through `core:ble` using **Kable**.
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
@ -38,6 +40,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
## 6. I/O & Serialization
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Room Patterns:**
- Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
- Use `LIMIT 1` on `@Query` methods that expect a single row.
- Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List<T>)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
## 7. Build-Logic Conventions
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.

View file

@ -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.
### 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.
### Koin Startup Pattern (K2 Compiler Plugin)
The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin<T>()` stub, which the plugin transforms at compile time via IR:
```kotlin
// Bootstrap class — separate from @Module, references the root module graph
@KoinApplication(modules = [AppKoinModule::class])
object AndroidKoinApp
// In Application.onCreate()
startKoin<AndroidKoinApp> {
androidContext(this@MeshUtilApplication)
workManagerFactory()
}
```
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
## Navigation 3
### Guidelines
@ -32,6 +50,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
## Reference Anchors
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`

View file

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

View file

@ -1,21 +1,13 @@
# Skill: Project Overview & Codebase Map
## 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
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.
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
- **Language:** Kotlin (primary), AIDL.
- **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
## Codebase Map
| 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/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`. |
| `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.
- **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.
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
```properties
@ -62,7 +53,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
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.
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
```
## 6. Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Missing Secrets:** Check `local.properties` (see Environment Setup above).
- **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`).
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
```bash
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## Reference Anchors
- **KMP Migration Status:** `docs/kmp-status.md`
- **Roadmap:** `docs/roadmap.md`
- **Architecture Decision Records:** `docs/decisions/`
- **Version Catalog:** `gradle/libs.versions.toml`
## Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.

View file

@ -5,17 +5,14 @@ Guidelines and commands for verifying code changes locally and understanding the
## 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
./gradlew clean
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew detekt
./gradlew assembleDebug
./gradlew test allTests
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
> **Why `test allTests` and not just `test`:**
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
@ -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`):
- `shard-core`: `allTests` for all `core:*` KMP modules.
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`).
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
@ -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.).
- **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.

View file

@ -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/implement-feature/` - Step-by-step feature workflow.
- `.skills/code-review/` - PR validation checklist.
- `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
</context_and_memory>
@ -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:
1. **Find the Android SDK**`ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
`./gradlew 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>
<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.
- `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>
<rules>
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`.
- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety.
- **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`).
- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
</rules>
<copilot_cli_workflow>
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>

View file

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

View file

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

31
SOUL.md
View file

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

View file

@ -171,8 +171,6 @@ configure<ApplicationExtension> {
} else {
signingConfig = signingConfigs.getByName("debug")
}
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = false
}
}
@ -266,7 +264,6 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
@ -282,7 +279,6 @@ dependencies {
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)

View file

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

View file

@ -861,15 +861,9 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) {
Text(
modifier = Modifier.padding(16.dp),
text =
stringResource(
Res.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
),
)
val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
}
}

View file

@ -26,7 +26,6 @@ import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.enableComposeActionTracking
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
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
.trackLongTasks()
.trackNonFatalAnrs(true)
.enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags
.setSessionSampleRate(sampleRate)
.build()
Rum.enable(rumConfiguration)

View file

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

View file

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

View file

@ -288,7 +288,7 @@
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/local_stats_widget_info" />
android:resource="@xml/widget_local_stats_info" />
</receiver>
<!-- allow for plugin discovery -->

View file

@ -24,6 +24,13 @@
}
],
"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",
"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",
"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"
},
{
"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"
}
]
},

View file

@ -45,11 +45,12 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
@ -57,8 +58,8 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
@ -91,6 +92,8 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
private val usbRepository: UsbRepository by inject()
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
@ -124,6 +127,8 @@ class MainActivity : ComponentActivity() {
setSingletonImageLoaderFactory { get<ImageLoader>() }
val theme by model.theme.collectAsStateWithLifecycle()
val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
@ -141,7 +146,7 @@ class MainActivity : ComponentActivity() {
}
AppCompositionLocals {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
@ -164,6 +169,16 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
override fun onResume() {
super.onResume()
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
// resumed while a USB device is already attached (e.g. process restart, returning
// from another app), the manifest-declared attach intent may have already fired
// before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
// reality without requiring the user to physically replug.
usbRepository.refreshState()
}
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
@ -255,6 +270,11 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
// Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
// receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
// never sees this event. Forward it explicitly so the serialDevices StateFlow
// refreshes and the device shows up in the Connect → Serial tab.
usbRepository.refreshState()
showSettingsPage()
}
@ -276,7 +296,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(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 {

View file

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

View file

@ -14,16 +14,13 @@
* 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
package org.meshtastic.app.di
import kotlin.test.Test
import kotlin.test.assertEquals
import org.koin.core.annotation.KoinApplication
class MeshtasticUriTest {
@Test
fun testParseAndToString() {
val uriString = "content://com.example.provider/file.txt"
val uri = MeshtasticUri.parse(uriString)
assertEquals(uriString, uri.toString())
}
}
/**
* Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when
* [org.koin.plugin.module.dsl.startKoin] is called with this type parameter.
*/
@KoinApplication(modules = [AppKoinModule::class])
object AndroidKoinApp

View file

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

View file

@ -25,6 +25,7 @@ import androidx.work.WorkerParameters
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import kotlinx.coroutines.CoroutineDispatcher
import org.koin.plugin.module.dsl.koinApplication
import org.koin.test.verify.definition
import org.koin.test.verify.injectedParameters
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()
}
}
}

View file

@ -54,7 +54,6 @@ dependencies {
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.androidx.room.gradlePlugin)
compileOnly(libs.secrets.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.test.retry.gradlePlugin)

View file

@ -18,7 +18,7 @@ import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.datadog.gradle.plugin.DdExtension
import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask
import com.datadog.gradle.plugin.InstrumentationMode
import com.datadog.gradle.plugin.SdkCheckLevel
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -110,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin<Project> {
variants {
register(variant.name) {
site = "US5"
composeInstrumentation = InstrumentationMode.AUTO
}
}
checkProjectDependencies = SdkCheckLevel.NONE

View file

@ -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
* 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
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -26,7 +25,6 @@ import org.meshtastic.buildlogic.configureTestOptions
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "org.gradle.test-retry")
apply(plugin = "meshtastic.android.lint")
@ -38,16 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
testOptions {
animationsDisabled = true
unitTests.isReturnDefaultValues = true
}
defaultConfig { vectorDrawables.useSupportLibrary = true }
buildTypes {
getByName("release") {
@ -55,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
rootProject.file("config/proguard/shared-rules.pro"),
"proguard-rules.pro",
)
}
getByName("debug") {
@ -67,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
}
}
buildFeatures {
buildConfig = true
}
buildFeatures { buildConfig = true }
}
configureTestOptions()
}

View file

@ -38,11 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions {
animationsDisabled = true
unitTests.isReturnDefaultValues = true
}
defaultConfig {
// When flavorless modules depend on flavored modules (like :core:data),

View file

@ -54,16 +54,18 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
// Logging
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 {
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
implementation(libs.library("compose-multiplatform-material3"))
implementation(libs.library("compose-multiplatform-ui"))
implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }

View file

@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.getByName("commonMain").dependencies {
implementation(libs.library("compose-multiplatform-runtime"))
// API because consuming modules will usually need the resource types
api(libs.library("compose-multiplatform-resources"))
sourceSets.matching { it.name == "commonMain" }.configureEach {
dependencies {
implementation(libs.library("compose-multiplatform-runtime"))
// API because consuming modules will usually need the resource types
api(libs.library("compose-multiplatform-resources"))
}
}
}
configureComposeCompiler()

View file

@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import dev.mokkery.gradle.MokkeryGradleExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback
import org.meshtastic.buildlogic.configureKmpTestDependencies
import org.meshtastic.buildlogic.configureKotlinMultiplatform
@ -39,8 +37,6 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
apply(plugin = "org.gradle.test-retry")
apply(plugin = libs.plugin("mokkery").get().pluginId)
extensions.configure<MokkeryGradleExtension> { stubs.allowConcreteClassInstantiation.set(true) }
configureKotlinMultiplatform()
configureKmpTestDependencies()
configureTestOptions()

View file

@ -29,11 +29,12 @@ class KoinConventionPlugin : Plugin<Project> {
// Configure Koin K2 Compiler Plugin (0.4.0+)
extensions.configure(KoinGradleExtension::class.java) {
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
// per-module safety checks strictly enforce that all dependencies must be explicitly
// provided or included locally. This breaks decoupled Clean Architecture designs.
// We disable compile safety globally to properly rely on Koin's A3 full-graph
// validation which perfectly handles inverted dependencies at the composition root.
// Meshtastic uses dependency inversion across KMP modules — interfaces in
// commonMain, implementations wired at the composition root. Koin's compileSafety
// flag enables A1 per-module checks that treat every module as self-contained,
// which breaks this pattern. There is no separate flag for A3 full-graph
// validation. Until Koin exposes granular safety levels we keep this disabled;
// runtime graph verification is handled by KoinVerificationTest instead.
compileSafety.set(false)
}

View file

@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
compileSdk = compileSdkVersion
defaultConfig.minSdk = minSdkVersion
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (this is ApplicationExtension) {
defaultConfig.targetSdk = targetSdkVersion
}
val javaVersion = if (project.name in listOf("api", "model", "proto")) {
JavaVersion.VERSION_17
} else {
JavaVersion.VERSION_21
}
val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
compileOptions.sourceCompatibility = javaVersion
compileOptions.targetCompatibility = javaVersion
testOptions.animationsDisabled = true
testOptions.unitTests.isReturnDefaultValues = true
// Exclude duplicate META-INF license files shipped by JUnit Platform JARs
packaging.resources.excludes.addAll(
listOf(
@ -72,6 +72,23 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
/** Configure Kotlin Multiplatform options */
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> {
// Standard KMP targets for Meshtastic
jvm()
@ -190,11 +207,25 @@ internal fun Project.configureKotlinJvm() {
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 */
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
val isPublishedModule = project.name in PUBLISHED_MODULES
extensions.configure<T> {
val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
val isPublishedModule = project.name in listOf("api", "model", "proto")
val javaVersion = if (isPublishedModule) 17 else 21
// 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.
jvmToolchain(javaVersion)
@ -208,14 +239,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.addAll(
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xexpect-actual-classes",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check",
)
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
@ -230,21 +254,13 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
val isPublishedModule = project.name in listOf("api", "model", "proto")
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
allWarningsAsErrors.set(warningsAsErrors)
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.addAll(
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xexpect-actual-classes",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check",
"-jvm-default=no-compatibility",
)
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}

View file

@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
id("com.gradle.develocity") version("4.4.0")
id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {

View file

@ -57,10 +57,6 @@ component_management:
name: Desktop
paths:
- desktop/**
- component_id: example
name: Example
paths:
- mesh_service_example/**
ignore:
- "**/build/**"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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.** { *; }

View file

@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
/**
* 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 message a human-readable description of the failure.
*/
@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
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
}

View file

@ -48,9 +48,7 @@ suspend fun <T> retryBleOperation(
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
Logger.w(e) {
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
}
Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
delay(delayMs)
}
}

View file

@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
### 2. `ByteUtils.kt`
Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
### 2. `MetricFormatter.kt`
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`
An interface for accessing build-time configuration in a multiplatform-friendly way.

View file

@ -37,6 +37,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
api(libs.uri.kmp)
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,13 +17,14 @@
package org.meshtastic.core.common.util
/**
* A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
* modules without coupling them to the android.net.Uri class.
* Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
* blank, or sentinel values (`"N"`, `"NULL"`).
*/
data class MeshtasticUri(val uriString: String) {
override fun toString(): String = uriString
companion object {
fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
fun normalizeAddress(addr: String?): String {
val u = addr?.trim()?.uppercase()
return when {
u.isNullOrBlank() -> "DEFAULT"
u == "N" || u == "NULL" -> "DEFAULT"
else -> u.replace(":", "")
}
}

View file

@ -16,22 +16,14 @@
*/
package org.meshtastic.core.common.util
/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
expect class CommonUri {
val host: String?
val fragment: String?
val pathSegments: List<String>
import com.eygraber.uri.Uri
fun getQueryParameter(key: String): String?
fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
override fun toString(): String
companion object {
fun parse(uriString: String): CommonUri
}
}
/** Extension to convert platform Uri to CommonUri in Android source sets. */
expect fun CommonUri.toPlatformUri(): Any
/**
* Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
*
* This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
* identically on Android, JVM, and iOS without platform stubs.
*
* On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
*/
typealias CommonUri = Uri

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
object Exceptions {
/** 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) {
try {
inner()
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
@ -69,3 +72,41 @@ fun exceptionReporter(inner: () -> Unit) {
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)
}

View file

@ -16,5 +16,114 @@
*/
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

View file

@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
val stringBuilder = StringBuilder()
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
return stringBuilder.toString()
fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
}
}

View file

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

View file

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

View file

@ -18,27 +18,26 @@ package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
fun testParseAndToString() {
val uriString = "content://com.example.provider/file.txt"
val uri = CommonUri.parse(uriString)
assertEquals(uriString, uri.toString())
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
fun testQueryParameters() {
val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
assertEquals("meshtastic.org", uri.host)
assertEquals("key=value&complete=true", uri.fragment)
}
@Test
fun testFileUri() {
val uri = CommonUri.parse("file:///tmp/export.csv")
assertEquals("file", uri.scheme)
assertEquals("/tmp/export.csv", uri.path)
}
}

View file

@ -93,4 +93,48 @@ class FormatStringTest {
fun sequentialFloatSubstitution() {
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"))
}
}

View file

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

View file

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

View file

@ -22,20 +22,6 @@ actual object BuildUtils {
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 fun formatRelativeTime(timestampMillis: Long): String = ""

View file

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

View file

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

View file

@ -17,9 +17,6 @@
package org.meshtastic.core.common.util
import java.net.InetAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.text.DateFormat
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@ -76,7 +73,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
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")
@ -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 DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,63}${'$'}")

View file

@ -32,6 +32,7 @@ class DeviceHardwareJsonDataSourceImpl(private val application: Application) : D
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
exceptionsWithDebugInfo = false
}
@OptIn(ExperimentalSerializationApi::class)

View file

@ -32,6 +32,7 @@ class FirmwareReleaseJsonDataSourceImpl(private val application: Application) :
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
exceptionsWithDebugInfo = false
}
@OptIn(ExperimentalSerializationApi::class)

View file

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

View file

@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
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",
)
runCatching {
safeCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,

View file

@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
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.MeshUser
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.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@ -62,6 +64,7 @@ class MeshActionHandlerImpl(
private val dataHandler: Lazy<MeshDataHandler>,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
@ -93,7 +96,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
runCatching {
safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
@ -206,7 +209,7 @@ class MeshActionHandlerImpl(
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position

View file

@ -20,18 +20,17 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import okio.IOException
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
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.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@ -56,7 +53,7 @@ class MeshConfigFlowManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigFlowManager {
private val wantConfigDelay = 100L
@ -90,10 +87,8 @@ class MeshConfigFlowManagerImpl(
* [myNodeInfo] was committed at the Stage 12 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
data class ReceivingNodeInfo(
val myNodeInfo: SharedMyNodeInfo,
val nodes: MutableList<NodeInfo> = mutableListOf(),
) : HandshakeState()
data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List<NodeInfo> = emptyList()) :
HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
@ -139,28 +134,31 @@ class MeshConfigFlowManagerImpl(
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)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
sendHeartbeat()
heartbeatSender.sendHeartbeat("inter-stage")
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
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) {
Logger.i { "NodeInfo complete (Stage 2)" }
@ -168,16 +166,12 @@ class MeshConfigFlowManagerImpl(
// Transition state immediately (synchronously) to prevent duplicate handling.
// 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)
// 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 =
nodesToProcess.mapNotNull { nodeInfo ->
state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
@ -242,7 +236,7 @@ class MeshConfigFlowManagerImpl(
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
state.nodes.add(info)
handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}

View file

@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@ -84,6 +85,7 @@ class MeshConnectionManagerImpl(
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
/**
@ -92,6 +94,7 @@ class MeshConnectionManagerImpl(
*/
private val connectionMutex = Mutex()
private var preHandshakeJob: Job? = null
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
@ -172,6 +175,8 @@ class MeshConnectionManagerImpl(
sleepTimeout?.cancel()
sleepTimeout = null
preHandshakeJob?.cancel()
preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
@ -192,16 +197,26 @@ class MeshConnectionManagerImpl(
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
Logger.i { "Starting mesh handshake (Stage 1)" }
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 =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
delay(timeout)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
@ -277,19 +292,19 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
startHandshakeStallGuard(1, action)
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
@ -381,7 +396,23 @@ class MeshConnectionManagerImpl(
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
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 (~1050ms) 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
// first want_config_id the retry completes within a few seconds. Waiting another 30s

View file

@ -32,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
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.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
@ -96,7 +98,7 @@ class MeshMessageProcessorImpl(
}
.onFailure { _ ->
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.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
proto.my_info != null -> "MyInfo" to proto.my_info.toString()
proto.node_info != null -> "NodeInfo" to proto.node_info.toString()
proto.config != null -> "Config" to proto.config.toString()
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString()
proto.channel != null -> "Channel" to proto.channel.toString()
proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString()
proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString()
proto.config != null -> "Config" to proto.config!!.toOneLineString()
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString()
proto.channel != null -> "Channel" to proto.channel!!.toOneLineString()
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
else -> return
}

View file

@ -20,15 +20,28 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
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.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import org.koin.core.annotation.Named
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.resolveEndpoint
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
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.ToRadio
@ -40,18 +53,30 @@ class MqttManagerImpl(
@Named("ServiceScope") private val scope: CoroutineScope,
) : MqttManager {
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) {
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
proxyActive.value = true
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable ->
serviceRepository.setErrorMessage(
text = "MqttClientProxy failed: $throwable",
severity = Severity.Warn,
)
proxyActive.value = false
val message =
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)
}
@ -63,6 +88,7 @@ class MqttManagerImpl(
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
proxyActive.value = false
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
@ -79,4 +105,57 @@ class MqttManagerImpl(
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)
}
}

View file

@ -22,7 +22,9 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -73,6 +75,11 @@ class PacketHandlerImpl(
private val queueMutex = Mutex()
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()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
@ -80,6 +87,20 @@ class PacketHandlerImpl(
private val responseMutex = Mutex()
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) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
@ -104,13 +125,9 @@ class PacketHandlerImpl(
}
override fun sendToRadio(packet: MeshPacket) {
scope.launch {
queueMutex.withLock {
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
// Non-suspend entry point — order-preserving via unbounded channel drained by
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
outboundChannel.trySend(packet)
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")

View file

@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
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.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
runCatching {
safeCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List<BootloaderOtaQuirk>,
): Result<DeviceHardware?> = runCatching {
): Result<DeviceHardware?> = safeCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Single
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.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
runCatching {
safeCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
runCatching {
safeCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
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.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactSettings
@ -108,7 +110,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dao.upsertContactSettings(listOf(updated))
}
override suspend fun getQueuedPackets(): List<DataPacket>? =
override suspend fun getQueuedPackets(): List<DataPacket> =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =
@ -154,13 +156,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
else -> dao.getMessagesFrom(contact)
}
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 ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
val message = packet.toMessage(cachedGetNode)
val replyId = message.replyId?.takeIf { it != 0 }
val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode)
if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
}
@ -177,13 +180,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
val cachedGetNode = memoize(getNode)
val replyCache = mutableMapOf<Int, PacketEntity?>()
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
val message = packet.toMessage(cachedGetNode)
val replyId = message.replyId?.takeIf { it != 0 }
val originalMessage =
replyId
?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
?.toMessage(cachedGetNode)
if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@ -204,13 +210,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
val cachedGetNode = memoize(getNode)
val replyCache = mutableMapOf<Int, PacketEntity?>()
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
val message = packet.toMessage(cachedGetNode)
val replyId = message.replyId?.takeIf { it != 0 }
val originalMessage =
replyId
?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
?.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) =
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(
packet: DataPacket,
myNodeNum: Int,

View file

@ -47,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@ -68,6 +69,7 @@ class MeshActionHandlerImplTest {
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(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 notificationManager = mock<NotificationManager>(MockMode.autofill)
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
@ -100,6 +102,7 @@ class MeshActionHandlerImplTest {
dataHandler = lazy { dataHandler },
analytics = analytics,
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
messageProcessor = lazy { messageProcessor },
@ -356,7 +359,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
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)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
@ -367,7 +370,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
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()
val invalidPosition = Position(0.0, 0.0, 0)
@ -380,7 +383,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
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)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
@ -97,7 +98,7 @@ class MeshConfigFlowManagerImplTest {
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
packetHandler = packetHandler,
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
scope = testScope,
)
}
@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest {
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
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)

View file

@ -129,6 +129,7 @@ class MeshConnectionManagerImplTest {
packetRepository,
workerManager,
appWidgetUpdater,
DataLayerHeartbeatSender(packetHandler),
scope,
)
@ -148,6 +149,59 @@ class MeshConnectionManagerImplTest {
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
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
@ -59,7 +59,7 @@ class MigrationTest {
)
@Before
fun createDb(): Unit = runBlocking {
fun createDb(): Unit = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
@ -77,7 +77,7 @@ class MigrationTest {
}
@Test
fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
fun testMigrateChannelsByPSK_duplicatePSK() = runTest {
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
@ -103,7 +103,7 @@ class MigrationTest {
}
@Test
fun testMigrateChannelsByPSK_reorder() = runBlocking {
fun testMigrateChannelsByPSK_reorder() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
@ -122,7 +122,7 @@ class MigrationTest {
}
@Test
fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
fun testMigrateChannelsByPSK_disambiguateByName() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
@ -141,7 +141,7 @@ class MigrationTest {
}
@Test
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A")

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.database
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.common.util.normalizeAddress
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
@ -40,17 +41,6 @@ object DatabaseConstants {
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 buildDbName(address: String?): String = if (address.isNullOrBlank()) {

View file

@ -241,6 +241,7 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
// runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
@ -266,6 +267,7 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
// runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}

View file

@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
],
version = 37,
version = 38,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)

View file

@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
@Upsert suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>

View file

@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()

View file

@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
@Dao
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>>
@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>>
/**
@ -40,7 +40,7 @@ interface MeshLogDao {
"""
SELECT * FROM log
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>>

View file

@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.MapColumn
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@ -37,6 +35,9 @@ interface NodeInfoDao {
companion object {
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")
fun getMyNodeInfo(): Flow<MyNodeEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@ -284,9 +284,15 @@ interface NodeInfoDao {
@Transaction
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")
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)
@Transaction
@ -295,17 +301,82 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun putAll(nodes: List<NodeEntity>)
@Upsert suspend fun putAll(nodes: List<NodeEntity>)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
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
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
clearMyNodeInfo()
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