mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
f4364cff9a
commit
ac6bb5479b
386 changed files with 17089 additions and 4590 deletions
66
.github/copilot-instructions.md
vendored
66
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/dependency-submission.yml
vendored
2
.github/workflows/dependency-submission.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/workflows/publish-core.yml
vendored
4
.github/workflows/publish-core.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
50
.github/workflows/release.yml
vendored
50
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
9
.github/workflows/reusable-check.yml
vendored
9
.github/workflows/reusable-check.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -48,3 +48,4 @@ wireless-install.sh
|
|||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
/firebase-debug.log
|
||||
|
|
|
|||
43
AGENTS.md
43
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
25
GEMINI.md
25
GEMINI.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":[]}"""
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ plugins {
|
|||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.ble"
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;;
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue