mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Compare commits
74 commits
v2.7.14-in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21d8af9ae | ||
|
|
a90cb2d89e | ||
|
|
7492a33cf8 | ||
|
|
2b47da3b61 | ||
|
|
3322257cfd | ||
|
|
99e7407a90 | ||
|
|
9dd57725f2 | ||
|
|
2c1984ace5 | ||
|
|
94856d257f | ||
|
|
84fe24467f | ||
|
|
68a414b75b | ||
|
|
4257e7b7e4 | ||
|
|
14e86b90f1 | ||
|
|
ef0e159abb | ||
|
|
61d7f6fef3 | ||
|
|
a273dc6623 | ||
|
|
c866f60b59 | ||
|
|
10bc58d417 | ||
|
|
dd74e501f3 | ||
|
|
56cbc3670d | ||
|
|
15a7c19b74 | ||
|
|
b979663e24 | ||
|
|
9f3fe865e3 | ||
|
|
90f6e21a9c | ||
|
|
cdeb1ac532 | ||
|
|
adfe3bfed1 | ||
|
|
a97f704300 | ||
|
|
df3b5365f9 | ||
|
|
a6a889430b | ||
|
|
65b885a073 | ||
|
|
17e69c6d4c | ||
|
|
872c566ef1 | ||
|
|
3a2f2fc56b | ||
|
|
50896d455b | ||
|
|
a580cd0467 | ||
|
|
8e5d99410c | ||
|
|
0f900fe7d7 | ||
|
|
9ac02cf851 | ||
|
|
878905aea3 | ||
|
|
dea364dd17 | ||
|
|
c7d2a76851 | ||
|
|
f72b91328d | ||
|
|
d0057752f6 | ||
|
|
84621acb04 | ||
|
|
96419f3251 | ||
|
|
60ff495037 | ||
|
|
401f59489a | ||
|
|
a2763bdfeb | ||
|
|
72b981f73b | ||
|
|
50ade01e55 | ||
|
|
79ed0a865a | ||
|
|
bf0deef708 | ||
|
|
fa63a4ac50 | ||
|
|
f48fc61729 | ||
|
|
099aea2d81 | ||
|
|
c6f58cc799 | ||
|
|
27055290e2 | ||
|
|
3aadd29e67 | ||
|
|
9acdf5309f | ||
|
|
99378c9291 | ||
|
|
3c7e1266f8 | ||
|
|
743851b0b5 | ||
|
|
e46a8296cb | ||
|
|
27367e9064 | ||
|
|
28be6933c8 | ||
|
|
92166f0fa2 | ||
|
|
8e7c4f54a3 | ||
|
|
938a951737 | ||
|
|
76386e419c | ||
|
|
b13f9bf989 | ||
|
|
8a06157ff4 | ||
|
|
75e2177da7 | ||
|
|
61f90352c4 | ||
|
|
087fbbfb45 |
462 changed files with 8690 additions and 6403 deletions
|
|
@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt"
|
|||
- Never use plain `androidx.compose` dependencies in `commonMain`.
|
||||
- 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
12
.github/lsp.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"lspServers": {
|
||||
"kotlin": {
|
||||
"command": "kotlin-language-server",
|
||||
"args": [],
|
||||
"fileExtensions": {
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
228
.github/renovate.json
vendored
228
.github/renovate.json
vendored
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
.github/workflows/models_pr_triage.yml
vendored
14
.github/workflows/models_pr_triage.yml
vendored
|
|
@ -44,13 +44,16 @@ jobs:
|
|||
uses: actions/ai-inference@v2
|
||||
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
|
||||
|
||||
|
|
|
|||
7
.github/workflows/pull-request.yml
vendored
7
.github/workflows/pull-request.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
2
.github/workflows/reusable-check.yml
vendored
2
.github/workflows/reusable-check.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -55,3 +55,4 @@ wireless-install.sh
|
|||
firebase-debug.log
|
||||
.agent_plans/
|
||||
.agent_refs/
|
||||
.agent_artifacts/
|
||||
|
|
|
|||
295
.pr5167.diff
Normal file
295
.pr5167.diff
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
||||
new file mode 100644
|
||||
index 0000000000..2a27b96906
|
||||
--- /dev/null
|
||||
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
|
||||
@@ -0,0 +1,39 @@
|
||||
+/*
|
||||
+ * Copyright (c) 2026 Meshtastic LLC
|
||||
+ *
|
||||
+ * This program is free software: you can redistribute it and/or modify
|
||||
+ * it under the terms of the GNU General Public License as published by
|
||||
+ * the Free Software Foundation, either version 3 of the License, or
|
||||
+ * (at your option) any later version.
|
||||
+ *
|
||||
+ * This program is distributed in the hope that it will be useful,
|
||||
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
+ * GNU General Public License for more details.
|
||||
+ *
|
||||
+ * You should have received a copy of the GNU General Public License
|
||||
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
+ */
|
||||
+package org.meshtastic.core.common.di
|
||||
+
|
||||
+import kotlinx.coroutines.CoroutineScope
|
||||
+import kotlinx.coroutines.SupervisorJob
|
||||
+import org.koin.core.annotation.Single
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
+
|
||||
+/**
|
||||
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
|
||||
+ *
|
||||
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
|
||||
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
|
||||
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
|
||||
+ *
|
||||
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
|
||||
+ * and should be used sparingly.
|
||||
+ */
|
||||
+interface ApplicationCoroutineScope : CoroutineScope
|
||||
+
|
||||
+@Single(binds = [ApplicationCoroutineScope::class])
|
||||
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
|
||||
+ override val coroutineContext = SupervisorJob() + ioDispatcher
|
||||
+}
|
||||
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
index 231c84d401..5365ab95e2 100644
|
||||
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.eygraber.uri.toAndroidUri
|
||||
import com.eygraber.uri.toKmpUri
|
||||
-import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Composable
|
||||
@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
{ uri, maxChars ->
|
||||
- withContext(Dispatchers.IO) {
|
||||
+ withContext(ioDispatcher) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val androidUri = uri.toAndroidUri()
|
||||
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
index 031e1fe35d..a938f92ea6 100644
|
||||
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
|
||||
@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import co.touchlab.kermit.Logger
|
||||
-import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
import java.awt.Desktop
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
|
||||
/** JVM — Reads text from a file URI. */
|
||||
@Composable
|
||||
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
|
||||
- withContext(Dispatchers.IO) {
|
||||
+ withContext(ioDispatcher) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val file = File(URI(uri.toString()))
|
||||
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
||||
index dc1c459716..f8ff9fcac8 100644
|
||||
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
||||
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
|
||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
|
||||
private val firmwareUpdateManager: FirmwareUpdateManager,
|
||||
private val usbManager: FirmwareUsbManager,
|
||||
private val fileHandler: FirmwareFileHandler,
|
||||
+ private val applicationScope: ApplicationCoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
|
||||
@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
|
||||
- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
|
||||
- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
|
||||
- // is cancelled concurrently.
|
||||
- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
|
||||
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
|
||||
+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
|
||||
+ // running even if something tries to cancel it mid-flight.
|
||||
+ applicationScope.launch(NonCancellable) {
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
}
|
||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
||||
index 4c48a1ced5..030d84effd 100644
|
||||
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
|
||||
@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
|
||||
firmwareUpdateManager,
|
||||
usbManager,
|
||||
fileHandler,
|
||||
+ TestApplicationCoroutineScope(testDispatcher),
|
||||
)
|
||||
|
||||
@Test
|
||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
||||
index 7032ed4088..a8eddff838 100644
|
||||
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
|
||||
@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
|
||||
firmwareUpdateManager,
|
||||
usbManager,
|
||||
fileHandler,
|
||||
+ TestApplicationCoroutineScope(testDispatcher),
|
||||
)
|
||||
|
||||
@Test
|
||||
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
|
||||
new file mode 100644
|
||||
index 0000000000..3ef5c44ef4
|
||||
--- /dev/null
|
||||
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
|
||||
@@ -0,0 +1,26 @@
|
||||
+/*
|
||||
+ * Copyright (c) 2026 Meshtastic LLC
|
||||
+ *
|
||||
+ * This program is free software: you can redistribute it and/or modify
|
||||
+ * it under the terms of the GNU General Public License as published by
|
||||
+ * the Free Software Foundation, either version 3 of the License, or
|
||||
+ * (at your option) any later version.
|
||||
+ *
|
||||
+ * This program is distributed in the hope that it will be useful,
|
||||
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
+ * GNU General Public License for more details.
|
||||
+ *
|
||||
+ * You should have received a copy of the GNU General Public License
|
||||
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
+ */
|
||||
+package org.meshtastic.feature.firmware
|
||||
+
|
||||
+import kotlinx.coroutines.CoroutineDispatcher
|
||||
+import kotlinx.coroutines.CoroutineScope
|
||||
+import kotlinx.coroutines.SupervisorJob
|
||||
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
|
||||
+
|
||||
+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
|
||||
+ ApplicationCoroutineScope,
|
||||
+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
|
||||
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
||||
index acb1545bdd..23a0d03ab2 100644
|
||||
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
||||
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
|
||||
@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
firmwareUpdateManager,
|
||||
usbManager,
|
||||
fileHandler,
|
||||
+ TestApplicationCoroutineScope(testDispatcher),
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
index c251b4d5ef..315ad1da85 100644
|
||||
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.debug_export_failed
|
||||
import org.meshtastic.core.resources.debug_export_success
|
||||
@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
||||
}
|
||||
|
||||
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
|
||||
- withContext(Dispatchers.IO) {
|
||||
+ withContext(ioDispatcher) {
|
||||
try {
|
||||
if (logs.isEmpty()) {
|
||||
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
|
||||
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
index 9afde85e5f..a28a576788 100644
|
||||
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import co.touchlab.kermit.Logger
|
||||
-import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
|
||||
@Composable
|
||||
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
|
||||
@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
|
||||
return { fileName -> exportLauncher.launch(fileName) }
|
||||
}
|
||||
|
||||
-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
|
||||
+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
|
||||
Logger.i { "TAK data package exported successfully to $targetUri" }
|
||||
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
index 5b63cc90a3..a9a7285593 100644
|
||||
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
|
||||
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
-import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
||||
return@launch
|
||||
}
|
||||
|
||||
- withContext(Dispatchers.IO) {
|
||||
+ withContext(ioDispatcher) {
|
||||
// Run file dialog to ask user where to save
|
||||
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
|
||||
fileDialog.file = fileName
|
||||
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
index 9fb71379fc..bfbb85bc0d 100644
|
||||
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
|
||||
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
-import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
+import org.meshtastic.core.common.util.ioDispatcher
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
|
||||
if (directory != null && file != null) {
|
||||
val targetFile = File(directory, file)
|
||||
val data = dataPackageProvider()
|
||||
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
|
||||
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
|
||||
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,7 @@
|
|||
# Skill: Code Review
|
||||
|
||||
## 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").
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
79
.skills/new-branch/SKILL.md
Normal file
79
.skills/new-branch/SKILL.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Skill: New Branch Bootstrap
|
||||
|
||||
## Description
|
||||
Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
|
||||
whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
|
||||
branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
|
||||
|
||||
This replaces the ad-hoc prose that used to be retyped at the start of every session.
|
||||
|
||||
## When to Use
|
||||
- Starting any new feature, fix, chore, or refactor.
|
||||
- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
|
||||
- Reproducing a CI failure from a clean baseline.
|
||||
|
||||
## Preconditions (verify before branching)
|
||||
1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
|
||||
2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
|
||||
`meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
|
||||
3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
|
||||
workspace bootstrap rules.
|
||||
4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
|
||||
(required for `google` flavor builds).
|
||||
|
||||
## Standard Recipe
|
||||
|
||||
```bash
|
||||
# 1. Fetch latest upstream
|
||||
git fetch upstream --prune --tags
|
||||
|
||||
# 2. Create the branch from upstream/main (never from a local stale main)
|
||||
git switch -c <branch-name> upstream/main
|
||||
|
||||
# 3. Ensure submodules track the new base
|
||||
git submodule update --init --recursive
|
||||
|
||||
# 4. Sanity check
|
||||
git --no-pager log -1 --oneline
|
||||
```
|
||||
|
||||
## Branch Naming
|
||||
Use conventional-commit style prefixes that match the PR title convention in AGENTS.md
|
||||
`<git_and_prs>`:
|
||||
|
||||
| Prefix | Use for |
|
||||
| :--- | :--- |
|
||||
| `feat/<scope>` | New user-visible behavior |
|
||||
| `fix/<scope>` | Bug fixes |
|
||||
| `refactor/<scope>` | Code structure changes, no behavior change |
|
||||
| `chore/<scope>` | Tooling, deps, CI, cleanup |
|
||||
| `docs/<scope>` | Documentation only |
|
||||
|
||||
Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
|
||||
|
||||
## Rebase Variant
|
||||
When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*:
|
||||
|
||||
```bash
|
||||
git fetch upstream --prune
|
||||
gh pr checkout <NNNN> # checks out the PR head locally
|
||||
git rebase upstream/main
|
||||
git submodule update --init --recursive
|
||||
# Resolve conflicts, then:
|
||||
git push --force-with-lease
|
||||
```
|
||||
|
||||
Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
|
||||
|
||||
## Post-Branch Checklist
|
||||
- [ ] Branch name follows conventional prefix.
|
||||
- [ ] Submodules up to date.
|
||||
- [ ] `local.properties` exists.
|
||||
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
|
||||
- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
|
||||
|
||||
## Tip: Prefer `/delegate` for Long Audits
|
||||
If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since
|
||||
v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider
|
||||
suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR
|
||||
end-to-end while the user keeps working locally. See AGENTS.md `<copilot_cli_workflow>`.
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
# Skill: Project Overview & Codebase Map
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
54
AGENTS.md
54
AGENTS.md
|
|
@ -18,6 +18,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
|
|||
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
|
||||
- `.skills/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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
46
Gemfile.lock
46
Gemfile.lock
|
|
@ -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
31
SOUL.md
|
|
@ -1,31 +0,0 @@
|
|||
# Meshtastic-Android: AI Agent Soul (SOUL.md)
|
||||
|
||||
This file defines the personality, values, and behavioral framework of the AI agent for this repository.
|
||||
|
||||
## 1. Core Identity
|
||||
I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack.
|
||||
|
||||
## 2. Core Truths & Values
|
||||
- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets.
|
||||
- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible.
|
||||
- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic.
|
||||
- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility.
|
||||
|
||||
## 3. Communication Style (The "Vibe")
|
||||
- **Direct & Concise:** I skip the fluff. I provide technical rationale first.
|
||||
- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions.
|
||||
- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it.
|
||||
|
||||
## 4. Operational Boundaries
|
||||
- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules.
|
||||
- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic.
|
||||
- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity.
|
||||
- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system.
|
||||
|
||||
## 5. Evolution
|
||||
I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers.
|
||||
|
||||
For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth.
|
||||
For implementation recipes and verification scope, I use `.skills/` directory.
|
||||
|
||||
|
||||
|
|
@ -171,8 +171,6 @@ configure<ApplicationExtension> {
|
|||
} else {
|
||||
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)
|
||||
|
|
|
|||
84
app/proguard-rules.pro
vendored
84
app/proguard-rules.pro
vendored
|
|
@ -1,61 +1,45 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pluginManagement {
|
|||
}
|
||||
|
||||
plugins {
|
||||
id("com.gradle.develocity") version("4.4.0")
|
||||
id("com.gradle.develocity") version("4.4.1")
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,6 @@ component_management:
|
|||
name: Desktop
|
||||
paths:
|
||||
- desktop/**
|
||||
- component_id: example
|
||||
name: Example
|
||||
paths:
|
||||
- mesh_service_example/**
|
||||
|
||||
ignore:
|
||||
- "**/build/**"
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# General Code Style Principles
|
||||
|
||||
This document outlines general coding principles that apply across all languages and frameworks used in this project.
|
||||
|
||||
## Readability
|
||||
- Code should be easy to read and understand by humans.
|
||||
- Avoid overly clever or obscure constructs.
|
||||
|
||||
## Consistency
|
||||
- Follow existing patterns in the codebase.
|
||||
- Maintain consistent formatting, naming, and structure.
|
||||
|
||||
## Simplicity
|
||||
- Prefer simple solutions over complex ones.
|
||||
- Break down complex problems into smaller, manageable parts.
|
||||
|
||||
## Maintainability
|
||||
- Write code that is easy to modify and extend.
|
||||
- Minimize dependencies and coupling.
|
||||
|
||||
## Documentation
|
||||
- Document *why* something is done, not just *what*.
|
||||
- Keep documentation up-to-date with code changes.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Project Context
|
||||
|
||||
## Definition
|
||||
- [Product Definition](./product.md)
|
||||
- [Product Guidelines](./product-guidelines.md)
|
||||
- [Tech Stack](./tech-stack.md)
|
||||
|
||||
## Workflow
|
||||
- [Workflow](./workflow.md)
|
||||
- [Code Style Guides](./code_styleguides/)
|
||||
|
||||
## Management
|
||||
- [Tracks Registry](./tracks.md)
|
||||
- [Tracks Directory](./tracks/)
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Product Guidelines
|
||||
|
||||
## Brand Voice and Tone
|
||||
- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic.
|
||||
- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety.
|
||||
- **Community-Oriented:** Encourage open-source participation and community support.
|
||||
|
||||
## UX Principles
|
||||
- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network.
|
||||
- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles.
|
||||
- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure.
|
||||
|
||||
## Prose Style
|
||||
- **Clarity over cleverness:** Use plain English.
|
||||
- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export").
|
||||
- **Consistent Terminology:**
|
||||
- Use "Node" for devices on the network.
|
||||
- Use "Channel" for communication groups.
|
||||
- Use "Direct Message" for 1-to-1 communication.
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Initial Concept
|
||||
A tool for using Android with open-source mesh radios.
|
||||
|
||||
# Product Guide
|
||||
|
||||
## Overview
|
||||
Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios.
|
||||
|
||||
## Target Audience
|
||||
- Off-grid communication enthusiasts and hobbyists
|
||||
- Outdoor adventurers needing reliable communication without cellular networks
|
||||
- Emergency response and disaster relief teams
|
||||
|
||||
## Core Features
|
||||
- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT)
|
||||
- Decentralized text messaging across the mesh network
|
||||
- Unified cross-platform notifications for messages and node events
|
||||
- Adaptive node and contact management
|
||||
- Offline map rendering and device positioning
|
||||
- Device configuration and firmware updates
|
||||
- Unified cross-platform debugging and packet inspection
|
||||
|
||||
## Key Architecture Goals
|
||||
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
|
||||
- Ensure offline-first functionality and resilient data persistence (Room 3 KMP)
|
||||
- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Tech Stack
|
||||
|
||||
## Programming Language
|
||||
- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`.
|
||||
|
||||
## Frontend Frameworks
|
||||
- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop.
|
||||
- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android.
|
||||
|
||||
## Background & Services
|
||||
- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary.
|
||||
|
||||
## Architecture
|
||||
- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`.
|
||||
- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module.
|
||||
|
||||
## Dependency Injection
|
||||
- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
|
||||
|
||||
## Database & Storage
|
||||
- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android).
|
||||
- **Jetpack DataStore:** Shared preferences.
|
||||
|
||||
## Networking & Transport
|
||||
- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
|
||||
- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
|
||||
- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target.
|
||||
- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library.
|
||||
- **Coroutines & Flows:** For asynchronous programming and state management.
|
||||
|
||||
## Testing (KMP)
|
||||
- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
|
||||
- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
|
||||
- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
|
||||
- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`).
|
||||
- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`).
|
||||
- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
|
||||
- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Project Tracks
|
||||
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
# Project Workflow
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
|
||||
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
|
||||
3. **Test-Driven Development:** Write unit tests before implementing functionality
|
||||
4. **High Code Coverage:** Aim for >80% code coverage for all modules
|
||||
5. **User Experience First:** Every decision should prioritize user experience
|
||||
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
|
||||
|
||||
## Task Workflow
|
||||
|
||||
All tasks follow a strict lifecycle:
|
||||
|
||||
### Standard Task Workflow
|
||||
|
||||
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
|
||||
|
||||
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
|
||||
|
||||
3. **Write Failing Tests (Red Phase):**
|
||||
- Create a new test file for the feature or bug fix.
|
||||
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
|
||||
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
|
||||
|
||||
4. **Implement to Pass Tests (Green Phase):**
|
||||
- Write the minimum amount of application code necessary to make the failing tests pass.
|
||||
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
|
||||
|
||||
5. **Refactor (Optional but Recommended):**
|
||||
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
|
||||
- Rerun tests to ensure they still pass after refactoring.
|
||||
|
||||
6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
|
||||
|
||||
7. **Document Deviations:** If implementation differs from tech stack:
|
||||
- **STOP** implementation
|
||||
- Update `tech-stack.md` with new design
|
||||
- Add dated note explaining the change
|
||||
- Resume implementation
|
||||
|
||||
8. **Commit Code Changes:**
|
||||
- Stage all code changes related to the task.
|
||||
- Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
|
||||
- Perform the commit.
|
||||
|
||||
9. **Attach Task Summary with Git Notes:**
|
||||
- **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
|
||||
- **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
|
||||
- **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
|
||||
```bash
|
||||
# The note content from the previous step is passed via the -m flag.
|
||||
git notes add -m "<note content>" <commit_hash>
|
||||
```
|
||||
|
||||
10. **Get and Record Task Commit SHA:**
|
||||
- **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
|
||||
- **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
11. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
|
||||
|
||||
### Phase Completion Verification and Checkpointing Protocol
|
||||
|
||||
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
|
||||
|
||||
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
|
||||
|
||||
2. **Ensure Test Coverage for Phase Changes:**
|
||||
- **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
|
||||
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` to get a precise list of all files modified during this phase.
|
||||
- **Step 2.3: Verify and Create Tests:** For each file in the list:
|
||||
- **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
|
||||
- For each remaining code file, verify a corresponding test file exists.
|
||||
- If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
|
||||
|
||||
3. **Execute Automated Tests with Proactive Debugging:**
|
||||
- Before execution, you **must** announce the exact shell command you will use to run the tests.
|
||||
- **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
|
||||
- Execute the announced command.
|
||||
- If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
|
||||
|
||||
4. **Propose a Detailed, Actionable Manual Verification Plan:**
|
||||
- **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
|
||||
- You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
|
||||
- The plan you present to the user **must** follow this format:
|
||||
|
||||
**For a Frontend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Start the development server with the command:** `npm run dev`
|
||||
2. **Open your browser to:** `http://localhost:3000`
|
||||
3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
|
||||
```
|
||||
|
||||
**For a Backend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Ensure the server is running.**
|
||||
2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
|
||||
3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
|
||||
```
|
||||
|
||||
5. **Await Explicit User Feedback:**
|
||||
- After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
|
||||
- **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
|
||||
|
||||
6. **Create Checkpoint Commit:**
|
||||
- Stage all changes. If no changes occurred in this step, proceed with an empty commit.
|
||||
- Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
|
||||
|
||||
7. **Attach Auditable Verification Report using Git Notes:**
|
||||
- **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
|
||||
- **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
|
||||
|
||||
8. **Get and Record Phase Checkpoint SHA:**
|
||||
- **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
|
||||
- **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: <sha>]`.
|
||||
- **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
9. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
|
||||
|
||||
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
Before marking any task complete, verify:
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Code coverage meets requirements (>80%)
|
||||
- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
|
||||
- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
|
||||
- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
|
||||
- [ ] No linting or static analysis errors (using the project's configured tools)
|
||||
- [ ] Works correctly on mobile (if applicable)
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] No security vulnerabilities introduced
|
||||
|
||||
## Development Commands
|
||||
|
||||
**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
|
||||
# e.g., for a Node.js project: npm install
|
||||
# e.g., for a Go project: go mod tidy
|
||||
```
|
||||
|
||||
### Daily Development
|
||||
```bash
|
||||
# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
|
||||
# e.g., for a Node.js project: npm run dev, npm test, npm run lint
|
||||
# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
|
||||
```
|
||||
|
||||
### Before Committing
|
||||
```bash
|
||||
# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
|
||||
# e.g., for a Node.js project: npm run check
|
||||
# e.g., for a Go project: make check (if a Makefile exists)
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Testing
|
||||
- Every module must have corresponding tests.
|
||||
- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
|
||||
- Mock external dependencies.
|
||||
- Test both success and failure cases.
|
||||
|
||||
### Integration Testing
|
||||
- Test complete user flows
|
||||
- Verify database transactions
|
||||
- Test authentication and authorization
|
||||
- Check form submissions
|
||||
|
||||
### Mobile Testing
|
||||
- Test on actual iPhone when possible
|
||||
- Use Safari developer tools
|
||||
- Test touch interactions
|
||||
- Verify responsive layouts
|
||||
- Check performance on 3G/4G
|
||||
|
||||
## Code Review Process
|
||||
|
||||
### Self-Review Checklist
|
||||
Before requesting review:
|
||||
|
||||
1. **Functionality**
|
||||
- Feature works as specified
|
||||
- Edge cases handled
|
||||
- Error messages are user-friendly
|
||||
|
||||
2. **Code Quality**
|
||||
- Follows style guide
|
||||
- DRY principle applied
|
||||
- Clear variable/function names
|
||||
- Appropriate comments
|
||||
|
||||
3. **Testing**
|
||||
- Unit tests comprehensive
|
||||
- Integration tests pass
|
||||
- Coverage adequate (>80%)
|
||||
|
||||
4. **Security**
|
||||
- No hardcoded secrets
|
||||
- Input validation present
|
||||
- SQL injection prevented
|
||||
- XSS protection in place
|
||||
|
||||
5. **Performance**
|
||||
- Database queries optimized
|
||||
- Images optimized
|
||||
- Caching implemented where needed
|
||||
|
||||
6. **Mobile Experience**
|
||||
- Touch targets adequate (44x44px)
|
||||
- Text readable without zooming
|
||||
- Performance acceptable on mobile
|
||||
- Interactions feel native
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
### Message Format
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation only
|
||||
- `style`: Formatting, missing semicolons, etc.
|
||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
git commit -m "feat(auth): Add remember me functionality"
|
||||
git commit -m "fix(posts): Correct excerpt generation for short posts"
|
||||
git commit -m "test(comments): Add tests for emoji reaction limits"
|
||||
git commit -m "style(mobile): Improve button touch targets"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A task is complete when:
|
||||
|
||||
1. All code implemented to specification
|
||||
2. Unit tests written and passing
|
||||
3. Code coverage meets project requirements
|
||||
4. Documentation complete (if applicable)
|
||||
5. Code passes all configured linting and static analysis checks
|
||||
6. Works beautifully on mobile (if applicable)
|
||||
7. Implementation notes added to `plan.md`
|
||||
8. Changes committed with proper message
|
||||
9. Git note with task summary attached to the commit
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Critical Bug in Production
|
||||
1. Create hotfix branch from main
|
||||
2. Write failing test for bug
|
||||
3. Implement minimal fix
|
||||
4. Test thoroughly including mobile
|
||||
5. Deploy immediately
|
||||
6. Document in plan.md
|
||||
|
||||
### Data Loss
|
||||
1. Stop all write operations
|
||||
2. Restore from latest backup
|
||||
3. Verify data integrity
|
||||
4. Document incident
|
||||
5. Update backup procedures
|
||||
|
||||
### Security Breach
|
||||
1. Rotate all secrets immediately
|
||||
2. Review access logs
|
||||
3. Patch vulnerability
|
||||
4. Notify affected users (if any)
|
||||
5. Document and update security procedures
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage >80%
|
||||
- [ ] No linting errors
|
||||
- [ ] Mobile testing complete
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrations ready
|
||||
- [ ] Backup created
|
||||
|
||||
### Deployment Steps
|
||||
1. Merge feature branch to main
|
||||
2. Tag release with version
|
||||
3. Push to deployment service
|
||||
4. Run database migrations
|
||||
5. Verify deployment
|
||||
6. Test critical paths
|
||||
7. Monitor for errors
|
||||
|
||||
### Post-Deployment
|
||||
1. Monitor analytics
|
||||
2. Check error logs
|
||||
3. Gather user feedback
|
||||
4. Plan next iteration
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
- Review workflow weekly
|
||||
- Update based on pain points
|
||||
- Document lessons learned
|
||||
- Optimize for user happiness
|
||||
- Keep things simple and maintainable
|
||||
166
config/proguard/shared-rules.pro
Normal file
166
config/proguard/shared-rules.pro
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# ============================================================================
|
||||
# Meshtastic — Shared ProGuard / R8 rules
|
||||
# ============================================================================
|
||||
# Cross-platform keep and dontwarn rules applied to BOTH the Android app
|
||||
# release build (R8) and the Desktop distribution (ProGuard). Host-specific
|
||||
# rules live in the per-module proguard-rules.pro file.
|
||||
#
|
||||
# Rule of thumb: anything describing a library shared between Android and
|
||||
# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable,
|
||||
# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries,
|
||||
# Markdown renderer, QRCode, Compose Multiplatform resources, core modules)
|
||||
# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android
|
||||
# framework, JDK-version quirks, flavor specifics) stays in the host file.
|
||||
# ============================================================================
|
||||
|
||||
# ---- Attributes -------------------------------------------------------------
|
||||
|
||||
# Preserve line numbers for meaningful stack traces, plus metadata needed for
|
||||
# reflective serializer/DI/Room lookups.
|
||||
-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations
|
||||
|
||||
# ---- Kotlin / Coroutines ----------------------------------------------------
|
||||
# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules
|
||||
# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep
|
||||
# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No
|
||||
# explicit wildcards needed here.
|
||||
|
||||
# ---- Koin DI (reflection-based injection) -----------------------------------
|
||||
|
||||
# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
|
||||
# replacing Koin's InstanceCreationException in stack traces, making crashes
|
||||
# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph.
|
||||
-keep class org.koin.** { *; }
|
||||
-dontwarn org.koin.**
|
||||
|
||||
# Keep Koin-annotated modules/components so Koin Annotations (KSP) output
|
||||
# survives tree-shaking.
|
||||
-keep @org.koin.core.annotation.Module class * { *; }
|
||||
-keep @org.koin.core.annotation.ComponentScan class * { *; }
|
||||
-keep @org.koin.core.annotation.Single class * { *; }
|
||||
-keep @org.koin.core.annotation.Factory class * { *; }
|
||||
-keep @org.koin.core.annotation.KoinViewModel class * { *; }
|
||||
|
||||
# ---- kotlinx-serialization --------------------------------------------------
|
||||
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-dontwarn kotlinx.serialization.**
|
||||
|
||||
# Keep @Serializable classes and their generated $serializer companions
|
||||
-keepclassmembers @kotlinx.serialization.Serializable class ** {
|
||||
static ** Companion;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep class **.$serializer { *; }
|
||||
-keepclassmembers class **.$serializer { *; }
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# ---- Wire Protobuf ----------------------------------------------------------
|
||||
|
||||
# Wire generates an ADAPTER static field on every Message subclass accessed
|
||||
# reflectively during encoding/decoding. Keep those fields and the
|
||||
# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve
|
||||
# the runtime itself.
|
||||
-keepclassmembers class * extends com.squareup.wire.Message {
|
||||
public static *** ADAPTER;
|
||||
}
|
||||
-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; }
|
||||
|
||||
# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs
|
||||
# when compiling for non-Android JVM targets; harmless on Android).
|
||||
-dontwarn android.os.Parcel**
|
||||
-dontwarn android.os.Parcelable**
|
||||
|
||||
# ---- Room KMP (room3) -------------------------------------------------------
|
||||
|
||||
# Preserve generated database constructors (Room uses reflection to instantiate)
|
||||
-keep class * extends androidx.room3.RoomDatabase { <init>(); }
|
||||
-keep class * implements androidx.room3.RoomDatabaseConstructor { *; }
|
||||
|
||||
# Keep the expect/actual MeshtasticDatabaseConstructor + database surface
|
||||
-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; }
|
||||
-keep class org.meshtastic.core.database.MeshtasticDatabase { *; }
|
||||
|
||||
# Room's own consumer rules (from androidx.room3) keep DAOs, entities,
|
||||
# generated _Impl classes, and TypeConverters referenced from the database.
|
||||
|
||||
# ---- SQLite bundled --------------------------------------------------------
|
||||
# androidx.sqlite ships consumer rules.
|
||||
|
||||
# ---- Ktor (ServiceLoader + plugin discovery) --------------------------------
|
||||
|
||||
# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory
|
||||
# implementations reflectively via ServiceLoader).
|
||||
-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
|
||||
|
||||
# ---- Coil 3 (image loading) -------------------------------------------------
|
||||
# coil3 ships consumer rules.
|
||||
|
||||
# ---- Kable BLE --------------------------------------------------------------
|
||||
# com.juul.kable ships consumer rules; if release builds fail with missing
|
||||
# Kable classes, restore a narrow keep for the specific reflection-loaded type.
|
||||
|
||||
# ---- Compose Multiplatform resources ----------------------------------------
|
||||
|
||||
# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.).
|
||||
# Without these the fdroid flavor has crashed at startup with a misleading
|
||||
# URLDecodeException due to R8 exception-class merging.
|
||||
-keep class org.jetbrains.compose.resources.** { *; }
|
||||
-keep class org.meshtastic.core.resources.Res { *; }
|
||||
-keepclassmembers class org.meshtastic.core.resources.Res$* { *; }
|
||||
|
||||
# ---- AboutLibraries ---------------------------------------------------------
|
||||
# com.mikepenz.aboutlibraries ships consumer rules.
|
||||
|
||||
# ---- Multiplatform Markdown Renderer ----------------------------------------
|
||||
# com.mikepenz.markdown ships consumer rules.
|
||||
|
||||
# ---- QR Code Kotlin ---------------------------------------------------------
|
||||
|
||||
-keep class io.github.g0dkar.qrcode.** { *; }
|
||||
-dontwarn io.github.g0dkar.qrcode.**
|
||||
-keep class qrcode.** { *; }
|
||||
-dontwarn qrcode.**
|
||||
|
||||
# ---- Kermit logging ---------------------------------------------------------
|
||||
# co.touchlab.kermit ships consumer rules.
|
||||
|
||||
# ---- Okio -------------------------------------------------------------------
|
||||
# okio ships consumer rules.
|
||||
|
||||
# ---- DataStore --------------------------------------------------------------
|
||||
# androidx.datastore ships consumer rules.
|
||||
|
||||
# ---- Paging -----------------------------------------------------------------
|
||||
# androidx.paging ships consumer rules.
|
||||
|
||||
# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) -----------------
|
||||
# androidx.lifecycle and androidx.navigation3 ship consumer rules.
|
||||
|
||||
# ---- Meshtastic shared model ------------------------------------------------
|
||||
# core.model types are reached via static references from Koin-wired graphs,
|
||||
# Room entities, and kotlinx-serialization @Serializable companions — all of
|
||||
# which have their own keep rules above.
|
||||
|
||||
# ---- Compose Runtime & Animation --------------------------------------------
|
||||
|
||||
# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that
|
||||
# are referenced indirectly through compiler-generated state machines. Applies
|
||||
# to BOTH R8 (Android app) and ProGuard (desktop distribution).
|
||||
#
|
||||
# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on
|
||||
# Composer.<clinit>() / ComposerImpl.<clinit>() and -assumevalues on
|
||||
# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full
|
||||
# mode on Android, ProGuard with optimize.set(true) on desktop) these call
|
||||
# sites can be rewritten even when the target classes are kept, causing the
|
||||
# recomposer / frame-clock / animation state machines to silently freeze on
|
||||
# the first frame. -dontoptimize (set per-host) is the primary defence; these
|
||||
# keep rules are a safety net against future toolchain changes. See #5146.
|
||||
-keep class androidx.compose.runtime.** { *; }
|
||||
-keep class androidx.compose.ui.** { *; }
|
||||
-keep class androidx.compose.animation.core.** { *; }
|
||||
-keep class androidx.compose.animation.** { *; }
|
||||
-keep class androidx.compose.foundation.** { *; }
|
||||
-keep class androidx.compose.material3.** { *; }
|
||||
|
|
@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
|
|||
/**
|
||||
* Classification of a BLE-layer exception for the transport layer to act on.
|
||||
*
|
||||
* @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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
actual class CommonUri(private val uri: Uri) {
|
||||
actual val host: String?
|
||||
get() = uri.host
|
||||
|
||||
actual val fragment: String?
|
||||
get() = uri.fragment
|
||||
|
||||
actual val pathSegments: List<String>
|
||||
get() = uri.pathSegments
|
||||
|
||||
actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
|
||||
|
||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
|
||||
uri.getBooleanQueryParameter(key, defaultValue)
|
||||
|
||||
actual override fun toString(): String = uri.toString()
|
||||
|
||||
actual companion object {
|
||||
actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
|
||||
}
|
||||
|
||||
fun toUri(): Uri = uri
|
||||
}
|
||||
|
||||
actual fun CommonUri.toPlatformUri(): Any = this.toUri()
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.common
|
||||
|
||||
/** Utility function to make it easy to declare byte arrays */
|
||||
fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
|
||||
|
||||
fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
|
||||
|
||||
private const val BYTE_MASK = 0xff
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.di
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
|
||||
/**
|
||||
* A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
|
||||
*
|
||||
* Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
|
||||
* (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
|
||||
* cancel siblings, and by [ioDispatcher] so work runs off the main thread.
|
||||
*
|
||||
* Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
|
||||
* and should be used sparingly.
|
||||
*/
|
||||
interface ApplicationCoroutineScope : CoroutineScope
|
||||
|
||||
@Single(binds = [ApplicationCoroutineScope::class])
|
||||
internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
|
||||
override val coroutineContext = SupervisorJob() + ioDispatcher
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* 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(":", "")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/**
|
||||
* Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
|
||||
* NodeItem, and metric screens.
|
||||
*
|
||||
* All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
|
||||
* for a mesh networking app where consistency matters.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
object MetricFormatter {
|
||||
|
||||
fun temperature(celsius: Float, isFahrenheit: Boolean): String {
|
||||
val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
|
||||
val unit = if (isFahrenheit) "°F" else "°C"
|
||||
return "${NumberFormatter.format(value, 1)}$unit"
|
||||
}
|
||||
|
||||
fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
|
||||
|
||||
fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
|
||||
"${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
|
||||
|
||||
fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
|
||||
|
||||
fun percent(value: Int): String = "$value%"
|
||||
|
||||
fun humidity(value: Float): String = percent(value, 0)
|
||||
|
||||
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
|
||||
|
||||
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
|
||||
|
||||
fun rssi(value: Int): String = "$value dBm"
|
||||
|
||||
fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String =
|
||||
"${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s"
|
||||
|
||||
fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String =
|
||||
"${NumberFormatter.format(millimeters, decimalPlaces)} mm"
|
||||
}
|
||||
|
||||
private const val FAHRENHEIT_SCALE = 1.8f
|
||||
private const val FAHRENHEIT_OFFSET = 32
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AddressUtilsTest {
|
||||
|
||||
@Test
|
||||
fun nullReturnsDefault() {
|
||||
assertEquals("DEFAULT", normalizeAddress(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankReturnsDefault() {
|
||||
assertEquals("DEFAULT", normalizeAddress(""))
|
||||
assertEquals("DEFAULT", normalizeAddress(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sentinelNReturnsDefault() {
|
||||
assertEquals("DEFAULT", normalizeAddress("N"))
|
||||
assertEquals("DEFAULT", normalizeAddress("n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sentinelNullReturnsDefault() {
|
||||
assertEquals("DEFAULT", normalizeAddress("NULL"))
|
||||
assertEquals("DEFAULT", normalizeAddress("null"))
|
||||
assertEquals("DEFAULT", normalizeAddress("Null"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsColons() {
|
||||
assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uppercases() {
|
||||
assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimsWhitespace() {
|
||||
assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyNormalizedPassesThrough() {
|
||||
assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedCaseWithColons() {
|
||||
assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
|
||||
}
|
||||
}
|
||||
|
|
@ -18,27 +18,26 @@ package org.meshtastic.core.common.util
|
|||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CommonUriTest {
|
||||
|
||||
@Test
|
||||
fun testParse() {
|
||||
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MetricFormatterTest {
|
||||
|
||||
@Test
|
||||
fun temperatureCelsius() {
|
||||
assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureFahrenheit() {
|
||||
assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureNegative() {
|
||||
assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voltage() {
|
||||
assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voltageOneDecimal() {
|
||||
assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun current() {
|
||||
assertEquals("150.3 mA", MetricFormatter.current(150.3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentFloat() {
|
||||
assertEquals("85.5%", MetricFormatter.percent(85.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentInt() {
|
||||
assertEquals("85%", MetricFormatter.percent(85))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun humidity() {
|
||||
assertEquals("65%", MetricFormatter.humidity(65.4f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pressure() {
|
||||
assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snr() {
|
||||
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rssi() {
|
||||
assertEquals("-90 dBm", MetricFormatter.rssi(-90))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureFreezingFahrenheit() {
|
||||
assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureBoilingFahrenheit() {
|
||||
assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voltageZero() {
|
||||
assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentZero() {
|
||||
assertEquals("0.0 mA", MetricFormatter.current(0.0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentZero() {
|
||||
assertEquals("0%", MetricFormatter.percent(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentHundred() {
|
||||
assertEquals("100%", MetricFormatter.percent(100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rssiZero() {
|
||||
assertEquals("0 dBm", MetricFormatter.rssi(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snrNegative() {
|
||||
assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windSpeed() {
|
||||
assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windSpeedZero() {
|
||||
assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rainfall() {
|
||||
assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rainfallZero() {
|
||||
assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/**
|
||||
* Apple (iOS) implementation of string formatting.
|
||||
*
|
||||
* Implements a subset of Java's `String.format()` patterns used in this codebase:
|
||||
* - `%s`, `%d` — positional or sequential string/integer
|
||||
* - `%N$s`, `%N$d` — explicit positional string/integer
|
||||
* - `%N$.Nf`, `%.Nf` — float with decimal precision
|
||||
* - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
|
||||
* - `%%` — literal percent
|
||||
*
|
||||
* This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
|
||||
*/
|
||||
actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
|
||||
var i = 0
|
||||
var autoIndex = 0
|
||||
while (i < pattern.length) {
|
||||
if (pattern[i] != '%') {
|
||||
append(pattern[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
i++ // skip '%'
|
||||
if (i >= pattern.length) break
|
||||
|
||||
// Literal %%
|
||||
if (pattern[i] == '%') {
|
||||
append('%')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse optional positional index (N$)
|
||||
var explicitIndex: Int? = null
|
||||
val startPos = i
|
||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||
if (i < pattern.length && pattern[i] == '$' && i > startPos) {
|
||||
explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
|
||||
i++ // skip '$'
|
||||
} else {
|
||||
i = startPos // rewind — digits are part of width/precision, not positional index
|
||||
}
|
||||
|
||||
// Parse optional flags (zero-pad)
|
||||
var zeroPad = false
|
||||
if (i < pattern.length && pattern[i] == '0') {
|
||||
zeroPad = true
|
||||
i++
|
||||
}
|
||||
|
||||
// Parse optional width
|
||||
var width: Int? = null
|
||||
val widthStart = i
|
||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||
if (i > widthStart) {
|
||||
width = pattern.substring(widthStart, i).toInt()
|
||||
}
|
||||
|
||||
// Parse optional precision (.N)
|
||||
var precision: Int? = null
|
||||
if (i < pattern.length && pattern[i] == '.') {
|
||||
i++ // skip '.'
|
||||
val precStart = i
|
||||
while (i < pattern.length && pattern[i].isDigit()) i++
|
||||
if (i > precStart) {
|
||||
precision = pattern.substring(precStart, i).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
// Parse conversion character
|
||||
if (i >= pattern.length) break
|
||||
val conversion = pattern[i]
|
||||
i++
|
||||
|
||||
val argIndex = explicitIndex ?: autoIndex++
|
||||
val arg = args.getOrNull(argIndex)
|
||||
|
||||
when (conversion) {
|
||||
's' -> append(arg?.toString() ?: "null")
|
||||
'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
|
||||
'f' -> {
|
||||
val value = (arg as? Number)?.toDouble() ?: 0.0
|
||||
val places = precision ?: DEFAULT_FLOAT_PRECISION
|
||||
append(NumberFormatter.format(value, places))
|
||||
}
|
||||
'x',
|
||||
'X',
|
||||
-> {
|
||||
val value = (arg as? Number)?.toLong() ?: 0L
|
||||
// Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
|
||||
val masked = if (arg is Int) value and INT_MASK else value
|
||||
var hex = masked.toString(HEX_RADIX)
|
||||
if (conversion == 'X') hex = hex.uppercase()
|
||||
val padChar = if (zeroPad) '0' else ' '
|
||||
val padWidth = width ?: 0
|
||||
append(hex.padStart(padWidth, padChar))
|
||||
}
|
||||
else -> {
|
||||
// Unknown conversion — reproduce original token
|
||||
append('%')
|
||||
if (explicitIndex != null) append("${explicitIndex + 1}$")
|
||||
if (zeroPad) append('0')
|
||||
if (width != null) append(width)
|
||||
if (precision != null) append(".$precision")
|
||||
append(conversion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_FLOAT_PRECISION = 6
|
||||
private const val HEX_RADIX = 16
|
||||
private const val INT_MASK = 0xFFFFFFFFL
|
||||
|
|
@ -22,20 +22,6 @@ actual object BuildUtils {
|
|||
actual val sdkInt: Int = 0
|
||||
}
|
||||
|
||||
actual 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 = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** JVM/Android implementation of string formatting. */
|
||||
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import java.net.URI
|
||||
|
||||
actual class CommonUri(private val uri: URI) {
|
||||
private val queryParameters: Map<String, List<String>> by lazy { parseQueryParameters(uri.rawQuery) }
|
||||
|
||||
actual val host: String?
|
||||
get() = uri.host
|
||||
|
||||
actual val fragment: String?
|
||||
get() = uri.fragment
|
||||
|
||||
actual val pathSegments: List<String>
|
||||
get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
|
||||
|
||||
actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
|
||||
|
||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
|
||||
val value = getQueryParameter(key) ?: return defaultValue
|
||||
return value != "false" && value != "0"
|
||||
}
|
||||
|
||||
actual override fun toString(): String = uri.toString()
|
||||
|
||||
actual companion object {
|
||||
actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
|
||||
}
|
||||
|
||||
fun toUri(): URI = uri
|
||||
}
|
||||
|
||||
actual fun CommonUri.toPlatformUri(): Any = this.toUri()
|
||||
|
|
@ -17,9 +17,6 @@
|
|||
package org.meshtastic.core.common.util
|
||||
|
||||
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}${'$'}")
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class DeviceHardwareJsonDataSourceImpl(private val application: Application) : D
|
|||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
exceptionsWithDebugInfo = false
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class FirmwareReleaseJsonDataSourceImpl(private val application: Application) :
|
|||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
exceptionsWithDebugInfo = false
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* Centralized heartbeat sender for the data layer.
|
||||
*
|
||||
* Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
|
||||
* per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
|
||||
*
|
||||
* This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
|
||||
* with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
|
||||
*/
|
||||
@Single
|
||||
class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
|
||||
private val nonce = atomic(0)
|
||||
|
||||
/**
|
||||
* Enqueues a heartbeat with a unique nonce.
|
||||
*
|
||||
* @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun sendHeartbeat(tag: String = "handshake") {
|
||||
try {
|
||||
val n = nonce.incrementAndGet()
|
||||
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
|
||||
Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
|
|||
import co.touchlab.kermit.Logger
|
||||
import 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1→2 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)" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (~10–50ms) while adding
|
||||
* negligible connection latency.
|
||||
*/
|
||||
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
|
||||
|
||||
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
|
||||
|
||||
/**
|
||||
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
|
||||
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
|
||||
* nodes.
|
||||
*/
|
||||
private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
|
||||
|
||||
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
||||
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds
|
|||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue