feat: introduce Desktop target and expand Kotlin Multiplatform (KMP) architecture (#4761)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-12 16:14:49 -05:00 committed by GitHub
parent f4364cff9a
commit ac6bb5479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
386 changed files with 17089 additions and 4590 deletions

View file

@ -7,9 +7,9 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
**Key Repository Details:**
- **Language:** Kotlin (primary), with some Java and AIDL files
- **Build System:** Gradle with Kotlin DSL
- **Size:** ~3MB source code across 3 modules
- **Architecture shape:** Android app shell plus a broad `core:*` / `feature:*` KMP module graph
- **Target Platform:** Android API 26+ (Android 8.0+), targeting API 36
- **Architecture:** Modern Android with Jetpack Compose, Hilt DI, Room database
- **Architecture:** Android-first Kotlin Multiplatform with Jetpack Compose, Koin DI, Room KMP, DataStore, and Navigation 3 shared backstack state
- **Product Flavors:** `fdroid` (F-Droid) and `google` (Google Play Store)
- **Build Types:** `debug` and `release`
@ -62,9 +62,10 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
# 10. Run lint checks for both flavors
./gradlew lintFdroidDebug lintGoogleDebug
```
### Time Requirements
# 11. Run the desktop module
./gradlew :desktop:run
./gradlew :desktop:test
- Clean build: 3-5 minutes
- Unit tests: 2-3 minutes
- Instrumented tests: 5-10 minutes
@ -91,8 +92,15 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
│ ├── src/fdroid/ # F-Droid specific code
│ └── src/google/ # Google Play specific code
├── core/ # Core library modules
├── network/ # HTTP API networking library
├── mesh_service_example/ # AIDL service usage example
├── desktop/ # Compose Desktop application (first non-Android KMP target)
├── feature/ # Feature modules (all KMP with JVM targets)
│ ├── connections/ # Device connections UI (BLE, TCP, USB scanning)
│ ├── firmware/ # Firmware update flow
│ ├── intro/ # Onboarding flow
│ ├── map/ # Map UI
│ ├── messaging/ # Messaging/contacts UI
│ ├── node/ # Node list and detail UI
│ └── settings/ # Settings screens
├── build-logic/ # Build configuration convention plugins
└── config/ # Linting and formatting configs
├── detekt/ # Detekt static analysis rules
@ -110,33 +118,36 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
### Architecture Components
- **UI Framework:** Jetpack Compose with Material 3
- **State Management:** Unidirectional Data Flow with ViewModels
- **Dependency Injection:** Hilt
- **Navigation:** Jetpack Navigation Compose
- **Dependency Injection:** Koin Annotations with K2 compiler plugin
- **Navigation:** AndroidX Navigation 3 (JetBrains multiplatform fork) with shared navigation keys/routes in `core:navigation`
- **Lifecycle:** JetBrains multiplatform forks for `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`
- **Local Data:** Room database + DataStore preferences
- **Remote Data:** Custom Bluetooth/WiFi protocol + HTTP API (network module)
- **Remote Data:** Shared BLE/network/service layers across `core:ble`, `core:network`, and `core:service`
- **Background Work:** WorkManager
- **Communication:** AIDL service interface (`IMeshService.aidl`)
- **Desktop:** First non-Android KMP target. Nav 3 shell, full Koin DI, TCP transport with `want_config` handshake, adaptive list-detail screens for nodes/messaging, ~35 settings screens, connections UI. See `docs/kmp-status.md`.
## Continuous Integration
### GitHub Workflows (.github/workflows/)
- **pull-request.yml** - Runs on every PR: build, detekt, tests
- **reusable-android-build.yml** - Shared build logic: spotless, detekt, lint, assemble, test
- **reusable-android-test.yml** - Instrumented tests on Android emulators (API 26, 35)
- **pull-request.yml** - PR entry workflow
- **reusable-check.yml** - Shared Android/JVM verification: spotless, detekt, unit tests, Kover, JVM smoke compile, assemble/lint, optional instrumented tests
### CI Commands (Must Pass)
```bash
# Exact commands run in CI that must pass:
./gradlew :app:spotlessCheck :app:detekt :app:lintFdroidDebug :app:lintGoogleDebug :app:assembleDebug :app:testFdroidDebug :app:testGoogleDebug --configuration-cache --scan
./gradlew :app:connectedFdroidDebugAndroidTest :app:connectedGoogleDebugAndroidTest --configuration-cache --scan
# Reusable CI workflow runs these core checks on the first matrix leg:
./gradlew spotlessCheck detekt -Pci=true
./gradlew testDebugUnitTest testFdroidDebugUnitTest testGoogleDebugUnitTest koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug -Pci=true --continue
./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :desktop:test -Pci=true --continue
```
### Validation Steps
1. **Code Style:** Spotless check (auto-fixable with `spotlessApply`)
2. **Static Analysis:** Detekt with custom rules in `config/detekt/detekt.yml`
3. **Lint Checks:** Android lint for both flavors
4. **Unit Tests:** JUnit tests in `app/src/test/`
5. **UI Tests:** Compose UI tests in `app/src/androidTest/`
3. **Shared smoke compile:** JVM compile checks for all `core:*` and `feature:*` KMP modules plus `:desktop:test`
4. **Lint Checks:** Android lint on debug variants
5. **Unit Tests:** Android/unit/shared tests plus Kover reports
6. **UI Tests:** Compose/instrumented tests when emulator runs are enabled
## Common Issues & Solutions
@ -146,6 +157,9 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
- **Configuration cache:** Add `--no-configuration-cache` flag if issues persist
- **Clean state:** Always run `./gradlew clean` before debugging build issues
### Desktop Issues
- **`Dispatchers.Main` missing:** JVM/Desktop requires `kotlinx-coroutines-swing` for `Dispatchers.Main`. Without it, any code using `lifecycle.coroutineScope` or `Dispatchers.Main` will crash at runtime. The desktop module already includes this dependency.
### Testing Issues
- **Instrumented tests:** Require Android device/emulator with API 26+
- **UI tests:** Use `ComposeTestRule` for Compose UI testing
@ -159,12 +173,12 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
## File Organization
### Source Code Locations
- **Main Activity:** `app/src/main/java/com/geeksville/mesh/MainActivity.kt`
- **Main Activity:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
- **Service Interface:** `core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl`
- **UI Screens:** `feature/*/src/main/kotlin/org/meshtastic/feature/*/`
- **Data Layer:** `core/data/src/main/kotlin/org/meshtastic/core/data/`
- **Database:** `core/database/src/main/kotlin/org/meshtastic/core/database/`
- **Models:** `core/model/src/main/kotlin/org/meshtastic/core/model/`
- **Shared feature/UI code:** `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/`
- **Data Layer:** `core/data/src/commonMain/kotlin/org/meshtastic/core/data/`
- **Database:** `core/database/src/commonMain/kotlin/org/meshtastic/core/database/`
- **Models:** `core/model/src/commonMain/kotlin/org/meshtastic/core/model/`
### Dependencies
- **Non-obvious deps:** Protobuf for device communication, DataDog for analytics (Google flavor)
@ -173,6 +187,12 @@ Meshtastic-Android is a native Android client application for the Meshtastic mes
## Agent Instructions
- Keep documentation continuously in sync with the code. If you change architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs in the same change.
- Treat `AGENTS.md` as the primary source of truth for project architecture and process; update mirrored guidance here when that source changes.
- Architecture review and gap analysis: `docs/decisions/architecture-review-2026-03.md`.
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives (see AGENTS.md §3B for the full list).
- **Testing:** Write ViewModel and business logic tests in `commonTest` (not `test/` Robolectric) so every target runs them.
**TRUST THESE INSTRUCTIONS** - they are validated and comprehensive. Only search for additional information if:
1. Commands fail with unexpected errors
2. Information appears outdated

View file

@ -24,5 +24,5 @@ jobs:
uses: gradle/actions/dependency-submission@v5
with:
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use"
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"

View file

@ -31,6 +31,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Configure Version
id: version

View file

@ -252,9 +252,57 @@ jobs:
with:
subject-path: app/build/outputs/apk/fdroid/release/*.apk
release-desktop:
runs-on: ${{ matrix.os }}
needs: [prepare-build-info]
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
- name: Package Native Distributions
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PappVersionName=${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} --no-daemon
- name: Upload Desktop Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: desktop-${{ runner.os }}
path: |
desktop/build/compose/binaries/main/app/*/*.dmg
desktop/build/compose/binaries/main/app/*/*.msi
desktop/build/compose/binaries/main/app/*/*.deb
retention-days: 1
if-no-files-found: ignore
github-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, release-google, release-fdroid]
needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
env:
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
permissions:

View file

@ -50,6 +50,7 @@ jobs:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@ -100,11 +101,15 @@ jobs:
- name: Code Style & Static Analysis
if: steps.tasks.outputs.is_first_api == 'true'
run: ./gradlew spotlessCheck detekt -Pci=true
run: ./gradlew spotlessCheck detekt -Pci=true --scan
- name: Shared Unit Tests
if: steps.tasks.outputs.is_first_api == 'true' && inputs.run_unit_tests == true
run: ./gradlew testDebugUnitTest testFdroidDebugUnitTest testGoogleDebugUnitTest koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug -Pci=true --continue
run: ./gradlew testDebugUnitTest testFdroidDebugUnitTest testGoogleDebugUnitTest koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug -Pci=true --continue --scan
- name: KMP JVM Smoke Compile
if: steps.tasks.outputs.is_first_api == 'true'
run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :desktop:test -Pci=true --continue --scan
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true

1
.gitignore vendored
View file

@ -48,3 +48,4 @@ wireless-install.sh
# Git worktrees
.worktrees/
/firebase-debug.log

View file

@ -12,6 +12,9 @@ We are incrementally migrating Meshtastic-Android to a **Kotlin Multiplatform (K
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.library`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
@ -20,19 +23,22 @@ We are incrementally migrating Meshtastic-Android to a **Kotlin Multiplatform (K
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor and MQTT abstractions. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec` in commonMain, `TcpTransport` in jvmAndroidMain). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components and platform abstractions. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions, including `jvmAndroidMain` bridges for shared JVM/Android actuals. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode abstractions with Android hardware implementation. |
| `core:nfc` | NFC abstractions with Android hardware implementation. |
| `core:barcode` | Barcode scanning (Android-only). Shared UI in `main/`; only the decoder (`createBarcodeAnalyzer`) differs per flavor (ML Kit / ZXing). Shared contract in `core:ui`. |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`; shared contract via `LocalNfcScannerProvider` in `core:ui`. |
| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** Lightweight with minimal dependencies (only `core:model`, `core:repository`, + test libs). Keeps module dependency graph clean by centralizing test consolidation. See `core/testing/README.md`. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. |
| `feature/connections` | Connections UI — device discovery, BLE/TCP/USB scanning, shared composables in `commonMain`; Android BLE bonding/NSD/USB in `androidMain`. |
| `feature/firmware` | Firmware update flow (KMP module with Android DFU in `androidMain`). |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP transport with `want_config` handshake, adaptive list-detail screens for nodes/messaging, ~35 real settings screens, connections UI. See `docs/kmp-status.md`. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines
@ -43,16 +49,28 @@ We are incrementally migrating Meshtastic-Android to a **Kotlin Multiplatform (K
- **Rule:** MUST use the **Compose Multiplatform Resource** library in `core:resources`.
- **Location:** `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Dialogs:** Use centralized components in `core:ui`.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. See `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` for the contract pattern and `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` for provider wiring.
### B. Logic & Data Layer
- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` (locale-independent for ASCII) or `expect`/`actual`.
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- **I/O:** Use **Okio** (`BufferedSource`/`BufferedSink`) for stream operations. Never use `java.io` in `commonMain`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Thread-Safety:** Use `atomicfu` and `kotlinx.collections.immutable` for shared state in `commonMain`. Avoid `synchronized` or JVM-specific atomics.
- **Dependency Injection:**
- Use **Koin Annotations** with the K2 compiler plugin.
- Keep root graph assembly in `app` (module inclusion in `AppKoinModule` and startup wiring in `MeshUtilApplication`).
- Keep `commonMain` business logic framework-agnostic. Shared modules may contain Koin-annotated definitions where that pattern already exists, but they must be included by the app root module.
- It is the recommended best practice to use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules. This provides compile-time safety and encapsulates dependency graphs per feature.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain` to maintain a single source of truth for UI state, relying heavily on `StateFlow`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Nordic Semiconductor's Android Common Libraries and Kotlin Coroutines/Flows. Never use legacy Android Bluetooth callbacks directly.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. New dependencies MUST be added to the version catalog, not directly to a `build.gradle.kts` file.
- **Shared JVM + Android code:** If a KMP module needs a `jvmAndroidMain` source set for code shared between desktop JVM and Android, apply the `meshtastic.kmp.jvm.android` convention plugin. Do **not** hand-wire `sourceSets.dependsOn(...)` edges in module `build.gradle.kts` files—the convention uses Kotlin's hierarchy template API and avoids default hierarchy warnings.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Testing:** Write ViewModel and business logic tests in `commonTest` (not `test/` Robolectric) so every target runs them. Use `core:testing` shared fakes when available. **Test framework dependencies** (`kotlin("test")` for both `commonTest` and `androidHostTest` source sets) are automatically provided by the `meshtastic.kmp.library` convention plugin—no need to add them manually to individual module `build.gradle.kts` files. See `build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt::configureKmpTestDependencies()` for details.
### C. Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
@ -61,15 +79,26 @@ We are incrementally migrating Meshtastic-Android to a **Kotlin Multiplatform (K
## 4. Execution Protocol
### A. Build and Verify
**Prerequisite:** JDK 17 is required. Copy `secrets.defaults.properties` to `local.properties` before building.
1. **Clean:** `./gradlew clean`
2. **Format:** `./gradlew spotlessCheck` then `./gradlew spotlessApply`
3. **Lint:** `./gradlew detekt`
4. **Build + Unit Tests:** `./gradlew assembleDebug test` (CI also runs `testDebugUnitTest`)
5. **Flavor/CI Parity (when relevant):** `./gradlew lintFdroidDebug lintGoogleDebug testFdroidDebug testGoogleDebug`
6. **Desktop (when touched):** `./gradlew :desktop:test :desktop:run`
### B. Expect/Actual Patterns
### B. Documentation Sync
- If you change architecture, module boundaries, target declarations, CI tasks, validation commands, or agent workflow rules, update the corresponding docs in the same slice.
- KMP status: `docs/kmp-status.md`. Roadmap: `docs/roadmap.md`. Decisions: `docs/decisions/`. Architecture review: `docs/decisions/architecture-review-2026-03.md`.
- At minimum, review and update the relevant source of truth among `AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, and `docs/kmp-status.md` when those areas are affected.
### C. Expect/Actual Patterns
Use `expect`/`actual` sparingly for platform-specific types (e.g., `Location`, platform utilities) to keep core logic pure. For navigation, prefer shared Navigation 3 backstack state (`List<NavKey>`) over platform controller types.
## 5. Troubleshooting
- **Build Failures:** Always check `gradle/libs.versions.toml` for dependency conflicts.
- **Missing Secrets:** Copy `secrets.defaults.properties``local.properties` with valid (or dummy) values for `MAPS_API_KEY`, `datadogApplicationId`, and `datadogClientToken`.
- **JDK Version:** JDK 17 is required. Mismatched JDK versions cause Gradle sync/build failures.
- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`) and that `startKoin` loads that module at app startup.
- **Desktop `Dispatchers.Main` missing:** JVM/Desktop requires `kotlinx-coroutines-swing` for `Dispatchers.Main`. Without it, any code using `lifecycle.coroutineScope` or `Dispatchers.Main` will crash at runtime. The desktop module already includes this dependency.

View file

@ -14,10 +14,12 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** `core:model`, `core:proto`, `core:common`, `core:resources`, `core:database`, `core:datastore`, `core:repository`, `core:domain`, `core:prefs`, `core:network`, `core:di`, and `core:data`.
- **KMP Modules:** `core:model`, `core:proto`, `core:common`, `core:resources`, `core:database`, `core:datastore`, `core:repository`, `core:domain`, `core:prefs`, `core:network`, `core:di`, `core:data`, `core:ble`, `core:nfc`, `core:service`, `core:ui`, `core:navigation`, `core:testing`. All declare `jvm()` target and compile clean on JVM.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` (`AppKoinModule` + `startKoin`), while shared modules can expose annotated definitions that are included by the app root module.
- **Navigation:** AndroidX Navigation 3 with shared backstack state (`List<NavKey>`).
- **Navigation:** AndroidX Navigation 3 (JetBrains multiplatform fork: `org.jetbrains.androidx.navigation3`) with shared backstack state (`List<NavKey>`).
- **Lifecycle (multiplatform):** JetBrains forks `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
## 2. Environment Setup (Mandatory First Steps)
@ -75,16 +77,29 @@ Always run commands in the following order to ensure reliability. Do not attempt
- **Rule:** You MUST use the Compose Multiplatform Resource library.
- **Location:** `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:** `stringResource(Res.string.your_key)`
- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` (locale-independent for ASCII) or `expect`/`actual`.
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- **Bluetooth/BLE:** Do not use legacy Android Bluetooth callbacks. All BLE communication MUST route through `:core:ble`, utilizing Nordic Semiconductor's Android Common Libraries and Kotlin Coroutines/Flows.
- **Dependencies:** Never assume a library is available. Check `gradle/libs.versions.toml` first. If adding a new dependency, it MUST be added to the version catalog, not directly to a `build.gradle.kts` file.
- **Namespacing:** Prefer the `org.meshtastic` namespace for all new code. The legacy `com.geeksville.mesh` ApplicationId is maintained for compatibility.
- **Testing:** Write ViewModel and business logic tests in `commonTest` (not `test/` Robolectric) so every target runs them. Use `core:testing` shared fakes when available.
- **Documentation Sync:** Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`) in the same slice.
## 5. Module Map
When locating code to modify, use this map:
- **`app/`**: Main application wiring and Koin DI modules/wrappers (`@KoinViewModel`, `@Module`, `@KoinWorker`). Package: `org.meshtastic.app`.
- **`:core:data`**: Core business logic and managers. Package: `org.meshtastic.core.data`.
- **`:core:repository`**: Domain interfaces and common models. Package: `org.meshtastic.core.repository`.
- **`:core:ble`**: Coroutine-based Bluetooth logic.
- **`:core:ble`**: Coroutine-based Bluetooth logic (Nordic Semiconductor). Package: `org.meshtastic.core.ble`.
- **`:core:nfc`**: NFC abstractions (KMP). Android NFC hardware in `androidMain`; shared contract via `LocalNfcScannerProvider` in `core:ui`.
- **`:core:barcode`**: Barcode scanning (Android-only). Shared UI in `main/`; only the decoder (`createBarcodeAnalyzer`) differs per flavor (ML Kit / ZXing). Shared contract in `core:ui`.
- **`:core:api`**: AIDL service interface (`IMeshService.aidl`) for third-party integrations (like ATAK).
- **`:core:ui`**: Shared Compose UI elements and theming.
- **`:feature:*`**: Isolated feature screens (e.g., `:feature:messaging` for chat, `:feature:map` for mapping).
- **`:core:ui`**: Shared Compose UI elements, platform abstractions, and theming.
- **`:core:navigation`**: Shared Navigation 3 routes/keys.
- **`:core:network`**: KMP networking (Ktor, `StreamFrameCodec`, `TcpTransport`).
- **`:core:testing`**: Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.
- **`:desktop`**: Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI, TCP transport with `want_config` handshake, adaptive list-detail screens for nodes/messaging, ~35 real settings screens, connections UI. See `docs/kmp-status.md`.
- **`:feature:*`**: Isolated feature screens (e.g., `:feature:messaging` for chat, `:feature:map` for mapping, `:feature:connections` for device discovery, `:feature:firmware` for updates).

View file

@ -229,6 +229,7 @@ dependencies {
implementation(projects.core.barcode)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
@ -326,6 +327,16 @@ dependencies {
}
aboutLibraries {
// Fetch full license text + funding info from GitHub API when on CI with a token
val isCi = providers.gradleProperty("ci").map { it.toBoolean() }.getOrElse(false)
val ghToken = providers.environmentVariable("GITHUB_TOKEN")
collect {
fetchRemoteLicense = isCi && ghToken.isPresent
fetchRemoteFunding = isCi && ghToken.isPresent
if (ghToken.isPresent) {
gitHubApiToken = ghToken.get()
}
}
export { excludeFields = listOf("generated") }
library {
duplicationMode = DuplicateMode.MERGE

View file

@ -26,6 +26,6 @@
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : RadioTransport</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -30,6 +30,7 @@ import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {

View file

@ -50,7 +50,6 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.intro.AndroidIntroViewModel
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.app.node.component.InlineMap
@ -72,6 +71,7 @@ import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@ -143,7 +143,7 @@ class MainActivity : ComponentActivity() {
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
val introViewModel = koinViewModel<AndroidIntroViewModel>()
val introViewModel = koinViewModel<IntroViewModel>()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
}
}

View file

@ -27,7 +27,6 @@ import com.hoho.android.usbserial.driver.UsbSerialProber
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.app.repository.usb.ProbeTableProvider
import org.meshtastic.core.ble.di.CoreBleAndroidModule
import org.meshtastic.core.ble.di.CoreBleModule
import org.meshtastic.core.common.BuildConfigProvider
@ -45,6 +44,8 @@ import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.connections.repository.ProbeTableProvider
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@ -76,6 +77,7 @@ import org.meshtastic.feature.settings.di.FeatureSettingsModule
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
FeatureFirmwareModule::class,

View file

@ -1,53 +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.app.firmware
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.feature.firmware.FirmwareFileHandler
import org.meshtastic.feature.firmware.FirmwareUpdateManager
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
import org.meshtastic.feature.firmware.FirmwareUsbManager
@Suppress("LongParameterList")
@KoinViewModel
class AndroidFirmwareUpdateViewModel(
firmwareReleaseRepository: FirmwareReleaseRepository,
deviceHardwareRepository: DeviceHardwareRepository,
nodeRepository: NodeRepository,
radioController: RadioController,
radioPrefs: RadioPrefs,
bootloaderWarningDataSource: BootloaderWarningDataSource,
firmwareUpdateManager: FirmwareUpdateManager,
usbManager: FirmwareUsbManager,
fileHandler: FirmwareFileHandler,
) : FirmwareUpdateViewModel(
firmwareReleaseRepository,
deviceHardwareRepository,
nodeRepository,
radioController,
radioPrefs,
bootloaderWarningDataSource,
firmwareUpdateManager,
usbManager,
fileHandler,
)

View file

@ -1,32 +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.app.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.feature.map.SharedMapViewModel
@KoinViewModel
class AndroidSharedMapViewModel(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
) : SharedMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)

View file

@ -1,32 +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.app.messaging
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@KoinViewModel
class AndroidContactsViewModel(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
) : ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository)

View file

@ -1,59 +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.app.messaging
import androidx.lifecycle.SavedStateHandle
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.feature.messaging.MessageViewModel
@Suppress("LongParameterList")
@KoinViewModel
class AndroidMessageViewModel(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
serviceRepository: ServiceRepository,
packetRepository: PacketRepository,
uiPrefs: UiPrefs,
customEmojiPrefs: CustomEmojiPrefs,
homoglyphEncodingPrefs: HomoglyphPrefs,
meshServiceNotifications: MeshServiceNotifications,
sendMessageUseCase: SendMessageUseCase,
) : MessageViewModel(
savedStateHandle,
nodeRepository,
radioConfigRepository,
quickChatActionRepository,
serviceRepository,
packetRepository,
uiPrefs,
customEmojiPrefs,
homoglyphEncodingPrefs,
meshServiceNotifications,
sendMessageUseCase,
)

View file

@ -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.app.messaging
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.feature.messaging.QuickChatViewModel
@KoinViewModel
class AndroidQuickChatViewModel(quickChatActionRepository: QuickChatActionRepository) :
QuickChatViewModel(quickChatActionRepository)

View file

@ -17,144 +17,57 @@
package org.meshtastic.app.model
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import org.meshtastic.core.ui.viewmodel.BaseUIViewModel
/**
* Android-specific thin adapter over [BaseUIViewModel].
*
* Adds deep-link / URI handling (requires [android.net.Uri]) and direct [IMeshService] access that cannot live in
* `commonMain`.
*/
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class UIViewModel(
private val nodeDB: NodeRepository,
private val serviceRepository: AndroidServiceRepository,
private val radioController: RadioController,
nodeDB: NodeRepository,
private val androidServiceRepository: AndroidServiceRepository,
radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
uiPreferencesDataSource: UiPreferencesDataSource,
meshServiceNotifications: MeshServiceNotifications,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
) : ViewModel() {
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
val clientNotification: StateFlow<ClientNotification?> = serviceRepository.clientNotification
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
}
/** Emits events for mesh network send/receive activity. */
val meshActivity: Flow<MeshActivity> = radioInterfaceService.meshActivity
private val _scrollToTopEventFlow =
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val scrollToTopEventFlow: Flow<ScrollToTopEvent> = _scrollToTopEventFlow.asSharedFlow()
fun emitScrollToTopEvent(event: ScrollToTopEvent) {
_scrollToTopEventFlow.tryEmit(event)
}
val currentAlert = alertManager.currentAlert
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums =
nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet(),
)
fun showAlert(
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
messageRes: StringResource? = null,
composableMessage: ComposableContent? = null,
html: String? = null,
onConfirm: (() -> Unit)? = {},
onDismiss: (() -> Unit)? = null,
confirmText: String? = null,
confirmTextRes: StringResource? = null,
dismissText: String? = null,
dismissTextRes: StringResource? = null,
choices: Map<String, () -> Unit> = emptyMap(),
) {
alertManager.showAlert(
title = title,
titleRes = titleRes,
message = message,
messageRes = messageRes,
composableMessage = composableMessage,
html = html,
onConfirm = onConfirm,
onDismiss = onDismiss,
confirmText = confirmText,
confirmTextRes = confirmTextRes,
dismissText = dismissText,
dismissTextRes = dismissTextRes,
choices = choices,
)
}
fun dismissAlert() {
alertManager.dismissAlert()
}
alertManager: AlertManager,
) : BaseUIViewModel(
nodeDB = nodeDB,
serviceRepository = androidServiceRepository,
radioController = radioController,
radioInterfaceService = radioInterfaceService,
meshLogRepository = meshLogRepository,
firmwareReleaseRepository = firmwareReleaseRepository,
uiPreferencesDataSource = uiPreferencesDataSource,
meshServiceNotifications = meshServiceNotifications,
packetRepository = packetRepository,
alertManager = alertManager,
) {
val meshService: IMeshService?
get() = serviceRepository.meshService
fun setDeviceAddress(address: String) {
radioController.setDeviceAddress(address)
}
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
get() = androidServiceRepository.meshService
private val _navigationDeepLink = MutableSharedFlow<Uri>(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
@ -163,66 +76,6 @@ class UIViewModel(
_navigationDeepLink.tryEmit(uri)
}
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeInfo?>
get() = nodeDB.myNodeInfo
init {
serviceRepository.errorMessage
.filterNotNull()
.onEach {
showAlert(
titleRes = Res.string.client_notification,
message = it,
onConfirm = { serviceRepository.clearErrorMessage() },
)
}
.launchIn(viewModelScope)
serviceRepository.clientNotification
.filterNotNull()
.onEach { notification ->
val isCompromised = notification.low_entropy_key != null || notification.duplicated_public_key != null
showAlert(
titleRes = Res.string.client_notification,
message = if (isCompromised) getString(Res.string.compromised_keys) else notification.message,
onConfirm = {
// Action for compromised keys should be handled via a callback or event
clearClientNotification(notification)
},
onDismiss = { clearClientNotification(notification) },
)
}
.launchIn(viewModelScope)
Logger.d { "ViewModel created" }
}
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(contact: SharedContact?) {
_sharedContactRequested.value = contact
}
/** Called immediately after activity observes requestChannelUrl */
fun clearSharedContactRequested() {
_sharedContactRequested.value = null
}
// Connection state to our radio device
val connectionState
get() = serviceRepository.connectionState
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet: StateFlow<ChannelSet?>
get() = _requestChannelSet
fun setRequestChannelSet(channelSet: ChannelSet?) {
_requestChannelSet.value = channelSet
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
uri.dispatchMeshtasticUri(
@ -231,35 +84,4 @@ class UIViewModel(
onInvalid = onInvalid,
)
}
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
/** Called immediately after activity observes requestChannelUrl */
fun clearRequestChannelUrl() {
_requestChannelSet.value = null
}
override fun onCleared() {
super.onCleared()
Logger.d { "ViewModel cleared" }
}
val tracerouteResponse: Flow<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse
fun clearTracerouteResponse() {
serviceRepository.clearTracerouteResponse()
}
val neighborInfoResponse: StateFlow<String?> = serviceRepository.neighborInfoResponse
fun clearNeighborInfoResponse() {
serviceRepository.clearNeighborInfoResponse()
}
val appIntroCompleted: StateFlow<Boolean> = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {
uiPreferencesDataSource.setAppIntroCompleted(true)
}
}

View file

@ -21,14 +21,16 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.ui.connections.ConnectionsScreen
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.connections.AndroidScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = {
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
@ -41,6 +43,7 @@ fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.app.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
@ -23,14 +24,14 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.messaging.AndroidContactsViewModel
import org.meshtastic.app.messaging.AndroidMessageViewModel
import org.meshtastic.app.messaging.AndroidQuickChatViewModel
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
@ -39,62 +40,17 @@ fun EntryProviderScope<NavKey>.contactsGraph(
scrollToTopEvents: Flow<ScrollToTopEvent>,
) {
entry<ContactsRoutes.ContactsGraph> {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Contacts> {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Messages> { args ->
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
ContactsEntryContent(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = args.contactKey,
initialMessage = args.message,
)
@ -102,7 +58,7 @@ fun EntryProviderScope<NavKey>.contactsGraph(
entry<ContactsRoutes.Share> { args ->
val message = args.message
val viewModel = koinViewModel<AndroidContactsViewModel>()
val viewModel = koinViewModel<ContactsViewModel>()
ShareScreen(
viewModel = viewModel,
onConfirm = {
@ -115,7 +71,35 @@ fun EntryProviderScope<NavKey>.contactsGraph(
}
entry<ContactsRoutes.QuickChat> {
val viewModel = koinViewModel<AndroidQuickChatViewModel>()
val viewModel = koinViewModel<QuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}
@Composable
private fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<ContactsViewModel>()
val messageViewModel = koinViewModel<MessageViewModel>()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
initialMessage = initialMessage,
)
}

View file

@ -20,13 +20,13 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.firmware.AndroidFirmwareUpdateViewModel
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
fun EntryProviderScope<NavKey>.firmwareGraph(backStack: NavBackStack<NavKey>) {
entry<FirmwareRoutes.FirmwareUpdate> {
val viewModel = koinViewModel<AndroidFirmwareUpdateViewModel>()
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
}
}

View file

@ -20,14 +20,14 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.AndroidSharedMapViewModel
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoutes.Map> {
val viewModel = koinViewModel<AndroidSharedMapViewModel>()
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },

View file

@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.node.NodeMapScreen
import org.meshtastic.app.map.node.NodeMapViewModel
import org.meshtastic.app.node.AndroidMetricsViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
@ -53,6 +52,7 @@ import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen

View file

@ -26,11 +26,10 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidCleanNodeDatabaseViewModel
import org.meshtastic.app.settings.AndroidDebugViewModel
import org.meshtastic.app.settings.AndroidFilterSettingsViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.settings.AndroidSettingsViewModel
import org.meshtastic.app.util.AboutLibrariesJsonProvider
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@ -41,9 +40,11 @@ import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.filter.FilterSettingsViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
@ -121,7 +122,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.CleanNodeDb> {
val viewModel: AndroidCleanNodeDatabaseViewModel = koinViewModel()
val viewModel: CleanNodeDatabaseViewModel = koinViewModel()
CleanNodeDatabaseScreen(viewModel = viewModel)
}
@ -181,10 +182,18 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
entry<SettingsRoutes.About> { AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
entry<SettingsRoutes.About> {
AboutScreen(
onNavigateUp = { backStack.removeLastOrNull() },
jsonProvider = {
// Load from AboutLibraries asset/classpath resource
AboutLibrariesJsonProvider.getJson()
},
)
}
entry<SettingsRoutes.FilterSettings> {
val viewModel: AndroidFilterSettingsViewModel = koinViewModel()
val viewModel: FilterSettingsViewModel = koinViewModel()
FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
}
}

View file

@ -1,32 +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.app.node
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
@KoinViewModel
class AndroidCompassViewModel(
headingProvider: CompassHeadingProvider,
locationProvider: PhoneLocationProvider,
magneticFieldProvider: MagneticFieldProvider,
dispatchers: CoroutineDispatchers,
) : CompassViewModel(headingProvider, locationProvider, magneticFieldProvider, dispatchers)

View file

@ -1,40 +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.app.node
import androidx.lifecycle.SavedStateHandle
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
@KoinViewModel
class AndroidNodeDetailViewModel(
savedStateHandle: SavedStateHandle,
nodeManagementActions: NodeManagementActions,
nodeRequestActions: NodeRequestActions,
serviceRepository: ServiceRepository,
getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : NodeDetailViewModel(
savedStateHandle,
nodeManagementActions,
nodeRequestActions,
serviceRepository,
getNodeDetailsUseCase,
)

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node
import androidx.lifecycle.SavedStateHandle
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.feature.node.list.NodeFilterPreferences
import org.meshtastic.feature.node.list.NodeListViewModel
@KoinViewModel
class AndroidNodeListViewModel(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
radioController: RadioController,
nodeManagementActions: NodeManagementActions,
getFilteredNodesUseCase: GetFilteredNodesUseCase,
nodeFilterPreferences: NodeFilterPreferences,
) : NodeListViewModel(
savedStateHandle,
nodeRepository,
radioConfigRepository,
serviceRepository,
radioController,
nodeManagementActions,
getFilteredNodesUseCase,
nodeFilterPreferences,
)

View file

@ -38,7 +38,6 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.app.BuildConfig
import org.meshtastic.app.repository.network.NetworkRepository
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.handledLaunch
@ -53,6 +52,8 @@ import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.feature.connections.repository.NetworkRepository
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
@ -81,6 +82,13 @@ class AndroidRadioInterfaceService(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
listOf(
org.meshtastic.core.model.DeviceType.BLE,
org.meshtastic.core.model.DeviceType.TCP,
org.meshtastic.core.model.DeviceType.USB,
)
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
override val receivedData: SharedFlow<ByteArray> = _receivedData
@ -104,7 +112,7 @@ class AndroidRadioInterfaceService(
/** We recreate this scope each time we stop an interface */
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: IRadioInterface = NopInterface("")
private var radioIf: RadioTransport = NopInterface("")
/**
* true if we have started our interface

View file

@ -19,6 +19,7 @@ package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/**
* Entry point for create radio backend instances given a specific address.
@ -48,7 +49,7 @@ class InterfaceFactory(
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
fun createInterface(address: String, service: RadioInterfaceService): IRadioInterface {
fun createInterface(address: String, service: RadioInterfaceService): RadioTransport {
val (spec, rest) = splitAddress(address)
return spec?.createInterface(rest, service) ?: nopInterface
}

View file

@ -16,6 +16,8 @@
*/
package org.meshtastic.app.repository.radio
import org.meshtastic.core.repository.RadioTransport
/**
* Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to
* create new instances. These instances are specific to a particular address. This interface defines a common API
@ -23,6 +25,6 @@ package org.meshtastic.app.repository.radio
*
* This is primarily used in conjunction with Dagger assisted injection for each backend interface type.
*/
interface InterfaceFactorySpi<T : IRadioInterface> {
interface InterfaceFactorySpi<T : RadioTransport> {
fun create(rest: String): T
}

View file

@ -17,9 +17,10 @@
package org.meshtastic.app.repository.radio
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/** This interface defines the contract that all radio backend implementations must adhere to. */
interface InterfaceSpec<T : IRadioInterface> {
interface InterfaceSpec<T : RadioTransport> {
fun createInterface(rest: String, service: RadioInterfaceService): T
/** Return true if this address is still acceptable. For BLE that means, still bonded */

View file

@ -26,6 +26,7 @@ import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getInitials
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
@ -56,7 +57,7 @@ private val defaultChannel = ProtoChannel(settings = Channel.default.settings, r
/** A simulated interface that is used for testing in the simulator */
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
class MockInterface(private val service: RadioInterfaceService, val address: String) : IRadioInterface {
class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport {
companion object {
private const val MY_NODE = 0x42424242

View file

@ -16,7 +16,9 @@
*/
package org.meshtastic.app.repository.radio
class NopInterface(val address: String) : IRadioInterface {
import org.meshtastic.core.repository.RadioTransport
class NopInterface(val address: String) : RadioTransport {
override fun handleSendToRadio(p: ByteArray) {
// No-op
}

View file

@ -45,6 +45,7 @@ import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
@ -53,7 +54,7 @@ private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
* A [RadioTransport] implementation for BLE devices using Nordic Kotlin BLE Library.
* https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
@ -77,7 +78,7 @@ class NordicBleInterface(
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
val address: String,
) : IRadioInterface {
) : RadioTransport {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
@ -247,7 +248,7 @@ class NordicBleInterface(
private var radioService: MeshtasticRadioProfile.State? = null
// --- IRadioInterface Implementation ---
// --- RadioTransport Implementation ---
/**
* Sends a packet to the radio with retry support.

View file

@ -17,11 +17,11 @@
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import org.meshtastic.app.repository.usb.SerialConnection
import org.meshtastic.app.repository.usb.SerialConnectionListener
import org.meshtastic.app.repository.usb.UsbRepository
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.SerialConnection
import org.meshtastic.feature.connections.repository.SerialConnectionListener
import org.meshtastic.feature.connections.repository.UsbRepository
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */

View file

@ -17,8 +17,8 @@
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.app.repository.usb.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Factory for creating `SerialInterface` instances. */
@Single

View file

@ -19,8 +19,8 @@ package org.meshtastic.app.repository.radio
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
import org.koin.core.annotation.Single
import org.meshtastic.app.repository.usb.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Serial/USB interface backend implementation. */
@Single

View file

@ -18,32 +18,19 @@ package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
* probably)
* probably).
*
* Delegates framing logic to [StreamFrameCodec] from `core:network`.
*/
abstract class StreamInterface(protected val service: RadioInterfaceService) : IRadioInterface {
companion object {
private const val START1 = 0x94.toByte()
private const val START2 = 0xc3.toByte()
private const val MAX_TO_FROM_RADIO_SIZE = 512
}
abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport {
private val debugLineBuf = kotlin.text.StringBuilder()
private val writeMutex = Mutex()
/** The index of the next byte we are hoping to receive */
private var ptr = 0
/** The two halves of our length */
private var msb = 0
private var lsb = 0
private var packetLen = 0
private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface")
override fun close() {
Logger.d { "Closing stream for good" }
@ -64,8 +51,7 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : I
protected open fun connect() {
// Before telling mesh service, send a few START1s to wake a sleeping device
val wakeBytes = byteArrayOf(START1, START1, START1, START1)
sendBytes(wakeBytes)
sendBytes(StreamFrameCodec.WAKE_BYTES)
// Now tell clients they can (finally use the api)
service.onConnect()
@ -73,94 +59,16 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : I
abstract fun sendBytes(p: ByteArray)
// If subclasses need to flash at the end of a packet they can implement
// If subclasses need to flush at the end of a packet they can implement
open fun flushBytes() {}
override fun handleSendToRadio(p: ByteArray) {
// This method is called from a continuation and it might show up late, so check for uart being null
service.serviceScope.launch {
writeMutex.withLock {
val header = ByteArray(4)
header[0] = START1
header[1] = START2
header[2] = (p.size shr 8).toByte()
header[3] = (p.size and 0xff).toByte()
sendBytes(header)
sendBytes(p)
flushBytes()
}
}
service.serviceScope.launch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
}
/** Print device serial debug output somewhere */
private fun debugOut(b: Byte) {
when (val c = b.toInt().toChar()) {
'\r' -> {} // ignore
'\n' -> {
Logger.d { "DeviceLog: $debugLineBuf" }
debugLineBuf.clear()
}
else -> debugLineBuf.append(c)
}
}
private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE)
/** Process a single incoming byte through the stream framing state machine. */
protected fun readChar(c: Byte) {
// Assume we will be advancing our pointer
var nextPtr = ptr + 1
fun lostSync() {
Logger.e { "Lost protocol sync" }
nextPtr = 0
}
// Deliver our current packet and restart our reader
fun deliverPacket() {
val buf = rxPacket.copyOf(packetLen)
service.handleFromRadio(buf)
nextPtr = 0 // Start parsing the next packet
}
when (ptr) {
0 -> // looking for START1
if (c != START1) {
debugOut(c)
nextPtr = 0 // Restart from scratch
}
1 -> // Looking for START2
if (c != START2) {
lostSync() // Restart from scratch
}
2 -> // Looking for MSB of our 16 bit length
msb = c.toInt() and 0xff
3 -> { // Looking for LSB of our 16 bit length
lsb = c.toInt() and 0xff
// We've read our header, do one big read for the packet itself
packetLen = (msb shl 8) or lsb
if (packetLen > MAX_TO_FROM_RADIO_SIZE) {
lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for
// START1 again
} else if (packetLen == 0) {
deliverPacket() // zero length packets are valid and should be delivered immediately (because there
// won't be a next byte of payload)
}
}
else -> {
// We are looking at the packet bytes now
rxPacket[ptr - 4] = c
// Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this
// code will be run with ptr of4
if (ptr - 4 + 1 == packetLen) {
deliverPacket()
}
}
}
ptr = nextPtr
codec.processInputByte(c)
}
}

View file

@ -17,24 +17,19 @@
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.meshtastic.app.repository.network.NetworkRepository
import org.meshtastic.core.common.util.Exceptions
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.network.transport.TcpTransport
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.IOException
import java.io.OutputStream
import java.net.InetAddress
import java.net.Socket
import java.net.SocketTimeoutException
import org.meshtastic.core.repository.RadioTransport
/**
* Android TCP radio interface thin adapter over the shared [TcpTransport] from `core:network`.
*
* Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport
* layer.
*/
open class TCPInterface(
service: RadioInterfaceService,
private val dispatchers: CoroutineDispatchers,
@ -42,207 +37,55 @@ open class TCPInterface(
) : StreamInterface(service) {
companion object {
const val MAX_RETRIES_ALLOWED = Int.MAX_VALUE
const val MIN_BACKOFF_MILLIS = 1 * 1000L // 1 second
const val MAX_BACKOFF_MILLIS = 5 * 60 * 1000L // 5 minutes
const val SOCKET_TIMEOUT = 5000
const val SOCKET_RETRIES = 18
const val SERVICE_PORT = NetworkRepository.SERVICE_PORT
const val TIMEOUT_LOG_INTERVAL = 5 // Log every Nth timeout
const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT
}
private var retryCount = 1
private var backoffDelay = MIN_BACKOFF_MILLIS
private val transport =
TcpTransport(
dispatchers = dispatchers,
scope = service.serviceScope,
listener =
object : TcpTransport.Listener {
override fun onConnected() {
super@TCPInterface.connect()
}
private var socket: Socket? = null
private var outStream: OutputStream? = null
override fun onDisconnected() {
// Transport already performed teardown; only propagate lifecycle to StreamInterface.
super@TCPInterface.onDeviceDisconnect(false)
}
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
private var timeoutEvents: Int = 0
override fun onPacketReceived(bytes: ByteArray) {
service.handleFromRadio(bytes)
}
},
logTag = "TCPInterface[$address]",
)
init {
connect()
}
override fun sendBytes(p: ByteArray) {
val stream = outStream
if (stream == null) {
Logger.w { "[$address] TCP cannot send ${p.size} bytes: outStream is null (connection not established)" }
return
}
packetsSent++
bytesSent += p.size
Logger.d { "[$address] TCP sending packet #$packetsSent - ${p.size} bytes (Total TX: $bytesSent bytes)" }
try {
stream.write(p)
} catch (ex: IOException) {
// TCP write errors are common when the connection is lost; log as warning to avoid Crashlytics noise
Logger.w(ex) { "[$address] TCP write error: ${ex.message}" }
onDeviceDisconnect(false)
}
}
override fun flushBytes() {
val stream = outStream ?: return
Logger.d { "[$address] TCP flushing output stream" }
try {
stream.flush()
} catch (ex: IOException) {
// TCP flush errors are common when the connection is lost; log as warning to avoid Crashlytics noise
Logger.w(ex) { "[$address] TCP flush error: ${ex.message}" }
onDeviceDisconnect(false)
}
// Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat
Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" }
}
override fun onDeviceDisconnect(waitForStopped: Boolean) {
val s = socket
if (s != null) {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] TCP disconnecting - " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes), " +
"Timeout events: $timeoutEvents"
}
s.close()
socket = null
outStream = null
}
transport.stop()
super.onDeviceDisconnect(waitForStopped)
}
override fun connect() {
service.serviceScope.handledLaunch {
while (true) {
try {
startConnect()
} catch (ex: IOException) {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
// Connection failures are common when the radio is offline or out of range
Logger.w(ex) { "[$address] TCP connection error after ${uptime}ms - ${ex.message}" }
onDeviceDisconnect(false)
} catch (ex: Throwable) {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.e(ex) { "[$address] TCP exception after ${uptime}ms - ${ex.message}" }
Exceptions.report(ex, "Exception in TCP reader")
onDeviceDisconnect(false)
}
if (retryCount > MAX_RETRIES_ALLOWED) {
Logger.e { "[$address] TCP max retries ($MAX_RETRIES_ALLOWED) exceeded, giving up" }
break
}
Logger.i {
"[$address] TCP reconnect attempt #$retryCount in ${backoffDelay / 1000}s " +
"(backoff: ${backoffDelay}ms)"
}
delay(backoffDelay)
retryCount++
backoffDelay = minOf(backoffDelay * 2, MAX_BACKOFF_MILLIS)
}
Logger.i { "[$address] TCP reader exiting" }
}
transport.start(address)
}
override fun keepAlive() {
Logger.d { "[$address] TCP keepAlive" }
val heartbeat = ToRadio(heartbeat = Heartbeat())
handleSendToRadio(heartbeat.encode())
service.serviceScope.handledLaunch { transport.sendHeartbeat() }
}
// Create a socket to make the connection with the server
private suspend fun startConnect() = withContext(dispatchers.io) {
val attemptStart = nowMillis
Logger.i { "[$address] TCP connection attempt starting..." }
val parts = address.split(":", limit = 2)
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT
Logger.d { "[$address] Resolving host '$host' and connecting to port $port..." }
Socket(InetAddress.getByName(host), port).use { socket ->
socket.tcpNoDelay = true
socket.keepAlive = true
socket.soTimeout = SOCKET_TIMEOUT
this@TCPInterface.socket = socket
val connectTime = nowMillis - attemptStart
connectionStartTime = nowMillis
Logger.i {
"[$address] TCP socket connected in ${connectTime}ms - " +
"Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}"
}
BufferedOutputStream(socket.getOutputStream()).use { outputStream ->
outStream = outputStream
BufferedInputStream(socket.getInputStream()).use { inputStream ->
super.connect()
retryCount = 1
backoffDelay = MIN_BACKOFF_MILLIS
var timeoutCount = 0
while (timeoutCount < SOCKET_RETRIES) {
try { // close after 90s of inactivity
val c = inputStream.read()
if (c == -1) {
Logger.w {
"[$address] TCP got EOF on stream after $packetsReceived packets received"
}
break
} else {
timeoutCount = 0
packetsReceived++
bytesReceived++
readChar(c.toByte())
}
} catch (ex: SocketTimeoutException) {
timeoutCount++
timeoutEvents++
if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
Logger.d {
"[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " +
"(total timeouts: $timeoutEvents)"
}
}
// Ignore and start another read
}
}
if (timeoutCount >= SOCKET_RETRIES) {
val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT
Logger.w {
"[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " +
"(${inactivityMs}ms of inactivity)"
}
}
}
}
onDeviceDisconnect(false)
}
override fun handleSendToRadio(p: ByteArray) {
service.serviceScope.handledLaunch { transport.sendPacket(p) }
}
}

View file

@ -1,23 +0,0 @@
# USB Module
This module provides a repository for acessing USB devices.
## Device Support
In order to be picked up, devices need to be supported by two different mechanisms:
- Android needs to be supplied with a device filter so that it knows what devices to inform
the app about. These are expressed as vendor and device IDs in `src/res/xml/device_filter.xml`.
- The USB driver library also needs to have a mapping between the vendor + device IDs and the
driver to use for communications. Many mappings are already natively supported by the driver
but unknown devices can have manual mappings added via `ProbeTableProvider`.
The [Serial USB Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_usb_terminal)
app in the Google Play Store seems to be a good app for determining both the vendor and
device IDs as well as testing different underlying drivers.
## Testing
When granting permissions to a USB device, the Android platform remembers the user's decision.
In order to test the permission granting logic, re-install the app. This will cause Android
to forget previously granted permissions and will re-trigger the permission acquisition logic.

View file

@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.inject
import org.meshtastic.app.BuildConfig
import org.meshtastic.app.ui.connections.NO_DEVICE_SELECTED
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
@ -55,6 +54,7 @@ import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.IMeshService
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.proto.PortNum
@Suppress("TooManyFunctions", "LargeClass")

View file

@ -1,28 +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.app.settings
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
@KoinViewModel
class AndroidCleanNodeDatabaseViewModel(
cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
alertManager: AlertManager,
) : CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)

View file

@ -34,6 +34,7 @@ import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
@ -47,6 +48,7 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream
@KoinViewModel
@Suppress("LongParameterList")
class AndroidSettingsViewModel(
private val app: Application,
radioConfigRepository: RadioConfigRepository,
@ -57,6 +59,7 @@ class AndroidSettingsViewModel(
databaseManager: DatabaseManager,
meshLogPrefs: MeshLogPrefs,
setThemeUseCase: SetThemeUseCase,
setLocaleUseCase: SetLocaleUseCase,
setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
setProvideLocationUseCase: SetProvideLocationUseCase,
setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
@ -73,6 +76,7 @@ class AndroidSettingsViewModel(
databaseManager,
meshLogPrefs,
setThemeUseCase,
setLocaleUseCase,
setAppIntroCompletedUseCase,
setProvideLocationUseCase,
setDatabaseCacheLimitUseCase,

View file

@ -20,14 +20,10 @@ package org.meshtastic.app.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
@ -58,14 +54,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
@ -73,9 +63,6 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@ -89,33 +76,22 @@ import org.meshtastic.app.navigation.mapGraph
import org.meshtastic.app.navigation.nodesGraph
import org.meshtastic.app.navigation.settingsGraph
import org.meshtastic.app.service.MeshService
import org.meshtastic.app.ui.connections.DeviceType
import org.meshtastic.app.ui.connections.ScannerViewModel
import org.meshtastic.app.ui.connections.components.ConnectionsNavIcon
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.firmware_old
import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.map
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
@ -123,34 +99,15 @@ import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.icon.Settings
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.core.ui.util.toMessageRes
enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) {
Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph),
Nodes(Res.string.nodes, MeshtasticIcons.Nodes, NodesRoutes.NodesGraph),
Map(Res.string.map, MeshtasticIcons.Map, MapRoutes.Map()),
Settings(Res.string.bottom_nav_settings, MeshtasticIcons.Settings, SettingsRoutes.SettingsGraph()),
Connections(Res.string.connections, MeshtasticIcons.Wifi, ConnectionsRoutes.ConnectionsGraph),
;
companion object {
fun fromNavKey(key: NavKey?): TopLevelDestination? =
entries.find { dest -> key?.let { it::class == dest.route::class } == true }
}
}
import org.meshtastic.feature.connections.ScannerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -254,37 +211,6 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
// State for managing the glow animation around the Connections icon
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
val animatedGlowAlpha = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
val capturedColorScheme = colorScheme // Capture current colorScheme instance for LaunchedEffect
val sendColor = capturedColorScheme.StatusGreen
val receiveColor = capturedColorScheme.StatusBlue
LaunchedEffect(uIViewModel.meshActivity, capturedColorScheme) {
uIViewModel.meshActivity.collectLatest { activity ->
Logger.d { "MeshActivity received in UI: $activity" }
val newTargetColor =
when (activity) {
is MeshActivity.Send -> sendColor
is MeshActivity.Receive -> receiveColor
}
currentGlowColor = newTargetColor
// Stop any existing animation and launch a new one.
// Launching in a new coroutine ensures the collect block is not suspended.
coroutineScope.launch {
animatedGlowAlpha.stop() // Stop before snapping/animating
animatedGlowAlpha.snapTo(1.0f) // Show glow instantly
animatedGlowAlpha.animateTo(
targetValue = 0.0f, // Fade out
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
}
}
}
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
@ -316,44 +242,12 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
Box(
modifier =
Modifier.drawWithCache {
val glowRadius = size.minDimension
val glowBrush =
Brush.radialGradient(
colors =
listOf(
currentGlowColor.copy(alpha = 0.8f),
currentGlowColor.copy(alpha = 0.4f),
Color.Transparent,
),
center =
androidx.compose.ui.geometry.Offset(
size.width / 2,
size.height / 2,
),
radius = glowRadius,
)
onDrawWithContent {
drawContent()
val alpha = animatedGlowAlpha.value
if (alpha > 0f) {
drawCircle(
brush = glowBrush,
radius = glowRadius,
alpha = alpha,
blendMode = BlendMode.Screen,
)
}
}
},
) {
ConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
)
}
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} else {
BadgedBox(
badge = {

View file

@ -1,306 +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.app.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldLabelPosition
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.model.DeviceListEntry
import org.meshtastic.app.repository.network.NetworkRepository
import org.meshtastic.app.ui.connections.ScannerViewModel
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.confirm_forget_connection
import org.meshtastic.core.resources.discovered_network_devices
import org.meshtastic.core.resources.forget_connection
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
fun NetworkDevices(
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: ScannerViewModel,
) {
val searchDialogState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showSearchDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var deviceToDelete by remember { mutableStateOf<DeviceListEntry?>(null) }
if (showSearchDialog) {
AddDeviceDialog(
searchDialogState,
onHideDialog = { showSearchDialog = false },
onClickAdd = { address, fullAddress ->
scanModel.onSelected(DeviceListEntry.Tcp(address, fullAddress))
showSearchDialog = false
},
)
}
if (showDeleteDialog) {
deviceToDelete?.let {
ConfirmDeleteDialog(
it.fullAddress,
onHideDialog = {
showDeleteDialog = false
deviceToDelete = null
},
onConfirm = { deviceFullAddress -> scanModel.removeRecentAddress(deviceFullAddress) },
)
}
}
NetworkDevicesInternal(
connectionState = connectionState,
discoveredNetworkDevices = discoveredNetworkDevices,
recentNetworkDevices = recentNetworkDevices,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onDelete = { device ->
deviceToDelete = device
showDeleteDialog = true
},
onClickAdd = { showSearchDialog = true },
)
}
@Composable
private fun NetworkDevicesInternal(
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
onSelect: (DeviceListEntry) -> Unit,
onDelete: (DeviceListEntry) -> Unit,
onClickAdd: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val addButton: @Composable () -> Unit = {
Button(onClick = onClickAdd) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(Res.string.add_network_device),
)
Text(stringResource(Res.string.add_network_device))
}
}
when {
discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty() -> {
EmptyStateContent(
imageVector = Icons.Rounded.Wifi,
text = stringResource(Res.string.no_network_devices),
actionButton = addButton,
)
}
else -> {
if (recentNetworkDevices.isNotEmpty()) {
recentNetworkDevices.DeviceListSection(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = onSelect,
onDelete = onDelete,
)
}
if (discoveredNetworkDevices.isNotEmpty()) {
discoveredNetworkDevices.DeviceListSection(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = onSelect,
)
}
addButton()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddDeviceDialog(
sheetState: SheetState,
onHideDialog: () -> Unit,
onClickAdd: (address: String, fullAddress: String) -> Unit,
) {
val addressState = rememberTextFieldState("")
val portState = rememberTextFieldState(NetworkRepository.SERVICE_PORT.toString())
val scope = rememberCoroutineScope()
@Suppress("MagicNumber")
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = addressState,
labelPosition = TextFieldLabelPosition.Above(),
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f),
)
OutlinedTextField(
state = portState,
labelPosition = TextFieldLabelPosition.Above(),
placeholder = { Text(NetworkRepository.SERVICE_PORT.toString()) },
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.ip_port)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(.3f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
Text(stringResource(Res.string.cancel))
}
Button(
modifier = Modifier.weight(1f),
onClick = {
val address = addressState.text.toString()
if (address.isValidAddress()) {
val portString = portState.text.toString()
val combinedString =
if (portString.isNotEmpty() && portString.toInt() != NetworkRepository.SERVICE_PORT) {
"$address:$portString"
} else {
address
}
onClickAdd(addressState.text.toString(), "t$combinedString")
scope
.launch { sheetState.hide() }
.invokeOnCompletion {
if (!sheetState.isVisible) {
onHideDialog()
}
}
}
},
) {
Text(stringResource(Res.string.add_network_device))
}
}
}
}
}
@Composable
private fun ConfirmDeleteDialog(
fullAddressToDelete: String,
onHideDialog: () -> Unit,
onConfirm: (deviceFullAddress: String) -> Unit,
) {
MeshtasticResourceDialog(
onDismiss = onHideDialog,
titleRes = Res.string.forget_connection,
messageRes = Res.string.confirm_forget_connection,
confirmTextRes = Res.string.forget_connection,
onConfirm = {
onConfirm(fullAddressToDelete)
onHideDialog()
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewLightDark
@Composable
private fun SearchDialogPreview() {
AppTheme {
AddDeviceDialog(sheetState = rememberModalBottomSheetState(), onHideDialog = {}, onClickAdd = { _, _ -> })
}
}
@PreviewLightDark
@Composable
private fun ConfirmDeleteDialogPreview() {
AppTheme { ConfirmDeleteDialog(fullAddressToDelete = "", onHideDialog = {}, onConfirm = {}) }
}
@PreviewLightDark
@Composable
private fun NetworkDevicesPreview() {
AppTheme {
NetworkDevicesInternal(
connectionState = ConnectionState.Disconnected,
discoveredNetworkDevices = listOf(DeviceListEntry.Tcp("Meshtastic", "t192.168.1.3")),
recentNetworkDevices =
listOf(
DeviceListEntry.Tcp("Home Node", "t192.168.1.100"),
DeviceListEntry.Tcp("Office", "t192.168.1.101"),
),
selectedDevice = "",
onSelect = {},
onDelete = {},
onClickAdd = {},
)
}
}

View file

@ -17,18 +17,6 @@
package org.meshtastic.app.ui.node
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
@ -39,28 +27,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.node.AndroidCompassViewModel
import org.meshtastic.app.node.AndroidNodeDetailViewModel
import org.meshtastic.app.node.AndroidNodeListViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.list.NodeListViewModel
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@ -71,7 +57,7 @@ fun AdaptiveNodeListScreen(
initialNodeId: Int? = null,
onNavigateToMessages: (String) -> Unit = {},
) {
val nodeListViewModel: AndroidNodeListViewModel = koinViewModel()
val nodeListViewModel: NodeListViewModel = koinViewModel()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
@ -140,8 +126,8 @@ fun AdaptiveNodeListScreen(
navigator.currentDestination?.contentKey?.let { nodeId ->
key(nodeId) {
LaunchedEffect(nodeId) { focusManager.clearFocus() }
val nodeDetailViewModel: AndroidNodeDetailViewModel = koinViewModel()
val compassViewModel: AndroidCompassViewModel = koinViewModel()
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
val compassViewModel: CompassViewModel = koinViewModel()
NodeDetailScreen(
nodeId = nodeId,
viewModel = nodeDetailViewModel,
@ -151,40 +137,8 @@ fun AdaptiveNodeListScreen(
onNavigateUp = handleBack,
)
}
} ?: PlaceholderScreen()
} ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
}
},
)
}
@Composable
fun NodeTabTitle() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = MeshtasticIcons.Nodes, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(
text = stringResource(Res.string.nodes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun PlaceholderScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(
imageVector = MeshtasticIcons.Nodes,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.nodes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -95,6 +95,7 @@ import org.meshtastic.core.ui.component.QrDialog
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.generateQrCode
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.channel.ChannelViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.util
import co.touchlab.kermit.Logger
import java.io.IOException
/**
* Provides the AboutLibraries JSON data for the About screen.
*
* The JSON is generated by the AboutLibraries Gradle plugin during the build process. For Android, we load it from the
* application's assets or classpath resource.
*/
object AboutLibrariesJsonProvider {
private val logger = Logger.withTag("AboutLibrariesJsonProvider")
/**
* Returns the AboutLibraries JSON string.
*
* Since the AboutLibraries Gradle plugin generates the JSON at build time, we attempt to load it from the
* classpath. If that fails, we return an empty object to allow the app to gracefully degrade.
*/
suspend fun getJson(): String = try {
val resource = AboutLibrariesJsonProvider::class.java.classLoader?.getResource("aboutlibraries.json")
if (resource != null) {
resource.readText()
} else {
// Fallback: return an empty libraries object
logger.w("AboutLibraries JSON resource not found in classpath")
"""{"libraries":[]}"""
}
} catch (e: SecurityException) {
// Security exception when accessing resources - return fallback
logger.w("SecurityException loading AboutLibraries JSON: ${e.message}")
"""{"libraries":[]}"""
} catch (e: IllegalStateException) {
// Libraries not generated/available - return fallback
logger.w("IllegalStateException loading AboutLibraries JSON: ${e.message}")
"""{"libraries":[]}"""
} catch (e: IOException) {
// I/O exception when reading resource - return fallback
logger.w("IOException loading AboutLibraries JSON: ${e.message}")
"""{"libraries":[]}"""
}
}

View file

@ -16,39 +16,37 @@
*/
package org.meshtastic.app.repository.radio
import io.mockk.every
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.app.service.Fakes
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
class TCPInterfaceTest {
@Test
fun testKeepAlive() {
val fakes = Fakes()
val testDispatcher = UnconfinedTestDispatcher()
val testScope = CoroutineScope(testDispatcher + Job())
every { fakes.service.serviceScope } returns testScope
fun testHeartbeatFraming() = runTest {
val sentBytes = mutableListOf<ByteArray>()
val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
val tcpIf =
object : TCPInterface(fakes.service, dispatchers, "127.0.0.1") {
var lastSent: ByteArray? = null
val codec = StreamFrameCodec(onPacketReceived = {}, logTag = "Test")
override fun handleSendToRadio(p: ByteArray) {
lastSent = p
}
}
val heartbeat = ToRadio(heartbeat = Heartbeat()).encode()
codec.frameAndSend(heartbeat, { sentBytes.add(it) })
tcpIf.keepAlive()
// First sent bytes are the 4-byte header, second is the payload
assertEquals(2, sentBytes.size)
val header = sentBytes[0]
assertEquals(4, header.size)
assertEquals(0x94.toByte(), header[0])
assertEquals(0xc3.toByte(), header[1])
val expected = ToRadio(heartbeat = Heartbeat()).encode()
assertEquals(expected.toList(), tcpIf.lastSent?.toList())
val payload = sentBytes[1]
assertEquals(heartbeat.toList(), payload.toList())
}
@Test
fun testServicePort() {
assertEquals(4403, TCPInterface.SERVICE_PORT)
}
}

View file

@ -167,6 +167,11 @@ gradlePlugin {
implementationClass = "KmpLibraryConventionPlugin"
}
register("kmpJvmAndroid") {
id = "meshtastic.kmp.jvm.android"
implementationClass = "KmpJvmAndroidConventionPlugin"
}
register("kmpLibraryCompose") {
id = "meshtastic.kmp.library.compose"
implementationClass = "KmpLibraryComposeConventionPlugin"

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
@ -24,12 +24,19 @@ import org.meshtastic.buildlogic.configureAndroidCompose
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
/**
* Compose configuration for Android applications.
*
* Note: This has identical implementation to AndroidLibraryComposeConventionPlugin.
* Both use the same configureAndroidCompose() function which works with CommonExtension.
* Kept separate to maintain explicit intent in build.gradle.kts configuration despite duplication.
*/
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = libs.plugin("compose-compiler").get().pluginId)
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure<CommonExtension> {
extensions.configure<ApplicationExtension> {
configureAndroidCompose(this)
}
}

View file

@ -21,6 +21,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configureFlavors
/**
* Flavor configuration for Android applications.
*
* Optimization note: This is nearly identical to AndroidLibraryFlavorsConventionPlugin.
* The underlying configureFlavors() function already handles both ApplicationExtension and LibraryExtension.
* Could be consolidated into a single plugin accepting CommonExtension, but kept separate for now
* to maintain explicit intent in build.gradle.kts declarations.
*/
class AndroidApplicationFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
@ -24,12 +24,19 @@ import org.meshtastic.buildlogic.configureAndroidCompose
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
/**
* Compose configuration for Android libraries.
*
* Note: This has identical implementation to AndroidApplicationComposeConventionPlugin.
* Both use the same configureAndroidCompose() function which works with CommonExtension.
* Kept separate to maintain explicit intent in build.gradle.kts configuration despite duplication.
*/
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = libs.plugin("compose-compiler").get().pluginId)
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure<CommonExtension> {
extensions.configure<LibraryExtension> {
configureAndroidCompose(this)
}
}

View file

@ -21,6 +21,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configureFlavors
/**
* Flavor configuration for Android libraries.
*
* Optimization note: This is nearly identical to AndroidApplicationFlavorsConventionPlugin.
* The underlying configureFlavors() function already handles both ApplicationExtension and LibraryExtension.
* Could be consolidated into a single plugin accepting CommonExtension, but kept separate for now
* to maintain explicit intent in build.gradle.kts declarations.
*/
class AndroidLibraryFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {

View file

@ -0,0 +1,33 @@
/*
* 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/>.
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.meshtastic.buildlogic.configureJvmAndroidMainHierarchy
/**
* Opt-in convention for KMP modules that intentionally share a `jvmAndroidMain` source set
* between the desktop JVM target and the Android target.
*/
class KmpJvmAndroidConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
configureJvmAndroidMainHierarchy()
}
}
}

View file

@ -18,6 +18,8 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback
import org.meshtastic.buildlogic.configureKmpTestDependencies
import org.meshtastic.buildlogic.configureKotlinMultiplatform
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
@ -34,6 +36,8 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
apply(plugin = "meshtastic.kover")
configureKotlinMultiplatform()
configureKmpTestDependencies()
configureAndroidMarketplaceFallback()
}
}
}

View file

@ -46,6 +46,16 @@ class KoinConventionPlugin : Plugin<Project> {
}
}
}
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
// If this is *only* a JVM module (no KMP plugin)
if (!pluginManager.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
dependencies {
add("implementation", koinCore)
add("implementation", koinAnnotations)
}
}
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* 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.buildlogic
import org.gradle.api.Project
import org.gradle.api.attributes.Attribute
private const val MARKETPLACE_ATTRIBUTE_NAME = "com.android.build.api.attributes.ProductFlavor:marketplace"
internal fun Project.configureAndroidMarketplaceFallback() {
val defaultMarketplace =
providers
.gradleProperty("meshtastic.defaultMarketplace")
.orElse(MeshtasticFlavor.entries.first { it.default }.name)
.get()
val marketplaceAttr = Attribute.of(MARKETPLACE_ATTRIBUTE_NAME, String::class.java)
afterEvaluate {
configurations.all {
if (!isCanBeResolved || isCanBeConsumed) return@all
if (!name.contains("android", ignoreCase = true)) return@all
if (attributes.getAttribute(marketplaceAttr) != null) return@all
// Prefer explicit flavor from configuration name; otherwise use configurable default.
val inferredMarketplace =
when {
name.contains(MeshtasticFlavor.fdroid.name, ignoreCase = true) -> MeshtasticFlavor.fdroid.name
name.contains(MeshtasticFlavor.google.name, ignoreCase = true) -> MeshtasticFlavor.google.name
else -> defaultMarketplace
}
attributes.attribute(marketplaceAttr, inferredMarketplace)
}
}
}

View file

@ -25,11 +25,13 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
@ -81,6 +83,48 @@ internal fun Project.configureKotlinMultiplatform() {
configureKotlin<KotlinMultiplatformExtension>()
}
/**
* Configure a shared `jvmAndroidMain` source set using Kotlin's hierarchy template DSL.
*
* This is for modules that intentionally share JVM-only implementations between the desktop
* `jvm()` target and the Android target without hand-written `dependsOn` edges.
*/
@OptIn(ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureJvmAndroidMainHierarchy() {
extensions.configure<KotlinMultiplatformExtension> {
applyHierarchyTemplate(KotlinHierarchyTemplate.default) {
common {
group("jvmAndroid") {
withCompilations { compilation ->
compilation.target.targetName == "android" ||
compilation.target.targetName == "jvm"
}
}
}
}
}
}
/**
* Configure common test dependencies for KMP modules
*/
internal fun Project.configureKmpTestDependencies() {
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.apply {
val commonTest = findByName("commonTest") ?: return@apply
commonTest.dependencies {
implementation(kotlin("test"))
}
// Configure androidHostTest if it exists
val androidHostTest = findByName("androidHostTest")
androidHostTest?.dependencies {
implementation(kotlin("test"))
}
}
}
}
/**
* Configure base Kotlin options for JVM (non-Android)
*/
@ -107,6 +151,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xexpect-actual-classes",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check"

View file

@ -1,31 +1,40 @@
# `:core:barcode`
## Overview
The `:core:barcode` module provides barcode and QR code scanning capabilities using Google ML Kit and CameraX. It is used for scanning node configuration, pairing, and contact sharing.
The `:core:barcode` module provides barcode and QR code scanning capabilities using CameraX and flavor-specific decoding engines. It is used for scanning node configuration, pairing, and contact sharing.
The shared contract (`BarcodeScanner` interface + `LocalBarcodeScannerProvider`) lives in `core:ui/commonMain`, keeping this module Android-only.
## Key Components
### 1. `BarcodeScanner`
A Composable component that provides a live camera preview and detects barcodes/QR codes in real-time.
### 1. `rememberBarcodeScanner`
A Composable function (in `main/`) that provides camera permission handling, a full-screen scanner dialog with live preview and reticule overlay, and returns a `BarcodeScanner` instance.
- **Technology:** Uses **CameraX** for camera lifecycle management and **ML Kit Barcode Scanning** for detection.
- **Flavors:** Uses the bundled ML Kit library to ensure consistent performance across both `google` and `fdroid` flavors without depending on Google Play Services.
- **Technology:** Uses **CameraX** for camera lifecycle management.
- **Flavors:** Barcode decoding is the only flavor-specific code:
- `google/`**ML Kit** (`BarcodeScanning` + `InputImage`) via `createBarcodeAnalyzer()`
- `fdroid/`**ZXing** (`MultiFormatReader` + `PlanarYUVLuminanceSource`) via `createBarcodeAnalyzer()`
- All shared UI (dialog, reticule, permissions, camera lifecycle) lives in `main/`.
### 2. `BarcodeUtil`
Utility functions for generating and parsing Meshtastic-specific QR codes (e.g., node URLs).
## Source Layout
```
src/
├── main/ BarcodeScannerProvider.kt (shared UI)
├── google/ BarcodeAnalyzerFactory.kt (ML Kit decoder)
├── fdroid/ BarcodeAnalyzerFactory.kt (ZXing decoder)
├── test/ Unit tests
└── androidTest/ Instrumented tests
```
## Usage
The module exposes a scanner that can be integrated into any Compose screen.
```kotlin
BarcodeScanner(
onBarcodeDetected = { barcode ->
// Handle scanned barcode
},
onDismiss = {
// Handle dismiss
}
)
// In a Composable (typically wired via LocalBarcodeScannerProvider in app/)
val scanner = rememberBarcodeScanner { result ->
// Handle scanned QR code string (or null on dismiss)
}
scanner.startScan()
```
## Module dependency graph

View file

@ -0,0 +1,54 @@
/*
* 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.barcode
import androidx.camera.core.ImageAnalysis
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.nio.ByteBuffer
/**
* Creates a CameraX [ImageAnalysis.Analyzer] that decodes QR codes using ZXing.
*
* This is the F-Droid flavor implementation; the Google flavor uses ML Kit instead.
*/
internal fun createBarcodeAnalyzer(onResult: (String) -> Unit): ImageAnalysis.Analyzer {
val reader = MultiFormatReader()
return ImageAnalysis.Analyzer { imageProxy ->
try {
val buffer: ByteBuffer = imageProxy.planes[0].buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
val width = imageProxy.width
val height = imageProxy.height
val source = PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val result = reader.decodeWithState(binaryBitmap)
result.text?.let { onResult(it) }
} catch (_: Exception) {
// Ignore decoding errors — no barcode found in this frame
} finally {
imageProxy.close()
}
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.barcode
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import co.touchlab.kermit.Logger
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
/**
* Creates a CameraX [ImageAnalysis.Analyzer] that decodes QR codes using Google ML Kit.
*
* This is the Google flavor implementation; the F-Droid flavor uses ZXing instead.
*/
@androidx.annotation.OptIn(ExperimentalGetImage::class)
internal fun createBarcodeAnalyzer(onResult: (String) -> Unit): ImageAnalysis.Analyzer {
val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
val scanner = BarcodeScanning.getClient(options)
return ImageAnalysis.Analyzer { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner
.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcode.rawValue?.let { onResult(it) }
}
}
.addOnFailureListener { Logger.e { "Barcode scanning failed: ${it.message}" } }
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
}

View file

@ -1,256 +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/>.
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package org.meshtastic.core.barcode
import android.Manifest
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.util.concurrent.Executors
@Composable
fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
var showDialog by remember { mutableStateOf(false) }
var pendingScan by remember { mutableStateOf(false) }
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(cameraPermissionState.status.isGranted) {
if (cameraPermissionState.status.isGranted && pendingScan) {
showDialog = true
pendingScan = false
}
}
if (showDialog) {
BarcodeScannerDialog(
onResult = {
showDialog = false
onResult(it)
},
onDismiss = {
showDialog = false
onResult(null)
},
)
}
return remember {
object : BarcodeScanner {
override fun startScan() {
if (cameraPermissionState.status.isGranted) {
showDialog = true
} else {
pendingScan = true
cameraPermissionState.launchPermissionRequest()
}
}
}
}
}
@Composable
private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> Unit) {
var isCameraReady by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(modifier = Modifier.fillMaxSize()) {
ScannerView(onResult = onResult, onCameraReady = { isCameraReady = it })
if (isCameraReady) {
ScannerReticule()
}
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(Res.string.close),
tint = Color.White,
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
private fun ScannerReticule() {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val reticleSize = width.coerceAtMost(height) * 0.7f
val left = (width - reticleSize) / 2
val top = (height - reticleSize) / 2
val rect = Rect(left, top, left + reticleSize, top + reticleSize)
// Draw semi-transparent background with a hole
clipPath(Path().apply { addRect(rect) }, clipOp = ClipOp.Difference) {
drawRect(Color.Black.copy(alpha = 0.6f))
}
// Draw reticle corners
val strokeWidth = 3.dp.toPx()
val cornerLength = 40.dp.toPx()
val color = Color.White
// Corners
val path =
Path().apply {
// Top Left
moveTo(left, top + cornerLength)
lineTo(left, top)
lineTo(left + cornerLength, top)
// Top Right
moveTo(left + reticleSize - cornerLength, top)
lineTo(left + reticleSize, top)
lineTo(left + reticleSize, top + cornerLength)
// Bottom Right
moveTo(left + reticleSize, top + reticleSize - cornerLength)
lineTo(left + reticleSize, top + reticleSize)
lineTo(left + reticleSize - cornerLength, top + reticleSize)
// Bottom Left
moveTo(left + cornerLength, top + reticleSize)
lineTo(left, top + reticleSize)
lineTo(left, top + reticleSize - cornerLength)
}
drawPath(path, color, style = Stroke(strokeWidth))
}
}
@Suppress("LongMethod")
@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
val barcodeScanner = remember {
val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
BarcodeScanning.getClient(options)
}
DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } }
LaunchedEffect(Unit) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(
{
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build()
preview.setSurfaceProvider { request ->
surfaceRequest = request
onCameraReady(true)
}
val imageAnalysis =
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { analysis ->
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image =
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner
.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcode.rawValue?.let { onResult(it) }
}
}
.addOnFailureListener { Logger.e { "Barcode scanning failed: ${it.message}" } }
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis,
)
} catch (exc: IllegalStateException) {
Logger.e(exc) { "Use case binding failed" }
} catch (exc: IllegalArgumentException) {
Logger.e(exc) { "Use case binding failed" }
} catch (exc: UnsupportedOperationException) {
Logger.e(exc) { "Use case binding failed" }
}
},
ContextCompat.getMainExecutor(context),
)
}
surfaceRequest?.let { CameraXViewfinder(surfaceRequest = it, modifier = Modifier.fillMaxSize()) }
}

View file

@ -21,7 +21,6 @@ package org.meshtastic.core.barcode
import android.Manifest
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
@ -59,15 +58,10 @@ import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.nio.ByteBuffer
import java.util.concurrent.Executors
@Composable
@ -181,7 +175,6 @@ private fun ScannerReticule() {
}
@Suppress("LongMethod")
@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
val context = LocalContext.current
@ -189,8 +182,6 @@ private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) ->
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
val barcodeScanner = remember { MultiFormatReader() }
DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } }
LaunchedEffect(Unit) {
@ -209,29 +200,7 @@ private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) ->
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { analysis ->
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
try {
val buffer: ByteBuffer = imageProxy.planes[0].buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
val width = imageProxy.width
val height = imageProxy.height
val source =
PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val result = barcodeScanner.decodeWithState(binaryBitmap)
result.text?.let { onResult(it) }
} catch (e: Exception) {
// Ignore decoding errors
} finally {
imageProxy.close()
}
}
}
.also { analysis -> analysis.setAnalyzer(cameraExecutor, createBarcodeAnalyzer(onResult)) }
try {
cameraProvider.unbindAll()

View file

@ -53,7 +53,7 @@ A utility for executing BLE operations with retry logic, essential for handling
## Integration in `app`
The `:core:ble` module is used by `NordicBleInterface` in the main application module to implement the `IRadioInterface` for Bluetooth devices.
The `:core:ble` module is used by `NordicBleInterface` in the main application module to implement the `RadioTransport` interface for Bluetooth devices.
## Usage

View file

@ -21,6 +21,8 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.ble"

View file

@ -18,10 +18,13 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.kmp.jvm.android")
id("meshtastic.koin")
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
androidResources.enable = false
@ -31,6 +34,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.javax.inject)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
@ -40,9 +44,6 @@ kotlin {
api(libs.androidx.core.ktx)
api(libs.nordic.common.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}

View file

@ -31,4 +31,7 @@ interface DatabaseManager {
/** Switches the active database to the one associated with the given [address]. */
suspend fun switchActiveDatabase(address: String?)
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean
}

View file

@ -16,9 +16,13 @@
*/
package org.meshtastic.core.common.util
/** Platform-agnostic Base64 utility. */
expect object Base64Factory {
fun encode(data: ByteArray): String
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
fun decode(data: String): ByteArray
/** Pure Kotlin Base64 utility — no expect/actual needed. */
@OptIn(ExperimentalEncodingApi::class)
object Base64Factory {
fun encode(data: ByteArray): String = Base64.Default.encode(data)
fun decode(data: String): ByteArray = Base64.Default.decode(data)
}

View file

@ -16,11 +16,31 @@
*/
package org.meshtastic.core.common.util
/** Platform-agnostic number formatting utility. */
expect object NumberFormatter {
import kotlin.math.pow
import kotlin.math.roundToLong
/** Pure Kotlin number formatting utility — no expect/actual needed. */
object NumberFormatter {
/** Formats a double value with the specified number of decimal places. */
fun format(value: Double, decimalPlaces: Int): String
fun format(value: Double, decimalPlaces: Int): String {
val factor = 10.0.pow(decimalPlaces)
val rounded = (value * factor).roundToLong()
return formatFixedPoint(rounded, decimalPlaces)
}
/** Formats a float value with the specified number of decimal places. */
fun format(value: Float, decimalPlaces: Int): String
fun format(value: Float, decimalPlaces: Int): String = format(value.toDouble(), decimalPlaces)
private fun formatFixedPoint(scaledValue: Long, decimalPlaces: Int): String {
if (decimalPlaces == 0) return scaledValue.toString()
val isNegative = scaledValue < 0
val abs = if (isNegative) -scaledValue else scaledValue
val factor = 10.0.pow(decimalPlaces).toLong()
val intPart = abs / factor
val fracPart = abs % factor
val sign = if (isNegative) "-" else ""
return "$sign$intPart.${fracPart.toString().padStart(decimalPlaces, '0')}"
}
}

View file

@ -17,12 +17,12 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import org.koin.core.annotation.Factory
import java.util.concurrent.atomic.AtomicReference
/**
* A helper class that manages a single [Job]. When a new job is launched, any previous job is cancelled. This is useful
@ -31,7 +31,7 @@ import java.util.concurrent.atomic.AtomicReference
*/
@Factory
class SequentialJob {
private val job = AtomicReference<Job?>()
private val job = atomic<Job?>(null)
/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
@ -56,7 +56,7 @@ class SequentialJob {
block()
}
}
job.set(newJob)
job.value = newJob
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
}

View file

@ -31,67 +31,3 @@ interface Continuation<in T> {
class CallbackContinuation<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
override fun resume(res: Result<T>) = cb(res)
}
/**
* A blocking version of coroutine Continuation using traditional threading primitives.
*
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
*/
class SyncContinuation<T> : Continuation<T> {
private val lock = java.util.concurrent.locks.ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
lock.lock()
try {
result = res
condition.signal()
} finally {
lock.unlock()
}
}
/**
* Blocks the current thread until the result is available or the timeout expires.
*
* @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely.
* @return The result of the operation.
* @throws IllegalStateException if a timeout occurs or if an internal error happens.
*/
@Suppress("NestedBlockDepth")
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
try {
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
check(remaining > 0) { "SyncContinuation timeout" }
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
} else {
condition.await()
}
}
val r = result
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
return r.getOrThrow()
} finally {
lock.unlock()
}
}
}
/**
* Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the
* current thread until the operation completes or times out.
*
* Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
initfn(cont)
return cont.await(timeoutMsecs)
}

View file

@ -16,7 +16,33 @@
*/
package org.meshtastic.core.common.util
/** Platform-agnostic URL encoding utility. */
expect object UrlUtils {
fun encode(value: String): String
/** Pure Kotlin URL encoding utility — no expect/actual needed. */
object UrlUtils {
/**
* Percent-encodes a string for use in a URL query parameter (RFC 3986). Unreserved characters (A-Z, a-z, 0-9, `-`,
* `_`, `.`, `~`) are not encoded. Spaces are encoded as `%20` (not `+`).
*/
@Suppress("MagicNumber")
fun encode(value: String): String = buildString {
for (byte in value.encodeToByteArray()) {
val char = byte.toInt().toChar()
if (char.isUnreserved()) {
append(char)
} else {
append('%')
append(HEX_DIGITS[(byte.toInt() shr 4) and 0x0F])
append(HEX_DIGITS[byte.toInt() and 0x0F])
}
}
}
private fun Char.isUnreserved(): Boolean = this in 'A'..'Z' ||
this in 'a'..'z' ||
this in '0'..'9' ||
this == '-' ||
this == '_' ||
this == '.' ||
this == '~'
private val HEX_DIGITS = "0123456789ABCDEF".toCharArray()
}

View file

@ -14,7 +14,7 @@
* 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.barcode
package org.meshtastic.core.common.util
/**
* Extracts WIFI SSID and password from a QR code string. Expected format: WIFI:S:SSID;P:PASSWORD;;

View file

@ -14,16 +14,16 @@
* 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.barcode
package org.meshtastic.core.common.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class BarcodeUtilTest {
class WifiCredentialsTest {
@Test
fun `extractWifiCredentials should parse valid QR code`() {
fun extractWifiCredentials_shouldParseValidQrCode() {
val qrCode = "WIFI:S:MyNetwork;P:MyPassword;;"
val (ssid, password) = extractWifiCredentials(qrCode)
assertEquals("MyNetwork", ssid)
@ -31,7 +31,7 @@ class BarcodeUtilTest {
}
@Test
fun `extractWifiCredentials should return null for invalid QR code`() {
fun extractWifiCredentials_shouldReturnNullForInvalidQrCode() {
val qrCode = "INVALID_QR_CODE"
val (ssid, password) = extractWifiCredentials(qrCode)
assertNull(ssid)
@ -39,7 +39,7 @@ class BarcodeUtilTest {
}
@Test
fun `extractWifiCredentials should handle missing password`() {
fun extractWifiCredentials_shouldHandleMissingPassword() {
val qrCode = "WIFI:S:MyNetwork;;"
val (ssid, password) = extractWifiCredentials(qrCode)
assertNull(ssid)

View file

@ -0,0 +1,83 @@
/*
* 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.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
/**
* A blocking version of coroutine Continuation using traditional threading primitives.
*
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
*/
class SyncContinuation<T> : Continuation<T> {
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
lock.lock()
try {
result = res
condition.signal()
} finally {
lock.unlock()
}
}
/**
* Blocks the current thread until the result is available or the timeout expires.
*
* @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely.
* @return The result of the operation.
* @throws IllegalStateException if a timeout occurs or if an internal error happens.
*/
@Suppress("NestedBlockDepth")
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
try {
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
check(remaining > 0) { "SyncContinuation timeout" }
condition.await(remaining, TimeUnit.MILLISECONDS)
} else {
condition.await()
}
}
val r = result
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
return r.getOrThrow()
} finally {
lock.unlock()
}
}
}
/**
* Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the
* current thread until the operation completes or times out.
*
* Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
initfn(cont)
return cont.await(timeoutMsecs)
}

View file

@ -0,0 +1,59 @@
/*
* 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 =
when (getQueryParameter(key)?.lowercase()) {
"1",
"true",
"yes",
"on",
-> true
"0",
"false",
"no",
"off",
-> false
else -> defaultValue
}
actual override fun toString(): String = uri.toString()
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
}
fun toUri(): URI = uri
}
actual fun CommonUri.toPlatformUri(): Any = this.toUri()

View file

@ -0,0 +1,126 @@
/*
* 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.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
import java.util.Locale
import kotlin.math.abs
actual object BuildUtils {
actual val isEmulator: Boolean = false
actual val sdkInt: Int = 0
}
actual object DateFormatter {
private val zoneId: ZoneId = ZoneId.systemDefault()
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val mediumTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM)
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
private val shortDateTimeFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM)
actual fun formatRelativeTime(timestampMillis: Long): String {
val deltaMillis = nowMillis - timestampMillis
val absDeltaMillis = abs(deltaMillis)
val suffix = if (deltaMillis >= 0) "ago" else "from now"
return when {
absDeltaMillis < MINUTE_MILLIS -> if (deltaMillis >= 0) "just now" else "in a moment"
absDeltaMillis < HOUR_MILLIS -> "${absDeltaMillis / MINUTE_MILLIS}m $suffix"
absDeltaMillis < DAY_MILLIS -> "${absDeltaMillis / HOUR_MILLIS}h $suffix"
else -> "${absDeltaMillis / DAY_MILLIS}d $suffix"
}
}
actual fun formatDateTime(timestampMillis: Long): String =
shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatShortDate(timestampMillis: Long): String {
val isWithin24Hours = (nowMillis - timestampMillis) <= DAY_MILLIS
val zonedDateTime = java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)
return if (isWithin24Hours) {
shortTimeFormatter.format(zonedDateTime)
} else {
shortDateFormatter.format(zonedDateTime)
}
}
actual fun formatTime(timestampMillis: Long): String =
shortTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatTimeWithSeconds(timestampMillis: Long): String =
mediumTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDate(timestampMillis: Long): String =
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
}
@Suppress("MagicNumber")
actual fun getSystemMeasurementSystem(): MeasurementSystem =
when (Locale.getDefault().country.uppercase(Locale.getDefault())) {
"US",
"LR",
"MM",
"GB",
-> MeasurementSystem.IMPERIAL
else -> MeasurementSystem.METRIC
}
actual fun String?.isValidAddress(): Boolean {
val value = this?.trim()
return when {
value.isNullOrEmpty() -> false
value == LOCALHOST -> true
IPV4_PATTERN.matches(value) -> value.split('.').all { segment -> segment.toIntOrNull() in 0..MAX_IPV4_SEGMENT }
value.contains(':') -> runCatching { InetAddress.getByName(value) }.isSuccess
else -> DOMAIN_PATTERN.matches(value)
}
}
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}${'$'}")
private const val MINUTE_MILLIS = 60_000L
private const val HOUR_MILLIS = 3_600_000L
private const val DAY_MILLIS = 86_400_000L
private const val MAX_IPV4_SEGMENT = 255
private const val LOCALHOST = "localhost"

View file

@ -0,0 +1,55 @@
/*
* 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
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>>
actual class CommonParcel {
actual fun readString(): String? = unsupportedParcelOperation()
actual fun readInt(): Int = unsupportedParcelOperation()
actual fun readLong(): Long = unsupportedParcelOperation()
actual fun readFloat(): Float = unsupportedParcelOperation()
actual fun createByteArray(): ByteArray? = unsupportedParcelOperation()
actual fun writeByteArray(b: ByteArray?) = unsupportedParcelOperation<Unit>()
}
private fun <T> unsupportedParcelOperation(): T =
error("CommonParcel is unavailable on JVM smoke targets. Manual parcel operations remain Android-only.")

View file

@ -16,8 +16,8 @@
*/
package org.meshtastic.core.common.util
import java.net.URLEncoder
import java.util.Date
import kotlin.time.Instant
actual object UrlUtils {
actual fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
/** Converts this [Instant] to a legacy [Date]. */
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())

View file

@ -22,6 +22,8 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.data"
@ -59,6 +61,13 @@ kotlin {
implementation(libs.androidx.sqlite.bundled)
}
jvmMain.dependencies {
// Room / SQLite runtime for JVM target
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.sqlite.bundled)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.data.datasource
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.di.CoroutineDispatchers
@ -26,7 +26,7 @@ import org.meshtastic.core.model.NetworkDeviceHardware
@Single
class DeviceHardwareLocalDataSource(
private val dbManager: DatabaseManager,
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
private val deviceHardwareDao

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.data.datasource
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asDeviceVersion
@ -28,7 +28,7 @@ import org.meshtastic.core.model.NetworkFirmwareRelease
@Single
class FirmwareReleaseLocalDataSource(
private val dbManager: DatabaseManager,
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
private val firmwareReleaseDao

View file

@ -19,13 +19,13 @@ package org.meshtastic.core.data.datasource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
@Single
class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseManager) : NodeInfoReadDataSource {
class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) : NodeInfoReadDataSource {
override fun myNodeInfoFlow(): Flow<MyNodeEntity?> =
dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().getMyNodeInfo() }

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.data.datasource
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
@ -26,7 +26,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
@Single
class SwitchingNodeInfoWriteDataSource(
private val dbManager: DatabaseManager,
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {

View file

@ -30,7 +30,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.repository.FromRadioPacketHandler

View file

@ -68,7 +68,7 @@ class MqttManagerImpl(
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
val topic = message.topic ?: ""
val topic = message.topic
Logger.d { "[mqttClientProxyMessage] $topic" }
val retained = message.retained == true
when {

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
@ -29,7 +30,6 @@ import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
@Single
class NeighborInfoHandlerImpl(
@ -49,7 +49,7 @@ class NeighborInfoHandlerImpl(
val ni = NeighborInfo.ADAPTER.decode(payload)
// Store the last neighbor info from our connected radio
val from = packet.from ?: 0
val from = packet.from
if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
@ -76,7 +76,7 @@ class NeighborInfoHandlerImpl(
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Neighbor info $requestId complete in $seconds s" }
String.format(Locale.US, "%s\n\nDuration: %.1f s", formatted, seconds)
"$formatted\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
formatted
}

View file

@ -319,10 +319,10 @@ class NodeManagerImpl(
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view ?: 0,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,

View file

@ -31,9 +31,9 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
@ -34,7 +35,6 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.MeshPacket
import java.util.Locale
@Single
class TracerouteHandlerImpl(
@ -83,7 +83,7 @@ class TracerouteHandlerImpl(
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = "Duration: %.1f s".format(Locale.US, seconds)
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s"
"$full\n\n$durationText"
} else {
full

View file

@ -28,9 +28,11 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS
@ -48,19 +50,23 @@ import org.meshtastic.proto.Telemetry
@Suppress("TooManyFunctions")
@Single
class MeshLogRepositoryImpl(
private val dbManager: DatabaseManager,
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
private val meshLogPrefs: MeshLogPrefs,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
) : MeshLogRepository {
/** Retrieves all [MeshLog]s in the database, up to [maxItem]. */
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItem) }.flowOn(dispatchers.io)
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getAllLogs(maxItem) }
.map { list -> list.map { it.asExternalModel() } }
.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database in the order they were received. */
override fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }.flowOn(dispatchers.io)
override fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }
.map { list -> list.map { it.asExternalModel() } }
.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database without any limit. */
override fun getAllLogsUnbounded(): Flow<List<MeshLog>> = getAllLogs(Int.MAX_VALUE)
@ -68,6 +74,7 @@ class MeshLogRepositoryImpl(
/** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
override fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, DEFAULT_MAX_LOGS) }
.map { list -> list.map { it.asExternalModel() } }
.distinctUntilChanged()
.flowOn(dispatchers.io)
@ -81,7 +88,7 @@ class MeshLogRepositoryImpl(
dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, DEFAULT_MAX_LOGS) }
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.mapLatest { list -> list.map { it.asExternalModel() }.mapNotNull(::parseTelemetryLog) }
}
.flowOn(dispatchers.io)
@ -93,12 +100,14 @@ class MeshLogRepositoryImpl(
override fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, DEFAULT_MAX_LOGS) }
.map { list ->
list.filter { log ->
val packet = log.fromRadio.packet ?: return@filter false
log.fromNum == MeshLog.NODE_NUM_LOCAL &&
packet.to == targetNodeNum &&
packet.decoded?.want_response == true
}
list
.map { it.asExternalModel() }
.filter { log ->
val packet = log.fromRadio.packet ?: return@filter false
log.fromNum == MeshLog.NODE_NUM_LOCAL &&
packet.to == targetNodeNum &&
packet.decoded?.want_response == true
}
}
.distinctUntilChanged()
.conflate()
@ -141,13 +150,13 @@ class MeshLogRepositoryImpl(
/** Returns the cached [MyNodeInfo] from the system logs. */
override fun getMyNodeInfo(): Flow<MyNodeInfo?> = dbManager.currentDb
.flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, DEFAULT_MAX_LOGS) }
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.mapLatest { list -> list.map { it.asExternalModel() }.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)
/** Persists a new log entry to the database if logging is enabled in preferences. */
override suspend fun insert(log: MeshLog) = withContext(dispatchers.io) {
if (!meshLogPrefs.loggingEnabled.value) return@withContext
dbManager.currentDb.value.meshLogDao().insert(log)
dbManager.currentDb.value.meshLogDao().insert(log.asEntity())
}
/** Clears all logs from the database. */

View file

@ -38,13 +38,13 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption

View file

@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.toReaction
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactSettings
@ -45,7 +45,7 @@ import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository
@Suppress("TooManyFunctions", "LongParameterList")
@Single
class PacketRepositoryImpl(private val dbManager: DatabaseManager, private val dispatchers: CoroutineDispatchers) :
class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dispatchers: CoroutineDispatchers) :
SharedPacketRepository {
override fun getWaypoints(): Flow<List<DataPacket>> = dbManager.currentDb

View file

@ -20,12 +20,15 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class QuickChatActionRepository(private val dbManager: DatabaseManager, private val dispatchers: CoroutineDispatchers) {
class QuickChatActionRepository(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
fun getAllActions() = dbManager.currentDb.flatMapLatest { it.quickChatActionDao().getAll() }.flowOn(dispatchers.io)
suspend fun upsert(action: QuickChatAction) =

View file

@ -24,14 +24,14 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.Position
@Single
class TracerouteSnapshotRepository(
private val dbManager: DatabaseManager,
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {

View file

@ -91,7 +91,7 @@ class MeshConnectionManagerImplTest {
@Before
fun setUp() {
mockkStatic("org.meshtastic.core.resources.ContextExtKt")
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { getString(any()) } returns "Mocked String"
every { getString(any(), *anyVararg()) } returns "Mocked String"
@ -128,7 +128,7 @@ class MeshConnectionManagerImplTest {
@After
fun tearDown() {
unmockkStatic("org.meshtastic.core.resources.ContextExtKt")
unmockkStatic("org.meshtastic.core.resources.GetStringKt")
}
@Test

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -80,12 +79,6 @@ class MeshDataHandlerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any(), any<String>()) } returns 0
every { android.util.Log.e(any(), any()) } returns 0
meshDataHandler =
MeshDataHandlerImpl(
nodeManager,

Some files were not shown because too many files have changed in this diff Show more