mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-15 19:38:15 -04:00
chore: KMP audit — commonize code, centralize utilities, eliminate dead abstractions (#5133)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -13,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).
|
||||
@@ -36,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
|
||||
@@ -47,7 +50,7 @@ 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.
|
||||
|
||||
@@ -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,7 +33,7 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,6 +15,13 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
|
||||
- **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).
|
||||
- **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 correct canonical startup for this path is:
|
||||
```kotlin
|
||||
startKoin { modules(AppKoinModule().module()) }
|
||||
```
|
||||
Do **not** use `@KoinApplication` — that annotation is part of the **KSP annotations path** (`koin-ksp-compiler`) and generates a `startKoin()` extension via KSP. It is incompatible with the K2 plugin approach. The two paths are mutually exclusive; the project has deliberately chosen K2 for compile-time wiring without KSP overhead.
|
||||
|
||||
## Navigation 3
|
||||
|
||||
### Guidelines
|
||||
|
||||
@@ -73,6 +73,11 @@ Agents **MUST** perform these steps automatically at the start of every session
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
|
||||
```bash
|
||||
[ -f local.properties ] || cp secrets.defaults.properties local.properties
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
|
||||
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
|
||||
|
||||
@@ -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.
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -25,11 +25,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>
|
||||
@@ -57,9 +62,10 @@ Do NOT duplicate content into agent-specific files. When you modify architecture
|
||||
|
||||
<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.
|
||||
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
@@ -57,7 +58,6 @@ 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.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -278,7 +278,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
|
||||
@@ -57,7 +58,7 @@ open class MeshUtilApplication :
|
||||
Application(),
|
||||
Configuration.Provider {
|
||||
|
||||
private val applicationScope = CoroutineScope(Dispatchers.Default)
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -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,11 +33,13 @@ 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
|
||||
@@ -47,6 +51,7 @@ 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 {
|
||||
@@ -67,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 {
|
||||
@@ -80,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()
|
||||
|
||||
@@ -87,6 +98,7 @@ 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
|
||||
|
||||
@@ -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")) }
|
||||
|
||||
@@ -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) 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
|
||||
|
||||
/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */
|
||||
fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString)
|
||||
|
||||
/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */
|
||||
fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString())
|
||||
@@ -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
|
||||
@@ -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,26 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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,53 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
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"
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,25 @@ package org.meshtastic.core.common.util
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MeshtasticUriTest {
|
||||
class CommonUriTest {
|
||||
@Test
|
||||
fun testParseAndToString() {
|
||||
val uriString = "content://com.example.provider/file.txt"
|
||||
val uri = MeshtasticUri.parse(uriString)
|
||||
val uri = CommonUri.parse(uriString)
|
||||
assertEquals(uriString, uri.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
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,123 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
@@ -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}${'$'}")
|
||||
|
||||
|
||||
@@ -1,44 +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
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -93,7 +94,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)
|
||||
|
||||
@@ -289,7 +289,7 @@ class MeshConnectionManagerImpl(
|
||||
|
||||
override fun onRadioConfigLoaded() {
|
||||
scope.handledLaunch {
|
||||
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
|
||||
val queuedPackets = packetRepository.getQueuedPackets()
|
||||
queuedPackets.forEach { packet ->
|
||||
try {
|
||||
workerManager.enqueueSendMessage(packet.id)
|
||||
|
||||
@@ -96,7 +96,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -108,7 +108,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) =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -35,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,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
|
||||
@@ -297,11 +306,77 @@ interface NodeInfoDao {
|
||||
@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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
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.Update
|
||||
@@ -326,8 +328,15 @@ interface PacketDao {
|
||||
)
|
||||
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
|
||||
|
||||
@Transaction
|
||||
suspend fun getQueuedPackets(): List<DataPacket>? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
|
||||
@Query(
|
||||
"""
|
||||
SELECT data FROM packet
|
||||
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
|
||||
AND json_extract(data, '${"$"}.status') = 'QUEUED'
|
||||
ORDER BY received_time ASC
|
||||
""",
|
||||
)
|
||||
suspend fun getQueuedPackets(): List<DataPacket>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
@@ -359,23 +368,24 @@ interface PacketDao {
|
||||
|
||||
@Upsert suspend fun upsertContactSettings(contacts: List<ContactSettings>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertContactSettingsIgnore(contacts: List<ContactSettings>)
|
||||
|
||||
@Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
|
||||
suspend fun updateMuteUntil(contactKeys: List<String>, muteUntil: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
|
||||
val contactList = contacts.map { contact ->
|
||||
// Always mute
|
||||
val absoluteMuteUntil =
|
||||
if (until == Long.MAX_VALUE) {
|
||||
Long.MAX_VALUE
|
||||
} else if (until == 0L) { // unmute
|
||||
0L
|
||||
} else {
|
||||
nowMillis + until
|
||||
}
|
||||
|
||||
getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)
|
||||
?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil)
|
||||
}
|
||||
upsertContactSettings(contactList)
|
||||
val absoluteMuteUntil =
|
||||
when {
|
||||
until == Long.MAX_VALUE -> Long.MAX_VALUE
|
||||
until == 0L -> 0L
|
||||
else -> nowMillis + until
|
||||
}
|
||||
// Ensure rows exist for all contacts (IGNORE avoids overwriting existing data)
|
||||
insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) })
|
||||
// Atomic column-level update — no read-then-write race
|
||||
updateMuteUntil(contacts, absoluteMuteUntil)
|
||||
}
|
||||
|
||||
@Upsert suspend fun insert(reaction: ReactionEntity)
|
||||
@@ -479,9 +489,10 @@ interface PacketDao {
|
||||
val indexMap =
|
||||
oldSettings
|
||||
.mapIndexed { oldIndex, oldChannel ->
|
||||
val pskMatches = newSettings.mapIndexedNotNull { index, channel ->
|
||||
if (channel.psk == oldChannel.psk) index to channel else null
|
||||
}
|
||||
val pskMatches =
|
||||
newSettings.mapIndexedNotNull { index, channel ->
|
||||
if (channel.psk == oldChannel.psk) index to channel else null
|
||||
}
|
||||
|
||||
val newIndex =
|
||||
when {
|
||||
|
||||
@@ -118,6 +118,7 @@ data class MetadataEntity(
|
||||
Index(value = ["hops_away"]),
|
||||
Index(value = ["is_favorite"]),
|
||||
Index(value = ["last_heard", "is_favorite"]),
|
||||
Index(value = ["public_key"]),
|
||||
],
|
||||
)
|
||||
data class NodeEntity(
|
||||
|
||||
@@ -74,6 +74,9 @@ data class PacketEntity(
|
||||
Index(value = ["contact_key"]),
|
||||
Index(value = ["contact_key", "port_num", "received_time"]),
|
||||
Index(value = ["packet_id"]),
|
||||
Index(value = ["received_time"]),
|
||||
Index(value = ["filtered"]),
|
||||
Index(value = ["read"]),
|
||||
],
|
||||
)
|
||||
data class Packet(
|
||||
@@ -98,9 +101,12 @@ data class Packet(
|
||||
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
|
||||
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
|
||||
|
||||
val candidateRelayNodes = nodes.filter {
|
||||
it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
|
||||
}
|
||||
val candidateRelayNodes =
|
||||
nodes.filter {
|
||||
it.num != ourNodeNum &&
|
||||
it.lastHeard != 0 &&
|
||||
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
|
||||
}
|
||||
|
||||
val closestRelayNode =
|
||||
if (candidateRelayNodes.size == 1) {
|
||||
|
||||
@@ -1,51 +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.model.util
|
||||
|
||||
import org.meshtastic.core.common.util.nowInstant
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import java.text.DateFormat
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
private val DAY_DURATION = 24.hours
|
||||
|
||||
/**
|
||||
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
|
||||
* representing the date.
|
||||
*
|
||||
* @param time The time in milliseconds
|
||||
* @return Formatted date or time string, or null if time is 0
|
||||
*/
|
||||
fun getShortDate(time: Long): String? {
|
||||
if (time == 0L) return null
|
||||
val instant = time.toInstant()
|
||||
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
|
||||
|
||||
return if (isWithin24Hours) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
|
||||
} else {
|
||||
DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the remaining mute time in days and hours.
|
||||
*
|
||||
* @param remainingMillis The remaining time in milliseconds
|
||||
* @return Pair of (days, hours), where days is Int and hours is Double
|
||||
*/
|
||||
@@ -17,12 +17,13 @@
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.eygraber.uri.toKmpUri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
|
||||
fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
|
||||
fun Uri.toCommonUri(): CommonUri = this.toKmpUri()
|
||||
|
||||
/** Bridge extension for Android clients. */
|
||||
fun Uri.dispatchMeshtasticUri(
|
||||
|
||||
@@ -19,10 +19,9 @@ package org.meshtastic.core.model
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.GPSFormat
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.formatString
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.proto.Config
|
||||
@@ -143,34 +142,26 @@ data class Node(
|
||||
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
|
||||
val temp =
|
||||
if ((temperature ?: 0f) != 0f) {
|
||||
if (isFahrenheit) {
|
||||
formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
|
||||
} else {
|
||||
formatString("%.1f°C", temperature)
|
||||
}
|
||||
MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
|
||||
val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
|
||||
val soilTemperatureStr =
|
||||
if ((soil_temperature ?: 0f) != 0f) {
|
||||
if (isFahrenheit) {
|
||||
formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
|
||||
} else {
|
||||
formatString("%.1f°C", soil_temperature)
|
||||
}
|
||||
MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val soilMoistureRange = 0..100
|
||||
val soilMoisture =
|
||||
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
|
||||
formatString("%d%%", soil_moisture)
|
||||
MetricFormatter.percent(soil_moisture ?: 0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
|
||||
val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
|
||||
val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null
|
||||
val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null
|
||||
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
|
||||
|
||||
return listOfNotNull(
|
||||
@@ -199,9 +190,12 @@ data class Node(
|
||||
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
|
||||
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
|
||||
|
||||
val candidateRelayNodes = nodes.filter {
|
||||
it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
|
||||
}
|
||||
val candidateRelayNodes =
|
||||
nodes.filter {
|
||||
it.num != ourNodeNum &&
|
||||
it.lastHeard != 0 &&
|
||||
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
|
||||
}
|
||||
|
||||
val closestRelayNode =
|
||||
if (candidateRelayNodes.size == 1) {
|
||||
|
||||
@@ -32,7 +32,7 @@ val Any?.anonymize: String
|
||||
get() = this.anonymize()
|
||||
|
||||
/** A version of anonymize that allows passing in a custom minimum length */
|
||||
fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
|
||||
fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null"
|
||||
|
||||
// A toString that makes sure all newlines are removed (for nice logging).
|
||||
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
|
||||
|
||||
@@ -16,7 +16,27 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
|
||||
expect object SfppHasher {
|
||||
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
|
||||
object SfppHasher {
|
||||
private const val HASH_SIZE = 16
|
||||
private const val INT_BYTES = 4
|
||||
private const val INT_COUNT = 3
|
||||
private const val SHIFT_8 = 8
|
||||
private const val SHIFT_16 = 16
|
||||
private const val SHIFT_24 = 24
|
||||
|
||||
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
|
||||
val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
|
||||
encryptedPayload.copyInto(input)
|
||||
var offset = encryptedPayload.size
|
||||
for (value in intArrayOf(to, from, id)) {
|
||||
input[offset++] = value.toByte()
|
||||
input[offset++] = (value shr SHIFT_8).toByte()
|
||||
input[offset++] = (value shr SHIFT_16).toByte()
|
||||
input[offset++] = (value shr SHIFT_24).toByte()
|
||||
}
|
||||
return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
|
||||
return if (changes.isEmpty()) {
|
||||
"No changes detected."
|
||||
} else {
|
||||
"Changes:\n" + changes.joinToString("\n")
|
||||
"Changes:\n${changes.joinToString("\n")}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
* 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
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ByteUtilsTest {
|
||||
class CommonUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testByteArrayOfInts() {
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.model.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
class SfppHasherTest {
|
||||
|
||||
@Test
|
||||
fun outputIsAlways16Bytes() {
|
||||
val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1)
|
||||
assertEquals(16, hash.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyPayloadProduces16Bytes() {
|
||||
val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0)
|
||||
assertEquals(16, hash.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deterministicOutput() {
|
||||
val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
|
||||
val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
|
||||
assertEquals(a.toList(), b.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentPayloadsProduceDifferentHashes() {
|
||||
val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3)
|
||||
val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3)
|
||||
assertNotEquals(a.toList(), b.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentIdsProduceDifferentHashes() {
|
||||
val payload = byteArrayOf(0x10, 0x20)
|
||||
val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100)
|
||||
val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101)
|
||||
assertNotEquals(a.toList(), b.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentFromProduceDifferentHashes() {
|
||||
val payload = byteArrayOf(0x10, 0x20)
|
||||
val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3)
|
||||
val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3)
|
||||
assertNotEquals(a.toList(), b.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxIntValues() {
|
||||
val hash =
|
||||
SfppHasher.computeMessageHash(
|
||||
byteArrayOf(0xFF.toByte()),
|
||||
to = Int.MAX_VALUE,
|
||||
from = Int.MAX_VALUE,
|
||||
id = Int.MAX_VALUE,
|
||||
)
|
||||
assertEquals(16, hash.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun littleEndianByteOrder() {
|
||||
// Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian)
|
||||
val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0)
|
||||
val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0)
|
||||
// Different byte orderings must produce different hashes
|
||||
assertNotEquals(hashA.toList(), hashB.toList())
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,3 @@ package org.meshtastic.core.model.util
|
||||
actual fun getShortDateTime(time: Long): String = ""
|
||||
|
||||
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
|
||||
|
||||
actual object SfppHasher {
|
||||
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
|
||||
}
|
||||
|
||||
@@ -1,35 +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.model.util
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
|
||||
actual object SfppHasher {
|
||||
private const val HASH_SIZE = 16
|
||||
private const val INT_BYTES = 4
|
||||
|
||||
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(encryptedPayload)
|
||||
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
|
||||
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
|
||||
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
|
||||
return digest.digest().copyOf(HASH_SIZE)
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,7 @@ object HttpClientDefaults {
|
||||
|
||||
/** Maximum number of automatic retries on server errors (5xx). */
|
||||
const val MAX_RETRIES = 3
|
||||
|
||||
/** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */
|
||||
const val API_BASE_URL = "https://api.meshtastic.org/"
|
||||
}
|
||||
|
||||
@@ -326,8 +326,8 @@ class MockRadioTransport(
|
||||
user =
|
||||
User(
|
||||
id = DataPacket.nodeNumToDefaultId(numIn),
|
||||
long_name = "Sim " + numIn.toString(16),
|
||||
short_name = getInitials("Sim " + numIn.toString(16)),
|
||||
long_name = "Sim ${numIn.toString(16)}",
|
||||
short_name = getInitials("Sim ${numIn.toString(16)}"),
|
||||
hw_model = HardwareModel.ANDROID_SIM,
|
||||
),
|
||||
position =
|
||||
|
||||
@@ -35,14 +35,14 @@ interface ApiService {
|
||||
/**
|
||||
* Ktor-based [ApiService] implementation.
|
||||
*
|
||||
* Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules.
|
||||
*
|
||||
* Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`)
|
||||
* provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines.
|
||||
*/
|
||||
@Single(binds = [])
|
||||
class ApiServiceImpl(private val client: HttpClient) : ApiService {
|
||||
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> =
|
||||
client.get("https://api.meshtastic.org/resource/deviceHardware").body()
|
||||
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = client.get("resource/deviceHardware").body()
|
||||
|
||||
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
|
||||
client.get("https://api.meshtastic.org/github/firmware/list").body()
|
||||
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
@@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent
|
||||
import javax.jmdns.ServiceListener
|
||||
|
||||
@Single
|
||||
class JvmServiceDiscovery : ServiceDiscovery {
|
||||
class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override val resolvedServices: Flow<List<DiscoveredService>> =
|
||||
callbackFlow {
|
||||
@@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.flowOn(dispatchers.io)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -17,16 +17,23 @@
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JvmServiceDiscoveryTest {
|
||||
|
||||
private val testDispatchers =
|
||||
UnconfinedTestDispatcher().let { dispatcher ->
|
||||
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvedServices emits initial empty list immediately`() = runTest {
|
||||
val discovery = JvmServiceDiscovery()
|
||||
val discovery = JvmServiceDiscovery(testDispatchers)
|
||||
discovery.resolvedServices.test {
|
||||
val first = awaitItem()
|
||||
assertNotNull(first, "First emission should not be null")
|
||||
|
||||
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.normalizeAddress
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.prefs.cachedFlow
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
@@ -95,15 +96,6 @@ class MeshPrefsImpl(
|
||||
|
||||
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
|
||||
|
||||
private fun normalizeAddress(address: String?): String {
|
||||
val raw = address?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return when {
|
||||
raw == null -> "DEFAULT"
|
||||
raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
|
||||
else -> raw.uppercase().replace(":", "")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ package org.meshtastic.core.repository
|
||||
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
|
||||
/**
|
||||
* Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain
|
||||
@@ -29,11 +29,11 @@ interface FileService {
|
||||
* Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block]
|
||||
* execution. Returns true if successful, false otherwise.
|
||||
*/
|
||||
suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean
|
||||
suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean
|
||||
|
||||
/**
|
||||
* Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block]
|
||||
* execution. Returns true if successful, false otherwise.
|
||||
*/
|
||||
suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean
|
||||
suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ interface PacketRepository {
|
||||
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long)
|
||||
|
||||
/** Returns all packets currently queued for transmission. */
|
||||
suspend fun getQueuedPackets(): List<DataPacket>?
|
||||
suspend fun getQueuedPackets(): List<DataPacket>
|
||||
|
||||
/**
|
||||
* Persists a packet in the database.
|
||||
|
||||
@@ -384,9 +384,9 @@
|
||||
<string name="battery">Battery</string>
|
||||
<string name="channel_utilization">ChUtil</string>
|
||||
<string name="air_utilization">AirUtil</string>
|
||||
<string name="device_metrics_percent_value">%1$s: %2$.1f%%</string>
|
||||
<string name="device_metrics_voltage_value">%1$s: %2$.1f V</string>
|
||||
<string name="device_metrics_numeric_value">%1$.1f</string>
|
||||
<string name="device_metrics_percent_value">%1$s: %2$s%%</string>
|
||||
<string name="device_metrics_voltage_value">%1$s: %2$s V</string>
|
||||
<string name="device_metrics_numeric_value">%1$s</string>
|
||||
<string name="device_metrics_label_value">%1$s: %2$s</string>
|
||||
<string name="temperature">Temp</string>
|
||||
<string name="humidity">Hum</string>
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
@@ -27,10 +29,15 @@ import kotlin.test.assertNotNull
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AndroidFileServiceTest {
|
||||
private val testDispatchers =
|
||||
UnconfinedTestDispatcher().let { dispatcher ->
|
||||
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialization() = runTest {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val service = AndroidFileService(context)
|
||||
val service = AndroidFileService(context, testDispatchers)
|
||||
assertNotNull(service)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ package org.meshtastic.core.service
|
||||
|
||||
import android.app.Application
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.eygraber.uri.toAndroidUri
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
@@ -26,15 +26,16 @@ import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.common.util.toAndroidUri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.FileService
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Single
|
||||
class AndroidFileService(private val context: Application) : FileService {
|
||||
override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) :
|
||||
FileService {
|
||||
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt")
|
||||
if (pfd == null) {
|
||||
@@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
val success =
|
||||
context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream ->
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
@@ -25,17 +24,18 @@ import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.FileService
|
||||
import java.io.File
|
||||
|
||||
@Single
|
||||
class JvmFileService : FileService {
|
||||
override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService {
|
||||
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
// Treat uriString as a local file path
|
||||
val file = File(uri.uriString)
|
||||
// Treat URI string as a local file path
|
||||
val file = File(uri.toString())
|
||||
file.parentFile?.mkdirs()
|
||||
file.sink().buffer().use { sink -> block(sink) }
|
||||
true
|
||||
@@ -45,10 +45,10 @@ class JvmFileService : FileService {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
val file = File(uri.uriString)
|
||||
val file = File(uri.toString())
|
||||
file.source().buffer().use { source -> block(source) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -20,47 +20,41 @@ package org.meshtastic.core.takserver
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun CoTMessage.toXml(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append(
|
||||
fun CoTMessage.toXml(): String = buildString {
|
||||
append(
|
||||
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?><event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
|
||||
)
|
||||
|
||||
contact?.let {
|
||||
sb.append(
|
||||
append(
|
||||
"<contact endpoint='${it.endpoint ?: DEFAULT_TAK_ENDPOINT}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
|
||||
)
|
||||
}
|
||||
|
||||
group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
|
||||
group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
|
||||
|
||||
status?.let { sb.append("<status battery='${it.battery}'/>") }
|
||||
status?.let { append("<status battery='${it.battery}'/>") }
|
||||
|
||||
track?.let { sb.append("<track course='${it.course}' speed='${it.speed}'/>") }
|
||||
track?.let { append("<track course='${it.course}' speed='${it.speed}'/>") }
|
||||
|
||||
if (chat != null) {
|
||||
val senderUid = uid.geoChatSenderUid()
|
||||
val messageId = uid.geoChatMessageId()
|
||||
sb.append(
|
||||
append(
|
||||
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
|
||||
)
|
||||
sb.append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
|
||||
sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
|
||||
sb.append(
|
||||
append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
|
||||
append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
|
||||
append(
|
||||
"<remarks source='BAO.F.ATAK.${senderUid.xmlEscaped()}' to='${chat.chatroom.xmlEscaped()}' time='${time.toXmlString()}'>${chat.message.xmlEscaped()}</remarks>",
|
||||
)
|
||||
} else if (!remarks.isNullOrEmpty()) {
|
||||
sb.append("<remarks>${remarks.xmlEscaped()}</remarks>")
|
||||
append("<remarks>${remarks.xmlEscaped()}</remarks>")
|
||||
}
|
||||
|
||||
rawDetailXml?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
sb.append(it)
|
||||
}
|
||||
}
|
||||
rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
|
||||
|
||||
sb.append("</detail></event>")
|
||||
return sb.toString()
|
||||
append("</detail></event>")
|
||||
}
|
||||
|
||||
private fun Instant.toXmlString(): String = this.toString()
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
internal expect object ZlibCodec {
|
||||
fun compress(data: ByteArray): ByteArray?
|
||||
|
||||
fun decompress(data: ByteArray): ByteArray?
|
||||
}
|
||||
|
||||
internal expect object CryptoCodec {
|
||||
fun sha256Prefix8(data: ByteArray): ByteArray
|
||||
internal object CryptoCodec {
|
||||
private const val PREFIX_SIZE = 8
|
||||
|
||||
fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.cinterop.value
|
||||
import platform.CoreCrypto.CC_SHA256
|
||||
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
|
||||
import platform.zlib.Z_BUF_ERROR
|
||||
import platform.zlib.Z_OK
|
||||
import platform.zlib.compress
|
||||
@@ -105,20 +103,3 @@ internal actual object ZlibCodec {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
internal actual object CryptoCodec {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun sha256Prefix8(data: ByteArray): ByteArray {
|
||||
val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
|
||||
if (data.isNotEmpty()) {
|
||||
data.usePinned { dataPin ->
|
||||
digest.usePinned { digestPin ->
|
||||
CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
|
||||
}
|
||||
return digest.copyOf(8)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.Inflater
|
||||
|
||||
@@ -66,10 +65,3 @@ internal actual object ZlibCodec {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal actual object CryptoCodec {
|
||||
actual fun sha256Prefix8(data: ByteArray): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
return digest.digest(data).copyOf(8)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ package org.meshtastic.core.ui.util
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -36,13 +35,14 @@ import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.MeshtasticUri
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Composable
|
||||
@@ -107,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit {
|
||||
@Composable
|
||||
@Suppress("Wrapping")
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit {
|
||||
val launcher =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) })
|
||||
}
|
||||
result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +135,7 @@ actual fun rememberSaveFileLauncher(
|
||||
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
onUriReceived(uri?.let { CommonUri(it) })
|
||||
onUriReceived(uri?.let { it.toKmpUri() })
|
||||
}
|
||||
return remember(launcher) { { mimeType -> launcher.launch(mimeType) } }
|
||||
}
|
||||
@@ -151,7 +149,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val androidUri = Uri.parse(uri.toString())
|
||||
val androidUri = uri.toAndroidUri()
|
||||
context.contentResolver.openInputStream(androidUri)?.use { stream ->
|
||||
stream.bufferedReader().use { reader ->
|
||||
val buffer = CharArray(maxChars)
|
||||
|
||||
@@ -62,12 +62,13 @@ fun <T : Enum<T>> DropDownPreference(
|
||||
enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() }
|
||||
}
|
||||
|
||||
val items = enumConstants.map {
|
||||
val label = itemLabel?.invoke(it) ?: it.name
|
||||
val icon = itemIcon?.invoke(it)
|
||||
val color = itemColor?.invoke(it)
|
||||
DropDownItem(it, label, icon, color)
|
||||
}
|
||||
val items =
|
||||
enumConstants.map {
|
||||
val label = itemLabel?.invoke(it) ?: it.name
|
||||
val icon = itemIcon?.invoke(it)
|
||||
val color = itemColor?.invoke(it)
|
||||
DropDownItem(it, label, icon, color)
|
||||
}
|
||||
|
||||
DropDownPreference(
|
||||
title = title,
|
||||
|
||||
@@ -23,7 +23,7 @@ import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -49,7 +49,7 @@ fun EditPasswordPreference(
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isPasswordVisible by remember { mutableStateOf(false) }
|
||||
var isPasswordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -90,10 +91,10 @@ fun MeshtasticImportFAB(
|
||||
) {
|
||||
sharedContact?.let { importDialog(it, onDismissSharedContact) }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showUrlDialog by remember { mutableStateOf(false) }
|
||||
var isNfcScanning by remember { mutableStateOf(false) }
|
||||
var showNfcDisabledDialog by remember { mutableStateOf(false) }
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
var showUrlDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var isNfcScanning by rememberSaveable { mutableStateOf(false) }
|
||||
var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val openNfcSettings = rememberOpenNfcSettings()
|
||||
|
||||
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } }
|
||||
|
||||
@@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
import org.meshtastic.core.common.util.formatString
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.bad
|
||||
import org.meshtastic.core.resources.fair
|
||||
@@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) {
|
||||
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr),
|
||||
text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
@@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
|
||||
}
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi),
|
||||
text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.formatString
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.unknown
|
||||
import org.meshtastic.core.ui.icon.BatteryEmpty
|
||||
@@ -49,7 +49,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
private const val FORMAT = "%d%%"
|
||||
private const val SIZE_ICON = 16
|
||||
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
@@ -60,7 +59,7 @@ fun MaterialBatteryInfo(
|
||||
voltage: Float? = null,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
val levelString = formatString(FORMAT, level)
|
||||
val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown)
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
@@ -130,7 +129,7 @@ fun MaterialBatteryInfo(
|
||||
?.takeIf { it > 0 }
|
||||
?.let {
|
||||
Text(
|
||||
text = formatString("%.2fV", it),
|
||||
text = MetricFormatter.voltage(it),
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
import org.meshtastic.core.common.util.formatString
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.signal_quality
|
||||
@@ -65,7 +65,10 @@ fun SignalInfo(
|
||||
tint = signalColor,
|
||||
)
|
||||
Text(
|
||||
text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)),
|
||||
text =
|
||||
"${MetricFormatter.snr(
|
||||
node.snr,
|
||||
)} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -117,8 +118,8 @@ fun EmojiPickerDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
val viewModel: EmojiPickerViewModel = koinViewModel()
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var selectedCategoryIndex by remember { mutableStateOf(0) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) }
|
||||
|
||||
val recentEmojis by
|
||||
remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } }
|
||||
@@ -427,7 +428,7 @@ private fun SectionHeader(title: String) {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) {
|
||||
var showSkinTonePopup by remember { mutableStateOf(false) }
|
||||
var showSkinTonePopup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Box(
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -89,7 +90,7 @@ fun ScannedQrCodeDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (ChannelSet) -> Unit,
|
||||
) {
|
||||
var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) }
|
||||
var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) }
|
||||
|
||||
val channelSet =
|
||||
remember(shouldReplace, channels, incoming) {
|
||||
|
||||
@@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
|
||||
/** Returns a function to open the platform's NFC settings. */
|
||||
@Composable expect fun rememberOpenNfcSettings(): () -> Unit
|
||||
@@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
|
||||
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
|
||||
@Composable
|
||||
expect fun rememberSaveFileLauncher(
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
onUriReceived: (CommonUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit
|
||||
|
||||
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */
|
||||
|
||||
@@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
@@ -99,18 +98,16 @@ class UIViewModel(
|
||||
* 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
|
||||
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
|
||||
*/
|
||||
fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
|
||||
val commonUri = CommonUri.parse(uri.uriString)
|
||||
|
||||
fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) {
|
||||
// Try navigation routing first
|
||||
val navKeys = DeepLinkRouter.route(commonUri)
|
||||
val navKeys = DeepLinkRouter.route(uri)
|
||||
if (navKeys != null) {
|
||||
_navigationDeepLink.tryEmit(navKeys)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to channel/contact importing
|
||||
commonUri.dispatchMeshtasticUri(
|
||||
uri.dispatchMeshtasticUri(
|
||||
onContact = { setSharedContactRequested(it) },
|
||||
onChannel = { setRequestChannelSet(it) },
|
||||
onInvalid = onInvalid,
|
||||
|
||||
@@ -21,6 +21,7 @@ package org.meshtastic.core.ui.viewmodel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -37,7 +38,6 @@ import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.unknown_error
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
|
||||
actual fun createClipEntry(text: String, label: String): ClipEntry =
|
||||
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
|
||||
@@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
|
||||
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
onUriReceived: (CommonUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -24,7 +24,6 @@ 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.MeshtasticUri
|
||||
import java.awt.Desktop
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
@@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
|
||||
/** JVM — Opens a native file dialog to save a file. */
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
onUriReceived: (CommonUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ ->
|
||||
val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE)
|
||||
dialog.file = defaultFilename
|
||||
@@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher(
|
||||
val dir = dialog.directory
|
||||
if (file != null && dir != null) {
|
||||
val path = File(dir, file)
|
||||
onUriReceived(MeshtasticUri(path.toURI().toString()))
|
||||
onUriReceived(CommonUri.parse(path.toURI().toString()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +82,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
|
||||
val dir = dialog.directory
|
||||
if (file != null && dir != null) {
|
||||
val path = File(dir, file)
|
||||
onUriReceived(CommonUri(path.toURI()))
|
||||
onUriReceived(CommonUri.parse(path.toURI().toString()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.svg.SvgDecoder
|
||||
@@ -62,7 +63,7 @@ import org.jetbrains.compose.resources.decodeToSvgPainter
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.context.startKoin
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.navigation.MultiBackstack
|
||||
import org.meshtastic.core.navigation.SettingsRoute
|
||||
@@ -130,7 +131,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array<String>, uiViewModel: U
|
||||
arg.startsWith("http://meshtastic.org") ||
|
||||
arg.startsWith("https://meshtastic.org")
|
||||
) {
|
||||
uiViewModel.handleDeepLink(MeshtasticUri(arg)) {
|
||||
uiViewModel.handleDeepLink(CommonUri.parse(arg)) {
|
||||
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
|
||||
}
|
||||
}
|
||||
@@ -141,7 +142,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array<String>, uiViewModel: U
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
|
||||
Desktop.getDesktop().setOpenURIHandler { event ->
|
||||
val uriStr = event.uri.toString()
|
||||
uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
|
||||
uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +305,12 @@ private fun CoilImageLoaderSetup() {
|
||||
val cacheDir = desktopDataDir() + "/image_cache_v3"
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(KtorNetworkFetcherFactory(httpClient = httpClient))
|
||||
add(
|
||||
KtorNetworkFetcherFactory(
|
||||
httpClient = httpClient,
|
||||
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
|
||||
),
|
||||
)
|
||||
// Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
|
||||
// that show up as solid/black hardware images.
|
||||
add(SvgDecoder.Factory(renderToBitmap = true))
|
||||
|
||||
@@ -14,18 +14,22 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports
|
||||
@file:Suppress(
|
||||
"ktlint:standard:no-unused-imports",
|
||||
) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule()
|
||||
|
||||
package org.meshtastic.desktop.di
|
||||
|
||||
// Generated Koin module extensions from core KMP modules
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.java.Java
|
||||
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 org.koin.dsl.module
|
||||
@@ -183,6 +187,7 @@ private fun desktopPlatformStubsModule() = module {
|
||||
single<HttpClient> {
|
||||
HttpClient(Java) {
|
||||
install(ContentNegotiation) { json(get<Json>()) }
|
||||
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
|
||||
@@ -195,7 +200,7 @@ private fun desktopPlatformStubsModule() = module {
|
||||
if (DesktopBuildConfig.IS_DEBUG) {
|
||||
install(Logging) {
|
||||
logger = KermitHttpLogger
|
||||
level = LogLevel.HEADERS
|
||||
level = LogLevel.BODY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# KMP Migration Status
|
||||
|
||||
> Last updated: 2026-04-13
|
||||
> Last updated: 2026-04-15
|
||||
|
||||
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
|
||||
|
||||
@@ -79,7 +79,7 @@ Working Compose Desktop application with:
|
||||
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
|
||||
| CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated |
|
||||
| DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
|
||||
| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils, desktop navigation graphs |
|
||||
| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils |
|
||||
|
||||
## Completion Estimates
|
||||
|
||||
@@ -109,12 +109,14 @@ Based on the latest codebase investigation, the following steps are proposed to
|
||||
| Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target |
|
||||
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints |
|
||||
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
|
||||
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
|
||||
| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) |
|
||||
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
|
||||
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. |
|
||||
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
||||
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
|
||||
| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` |
|
||||
| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) |
|
||||
|
||||
## Navigation Parity Note
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
> Last updated: 2026-04-10
|
||||
> Last updated: 2026-04-15
|
||||
|
||||
Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md).
|
||||
|
||||
@@ -18,6 +18,8 @@ These items address structural gaps identified in the March 2026 architecture re
|
||||
| Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ |
|
||||
| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ |
|
||||
| **iOS CI gate (compile-only validation)** | High | Medium | ✅ |
|
||||
| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ |
|
||||
| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ |
|
||||
|
||||
## Active Work
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo(
|
||||
while (bleDevice.device.isConnected) {
|
||||
try {
|
||||
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Logger.d { "RSSI read timed out" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise
|
||||
Logger.d(e) { "Failed to read RSSI ${e.message}" }
|
||||
}
|
||||
delay(RSSI_DELAY.seconds)
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware
|
||||
|
||||
import android.content.Context
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.eygraber.uri.toAndroidUri
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.head
|
||||
@@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
try {
|
||||
val platformUri = uri.toPlatformUri() as android.net.Uri
|
||||
val platformUri = uri.toAndroidUri()
|
||||
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
|
||||
ZipInputStream(inputStream).use { zipInput ->
|
||||
var entry = zipInput.nextEntry
|
||||
@@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
|
||||
override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) {
|
||||
file.toLocalFileOrNull()?.takeIf { it.exists() }?.length()
|
||||
?: context.contentResolver
|
||||
.openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r")
|
||||
?.use { descriptor -> descriptor.length.takeIf { it >= 0L } }
|
||||
?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor ->
|
||||
descriptor.length.takeIf { it >= 0L }
|
||||
}
|
||||
?: 0L
|
||||
}
|
||||
|
||||
@@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
if (localFile != null && localFile.exists()) {
|
||||
localFile.readBytes()
|
||||
} else {
|
||||
context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use {
|
||||
it.readBytes()
|
||||
} ?: throw IOException("Cannot open artifact: ${artifact.uri}")
|
||||
context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() }
|
||||
?: throw IOException("Cannot open artifact: ${artifact.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) {
|
||||
val inputStream =
|
||||
context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
|
||||
?: return@withContext null
|
||||
val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null
|
||||
val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin")
|
||||
tempFile.parentFile?.mkdirs()
|
||||
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
|
||||
@@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
withContext(ioDispatcher) {
|
||||
val inputStream =
|
||||
source.toLocalFileOrNull()?.inputStream()
|
||||
?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri)
|
||||
?: context.contentResolver.openInputStream(source.uri.toAndroidUri())
|
||||
?: throw IOException("Cannot open source URI")
|
||||
val outputStream =
|
||||
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
|
||||
context.contentResolver.openOutputStream(destinationUri.toAndroidUri())
|
||||
?: throw IOException("Cannot open content URI for writing")
|
||||
|
||||
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
|
||||
|
||||
@@ -163,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
|
||||
uri?.let { viewModel.startUpdateFromFile(it) }
|
||||
}
|
||||
|
||||
val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri ->
|
||||
viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString))
|
||||
}
|
||||
val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) }
|
||||
|
||||
val actions =
|
||||
remember(viewModel, onNavigateUp) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.datastore.BootloaderWarningDataSource
|
||||
@@ -123,9 +124,12 @@ class FirmwareUpdateViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// viewModelScope is already cancelled when onCleared() runs, so use a standalone scope
|
||||
// for fire-and-forget cleanup of temporary firmware files.
|
||||
kotlinx.coroutines.CoroutineScope(NonCancellable).launch {
|
||||
// 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) {
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +151,7 @@ class FirmwareUpdateViewModel(
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
_state.value = FirmwareUpdateState.Checking
|
||||
runCatching {
|
||||
safeCatching {
|
||||
val ourNode = nodeRepository.myNodeInfo.value
|
||||
val address = radioPrefs.devAddr.value?.drop(1)
|
||||
if (address == null || ourNode == null) {
|
||||
@@ -200,7 +204,6 @@ class FirmwareUpdateViewModel(
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
if (e is CancellationException) throw e
|
||||
Logger.e(e) { "Error checking for updates" }
|
||||
val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error)
|
||||
_state.value =
|
||||
@@ -390,7 +393,7 @@ private suspend fun cleanupTemporaryFiles(
|
||||
fileHandler: FirmwareFileHandler,
|
||||
tempFirmwareFile: FirmwareArtifact?,
|
||||
): FirmwareArtifact? {
|
||||
runCatching {
|
||||
safeCatching {
|
||||
tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) }
|
||||
fileHandler.cleanupAllTemporaryFiles()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user