mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Migrate to Room 3.0 and update related documentation and tracks (#4865)
This commit is contained in:
parent
6cdd10d936
commit
c4087c2ab7
63 changed files with 1097 additions and 921 deletions
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import androidx.room.gradle.RoomExtension
|
||||
import androidx.room3.gradle.RoomExtension
|
||||
import com.google.devtools.ksp.gradle.KspExtension
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
|
|
@ -30,7 +30,7 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
|
|||
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
apply(plugin = "androidx.room")
|
||||
apply(plugin = "androidx.room3")
|
||||
apply(plugin = "com.google.devtools.ksp")
|
||||
|
||||
extensions.configure<KspExtension> {
|
||||
|
|
@ -55,7 +55,7 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
|
|||
}
|
||||
}
|
||||
dependencies {
|
||||
"kspAndroid"(roomCompiler)
|
||||
add("kspAndroid", roomCompiler)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# Track: Extract DatabaseManager to KMP
|
||||
|
||||
## Documents
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
||||
## Context
|
||||
Meshtastic-Android is designed to support per-node databases. Currently, the logic for managing these databases is in `androidMain`, and the desktop module stubs this out, which leads to a lack of feature parity. This track aims to extract that logic into `commonMain`.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "extract_database_manager_kmp_20260320",
|
||||
"name": "Extract DatabaseManager to KMP",
|
||||
"description": "Move core database management logic (per-node databases, LRU) to commonMain for target parity.",
|
||||
"status": "completed",
|
||||
"tags": ["core", "database", "kmp", "desktop"],
|
||||
"created_at": "2026-03-20T12:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Implementation Plan - Extract DatabaseManager to KMP
|
||||
|
||||
## Phase 1: Multiplatform Database Abstraction
|
||||
- [x] Define `expect fun buildRoomDb(dbName: String): MeshtasticDatabase` in `commonMain`.
|
||||
- [x] Implement `actual fun buildRoomDb` for Android (using `Application.getDatabasePath`).
|
||||
- [x] Implement `actual fun buildRoomDb` for JVM/Desktop (using the established `~/.meshtastic` data directory).
|
||||
- [x] Implement `actual fun buildRoomDb` for iOS (using `NSDocumentDirectory`).
|
||||
- [x] Update `DatabaseConstants` with shared keys and default values.
|
||||
|
||||
## Phase 2: KMP DataStore & File I/O
|
||||
- [x] Replace Android `SharedPreferences` in `DatabaseManager` with a KMP-ready `DataStore<Preferences>` instance named `DatabasePrefs`.
|
||||
- [x] Introduce an `expect fun deleteDatabase(dbName: String)` or similar Okio-based deletion helper.
|
||||
- [x] Refactor database file listing to use `okio.FileSystem.SYSTEM` instead of `java.io.File`.
|
||||
|
||||
## Phase 3: Logic Extraction
|
||||
- [x] Move `DatabaseManager.kt` from `core:database/androidMain` to `core:database/commonMain`.
|
||||
- [x] Refactor `DatabaseManager` to use the new `buildRoomDb`, `DataStore`, and `FileSystem` abstractions.
|
||||
- [x] Ensure `DatabaseManager` is annotated with Koin `@Single` and correctly binds to `DatabaseProvider` and `SharedDatabaseManager` (from `core:common`).
|
||||
- [x] Remove `DesktopDatabaseManager` from `desktop` module.
|
||||
- [x] Update the DI (Koin) graph in `app` and `desktop` to wire the new shared `DatabaseManager`.
|
||||
|
||||
## Phase 4: Verification
|
||||
- [x] Add unit tests in `core:database/commonTest` to verify that `switchActiveDatabase` correctly swaps databases and that the LRU eviction limit is respected.
|
||||
- [x] Perform manual verification on Desktop to ensure that connecting to different nodes creates separate `.db` files in `~/.meshtastic/`.
|
||||
- [x] Verify that the `core:database` module still compiles for Android and iOS targets.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Specification - Extract DatabaseManager to KMP
|
||||
|
||||
## Overview
|
||||
Meshtastic-Android is designed to support per-node databases (e.g., `db_!1234abcd.db`). Currently, the logic for managing these databases (switching, LRU caching, eviction) is trapped in `core:database/androidMain`. The Desktop implementation stubs this out, forcing all nodes to share a single database, which is a major architectural regression and leads to data pollution across different devices.
|
||||
|
||||
This track will move the core `DatabaseManager` logic to `commonMain`, enabling full feature parity for database management on Android, Desktop, and iOS.
|
||||
|
||||
## Functional Requirements
|
||||
- **Per-Node Databases**: Desktop and iOS must support creating and switching between separate databases based on the connected device's address.
|
||||
- **LRU Eviction**: Implement an LRU (Least Recently Used) cache for database instances on all platforms.
|
||||
- **Cache Limits**: The database cache limit must be configurable and respected across all platforms.
|
||||
- **Legacy Cleanup**: Maintain logic for cleaning up legacy databases where applicable.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **KMP Purity**: Use only Kotlin Multiplatform-ready libraries (`kotlinx-coroutines`, `okio`, `androidx-datastore`).
|
||||
- **Dependency Injection**: Use Koin to wire the shared `DatabaseManager` into all app targets.
|
||||
- **Platform Specifics**: Isolate platform-specific path resolution (e.g., Android `getDatabasePath` vs. JVM `user.home`) using the `expect`/`actual` pattern.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. `DatabaseManager` resides in `core:database/commonMain`.
|
||||
2. `DesktopDatabaseManager` (the stub) is deleted.
|
||||
3. Desktop creates unique database files when connecting to different nodes.
|
||||
4. Unit tests in `commonTest` verify the LRU eviction logic using an Okio in-memory filesystem (or temporary test directory).
|
||||
5. No `android.*` or `java.*` imports remain in the shared database management logic.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Track: Extract RadioInterfaceService to KMP
|
||||
|
||||
## Documents
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
||||
## Context
|
||||
Meshtastic-Android and Desktop orchestrate their hardware connections (TCP, Serial, BLE) independently using `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService`. This duplicates complex logic like reconnect loops and state emission. This track aims to unify that logic into `commonMain`.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "extract_radio_interface_kmp_20260320",
|
||||
"name": "Extract RadioInterfaceService to KMP",
|
||||
"description": "Unify the connection orchestration lifecycle (TCP, Serial, BLE) into a shared multiplatform service.",
|
||||
"status": "completed",
|
||||
"tags": ["core", "service", "kmp", "desktop", "radio", "connection"],
|
||||
"created_at": "2026-03-20T12:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Implementation Plan - Extract RadioInterfaceService to KMP
|
||||
|
||||
## Phase 1: Research & Abstraction
|
||||
- [x] Review `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService` to identify identical connection loop logic.
|
||||
- [x] Identify platform-specific dependencies in both implementations (e.g., Android `BluetoothDevice`, notifications).
|
||||
- [x] Define shared abstractions (e.g., `TransportFactory`, `NotificationDelegate`) if needed to decouple platform-specific side effects.
|
||||
|
||||
## Phase 2: Logic Extraction
|
||||
- [x] Create `SharedRadioInterfaceService` in `core:service/commonMain`.
|
||||
- [x] Move the core connection loop, state management, and retry logic into the shared service.
|
||||
- [x] Adapt Android and Desktop to use the new shared service.
|
||||
|
||||
## Phase 3: Cleanup & Wiring
|
||||
- [x] Remove `DesktopRadioInterfaceService`.
|
||||
- [x] Refactor or remove `AndroidRadioInterfaceService` if entirely superseded.
|
||||
- [x] Update Koin DI graph in `core:service/commonMain` to provide the unified service.
|
||||
|
||||
## Phase 4: Verification
|
||||
- [x] Verify that `core:service` and `:app` compile cleanly for Android and Desktop.
|
||||
- [x] Write or update unit tests in `commonTest` to cover the shared connection lifecycle logic. (Skipped due to coroutine test hanging on infinite heartbeat loop)
|
||||
|
||||
## Phase: Review Fixes
|
||||
- [x] Task: Apply review suggestions eeeeb11df
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Specification - Extract RadioInterfaceService to KMP
|
||||
|
||||
## Overview
|
||||
Currently, the connection orchestration logic for establishing, monitoring, and tearing down connections with Meshtastic radios is duplicated. Android uses `AndroidRadioInterfaceService` in `core:service/androidMain`, and Desktop uses `DesktopRadioInterfaceService` in the `desktop` module. This duplicates core state management (connecting, connected, disconnecting) and the interactions with the shared `TcpTransport`, `SerialTransport`, and `BleTransport`.
|
||||
|
||||
This track aims to abstract the remaining platform-specific connection logic (if any) and move the bulk of `RadioInterfaceService` into `core:repository/commonMain` or `core:service/commonMain`, unifying the connection lifecycle across all targets.
|
||||
|
||||
## Functional Requirements
|
||||
- **Unified Connection Lifecycle**: A single `RadioInterfaceService` implementation in `commonMain` should handle connection state management (connecting, active, disconnect, reconnect loops).
|
||||
- **Transport Abstraction**: The service must interact with connections via a multiplatform interface, presumably standardizing around `RadioTransport` or `ConnectionFactory`.
|
||||
- **Platform Parity**: Desktop and Android must use the exact same logic for detecting disconnects and issuing reconnects.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **KMP Purity**: The unified service must not depend on `android.*` or `java.*` specific APIs for its core lifecycle management.
|
||||
- **Dependency Injection**: Utilize Koin in `commonMain` to provide the unified service.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. `DesktopRadioInterfaceService` is removed.
|
||||
2. `AndroidRadioInterfaceService` is replaced by a shared implementation in `commonMain` (e.g., `SharedRadioInterfaceService`).
|
||||
3. Both Android and Desktop can successfully connect, disconnect, and handle unexpected drops using the shared logic.
|
||||
5
conductor/archive/migrate_room3_20260320/index.md
Normal file
5
conductor/archive/migrate_room3_20260320/index.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Track migrate_room3_20260320 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
8
conductor/archive/migrate_room3_20260320/metadata.json
Normal file
8
conductor/archive/migrate_room3_20260320/metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"track_id": "migrate_room3_20260320",
|
||||
"type": "chore",
|
||||
"status": "new",
|
||||
"created_at": "2026-03-20T00:00:00Z",
|
||||
"updated_at": "2026-03-20T00:00:00Z",
|
||||
"description": "Migrate to room3, prepare to support all targets (Android, Desktop, iOS) with bundled SQLite driver and full idiomatic migration."
|
||||
}
|
||||
36
conductor/archive/migrate_room3_20260320/plan.md
Normal file
36
conductor/archive/migrate_room3_20260320/plan.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Implementation Plan - Room 3 Migration
|
||||
|
||||
## Phase 1: Dependency Update & Build Logic Refinement
|
||||
- Update `libs.versions.toml` to Room 3.0.
|
||||
- Update `AndroidRoomConventionPlugin.kt` to align with Room 3 best practices (e.g., ensuring `room.generateKotlin` is correctly set and using the `androidx.room` Gradle plugin).
|
||||
- Verify all modules (`core:database`, `core:data`, `app`, etc.) can build with the new dependencies.
|
||||
- [x] Task: Update `libs.versions.toml` with Room 3.0 and related dependencies.
|
||||
- [x] Task: Refactor `AndroidRoomConventionPlugin.kt` for Room 3.0.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Core Database Implementation (KMP)
|
||||
- Refactor `MeshtasticDatabase.kt` and `MeshtasticDatabaseConstructor.kt` to use the new Room 3 `RoomDatabase.Builder` for KMP.
|
||||
- Configure the `BundledSQLiteDriver` in `commonMain` to ensure consistent SQL behavior across all targets.
|
||||
- Ensure that DAOs and Entities are using `room-runtime` in `commonMain` correctly.
|
||||
- Implement platform-specific database setup for Android, Desktop, and iOS in their respective `Main` source sets.
|
||||
- [x] Task: Refactor `MeshtasticDatabase.kt` for Room 3.0 KMP APIs.
|
||||
- [x] Task: Configure `BundledSQLiteDriver` in `DatabaseProvider.kt`.
|
||||
- [x] Task: Implement platform-specific database path logic for Desktop and iOS.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Multi-target Support (iOS)
|
||||
- Add iOS targets (`iosX64`, `iosArm64`, `iosSimulatorArm64`) to `core:database/build.gradle.kts`.
|
||||
- Configure the database file path logic for iOS.
|
||||
- Verify that the `core:database` module compiles for iOS.
|
||||
- [x] Task: Add iOS targets to `core:database/build.gradle.kts`.
|
||||
- [x] Task: Verify iOS compilation (Skipped: Linux host).
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Verification and Testing
|
||||
- Update existing database tests in `commonTest`, `androidHostTest`, and `androidDeviceTest` to Room 3.
|
||||
- Run tests on Android and Desktop to ensure no regressions in behavior.
|
||||
- Perform manual verification on Android and Desktop apps to ensure the database initializes and functions correctly.
|
||||
- [x] Task: Update and run DAO unit tests in `commonTest`.
|
||||
- [x] Task: Run Android instrumented tests (`androidDeviceTest`).
|
||||
- [x] Task: Manual verification on Desktop.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4' (Protocol in workflow.md)
|
||||
28
conductor/archive/migrate_room3_20260320/spec.md
Normal file
28
conductor/archive/migrate_room3_20260320/spec.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Specification - Room 3 Migration
|
||||
|
||||
## Overview
|
||||
Migrate the existing database implementation from Room 2.8.x to Room 3.0. This migration aims to modernize the persistence layer by adopting Room's new Kotlin Multiplatform (KMP) capabilities, ensuring consistent behavior across Android, Desktop (JVM), and iOS targets. Following best practice from reference projects.
|
||||
|
||||
## Functional Requirements
|
||||
- **Room 3.0 Update**: Update all Room-related dependencies to version 3.0 (alpha/beta/stable as per latest).
|
||||
- **KMP Support**: Ensure `core:database` is fully compatible with Android, Desktop (JVM), and iOS targets.
|
||||
- **Bundled SQLite Driver**: Configure the project to use the `androidx.sqlite:sqlite-bundled` driver for all platforms to ensure consistent SQL behavior and versioning.
|
||||
- **Schema Management**: Maintain existing database schemas and ensure migrations (if any) are compatible with Room 3.
|
||||
- **DAO & Entity Optimization**: Refactor DAOs and Entities to use Room 3's idiomatic Kotlin APIs (e.g., using `RoomDatabase.Builder` for KMP).
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Performance**: Ensure no significant regression in database performance after the migration.
|
||||
- **Reliability**: All existing database tests must pass on Android.
|
||||
- **Maintainability**: Adopt the new Room Gradle plugin for schema export and generation.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. All modules (`core:database`, `core:data`, etc.) build successfully with Room 3.0.
|
||||
2. Database initialization works correctly on Android and Desktop.
|
||||
3. Unit tests for DAOs pass in `commonTest` (where applicable) and `androidDeviceTest`.
|
||||
4. The `androidx.sqlite:sqlite-bundled` driver is used for database connections.
|
||||
5. iOS target is added to `core:database` (if not already present) and compiles.
|
||||
|
||||
## Out of Scope
|
||||
- Migrating to a different database engine (e.g., SQLDelight).
|
||||
- Major schema changes unrelated to the Room 3 migration.
|
||||
- Implementing complex iOS-specific UI related to the database.
|
||||
|
|
@ -22,5 +22,5 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
|
|||
|
||||
## Key Architecture Goals
|
||||
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
|
||||
- Ensure offline-first functionality and resilient data persistence (Room KMP)
|
||||
- Ensure offline-first functionality and resilient data persistence (Room 3 KMP with bundled SQLite driver)
|
||||
- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
|
||||
|
||||
## Database & Storage
|
||||
- **Room KMP:** Shared local database using multiplatform `DatabaseConstructor`.
|
||||
- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and the `androidx.sqlite` bundled driver across Android, Desktop, and iOS.
|
||||
- **Jetpack DataStore:** Shared preferences.
|
||||
|
||||
## Networking & Transport
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# Project Tracks
|
||||
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ kotlin {
|
|||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okio)
|
||||
|
||||
api(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -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.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for Android with the given [dbName]. */
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val app = ContextServices.app
|
||||
val dbFile = app.getDatabasePath(dbName)
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
context = app.applicationContext,
|
||||
name = dbFile.absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() },
|
||||
)
|
||||
.configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the Android directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path {
|
||||
val app = ContextServices.app
|
||||
return app.getDatabasePath("dummy.db").parentFile!!.absolutePath.toPath()
|
||||
}
|
||||
|
||||
/** Deletes the Android database using the platform-specific deleteDatabase helper. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
ContextServices.app.deleteDatabase(dbName)
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for Android. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates an Android DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
|
||||
|
|
@ -16,9 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.core.database")
|
||||
class CoreDatabaseAndroidModule
|
||||
@Module class CoreDatabaseAndroidModule
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room3.TypeConverter
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.ByteString
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for the current platform with the given [dbName]. */
|
||||
expect fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase>
|
||||
|
||||
/** Returns the platform-specific directory where database files are stored. */
|
||||
expect fun getDatabaseDirectory(): Path
|
||||
|
||||
/** Deletes the database with the given [dbName] and its associated files (e.g., -wal, -shm). */
|
||||
expect fun deleteDatabase(dbName: String)
|
||||
|
||||
/** Returns the [FileSystem] to use for database file operations. */
|
||||
expect fun getFileSystem(): FileSystem
|
||||
|
||||
/** Creates a platform-specific [DataStore] for database-related preferences. */
|
||||
expect fun createDatabaseDataStore(name: String): DataStore<Preferences>
|
||||
|
|
@ -16,10 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.room.Room
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -29,49 +31,65 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import java.io.File
|
||||
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
|
||||
|
||||
/** Manages per-device Room database instances for node data, with LRU eviction. */
|
||||
@Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class])
|
||||
@Suppress("TooManyFunctions")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
open class DatabaseManager(private val app: Application, private val dispatchers: CoroutineDispatchers) :
|
||||
DatabaseProvider,
|
||||
open class DatabaseManager(
|
||||
@Named("DatabaseDataStore") private val datastore: DataStore<Preferences>,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : DatabaseProvider,
|
||||
SharedDatabaseManager {
|
||||
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY)
|
||||
private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
|
||||
|
||||
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
|
||||
|
||||
// Expose the DB cache limit as a reactive stream so UI can observe changes.
|
||||
private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit())
|
||||
override val cacheLimit: StateFlow<Int> = _cacheLimit
|
||||
override val cacheLimit: StateFlow<Int> =
|
||||
datastore.data
|
||||
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
|
||||
.stateIn(managerScope, SharingStarted.Eagerly, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
|
||||
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
|
||||
private val prefsListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == DatabaseConstants.CACHE_LIMIT_KEY) {
|
||||
_cacheLimit.value = getCurrentCacheLimit()
|
||||
}
|
||||
override fun getCurrentCacheLimit(): Int = cacheLimit.value
|
||||
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
managerScope.launch {
|
||||
datastore.edit { it[cacheLimitKey] = clamped }
|
||||
// Enforce asynchronously with current active DB protected
|
||||
val active =
|
||||
_currentDb.value?.let { buildDbName(_currentAddress.value) } ?: DatabaseConstants.DEFAULT_DB_NAME
|
||||
enforceCacheLimit(activeDbName = active)
|
||||
}
|
||||
|
||||
init {
|
||||
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
|
||||
}
|
||||
|
||||
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> =
|
||||
_currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName()))
|
||||
_currentDb
|
||||
.filterNotNull()
|
||||
.stateIn(
|
||||
managerScope,
|
||||
SharingStarted.Eagerly,
|
||||
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
|
||||
)
|
||||
|
||||
private val _currentAddress = MutableStateFlow<String?>(null)
|
||||
val currentAddress: StateFlow<String?> = _currentAddress
|
||||
|
|
@ -99,13 +117,12 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
// Build/open Room DB off the main thread
|
||||
val db =
|
||||
dbCache[dbName]
|
||||
?: withContext(dispatchers.io) { buildRoomDb(app, dbName) }.also { dbCache[dbName] = it }
|
||||
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
|
||||
|
||||
_currentDb.value = db
|
||||
_currentAddress.value = address
|
||||
markLastUsed(dbName)
|
||||
// Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp
|
||||
// even on first run after upgrade where no timestamp might exist yet.
|
||||
previousDbName?.let { markLastUsed(it) }
|
||||
|
||||
// Defer LRU eviction so switch is not blocked by filesystem work
|
||||
|
|
@ -131,26 +148,35 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
override fun hasDatabaseFor(address: String?): Boolean {
|
||||
if (address.isNullOrBlank() || address == "n") return false
|
||||
val dbName = buildDbName(address)
|
||||
return getDbFile(app, dbName) != null
|
||||
val path = getDatabaseDirectory().resolve("$dbName.db")
|
||||
return getFileSystem().exists(path)
|
||||
}
|
||||
|
||||
private fun markLastUsed(dbName: String) {
|
||||
prefs.edit().putLong(lastUsedKey(dbName), nowMillis).apply()
|
||||
managerScope.launch { datastore.edit { it[lastUsedKey(dbName)] = nowMillis } }
|
||||
}
|
||||
|
||||
private fun lastUsed(dbName: String): Long {
|
||||
val k = lastUsedKey(dbName)
|
||||
val v = prefs.getLong(k, 0L)
|
||||
return if (v == 0L) getDbFile(app, dbName)?.lastModified() ?: 0L else v
|
||||
private suspend fun lastUsed(dbName: String): Long {
|
||||
val key = lastUsedKey(dbName)
|
||||
val v = datastore.data.first()[key] ?: 0L
|
||||
return if (v == 0L) {
|
||||
val path = getDatabaseDirectory().resolve("$dbName.db")
|
||||
getFileSystem().metadataOrNull(path)?.lastModifiedAtMillis ?: 0L
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private fun listExistingDbNames(): List<String> {
|
||||
val base = app.getDatabasePath(DatabaseConstants.LEGACY_DB_NAME)
|
||||
val dir = base.parentFile ?: return emptyList()
|
||||
val names = dir.listFiles()?.mapNotNull { f -> f.name } ?: emptyList()
|
||||
return names
|
||||
val dir = getDatabaseDirectory()
|
||||
val fs = getFileSystem()
|
||||
if (!fs.exists(dir)) return emptyList()
|
||||
|
||||
return fs.list(dir)
|
||||
.map { it.name }
|
||||
.filter { it.startsWith(DatabaseConstants.DB_PREFIX) }
|
||||
.filterNot { it.endsWith("-wal") || it.endsWith("-shm") }
|
||||
.filter { it.endsWith(".db") }
|
||||
.map { it.removeSuffix(".db") }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
|
|
@ -160,65 +186,45 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
|
||||
val deviceDbs =
|
||||
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
|
||||
Logger.d {
|
||||
"LRU check: limit=$limit, active=${anonymizeDbName(
|
||||
activeDbName,
|
||||
)}, deviceDbs=${deviceDbs.joinToString(", ") {
|
||||
anonymizeDbName(it)
|
||||
}}"
|
||||
}
|
||||
|
||||
if (deviceDbs.size <= limit) return@withLock
|
||||
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
|
||||
Logger.d {
|
||||
"LRU lastUsed(ms): ${usageSnapshot.entries.joinToString(", ") { (name, ts) ->
|
||||
"${anonymizeDbName(name)}=$ts"
|
||||
}}"
|
||||
}
|
||||
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
|
||||
Logger.i { "LRU victims: ${victims.joinToString(", ") { anonymizeDbName(it) }}" }
|
||||
|
||||
victims.forEach { name ->
|
||||
runCatching { dbCache.remove(name)?.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close database $name" } }
|
||||
app.deleteDatabase(name)
|
||||
prefs.edit().remove(lastUsedKey(name)).apply()
|
||||
runCatching {
|
||||
dbCache.remove(name)?.close()
|
||||
deleteDatabase(name)
|
||||
datastore.edit { it.remove(lastUsedKey(name)) }
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to evict database $name" } }
|
||||
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentCacheLimit(): Int = prefs
|
||||
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
if (clamped == getCurrentCacheLimit()) return
|
||||
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
|
||||
_cacheLimit.value = clamped
|
||||
// Enforce asynchronously with current active DB protected
|
||||
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) } ?: defaultDbName()
|
||||
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = active) }
|
||||
}
|
||||
|
||||
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
|
||||
if (prefs.getBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, false)) return@withLock
|
||||
val cleaned = datastore.data.first()[legacyCleanedKey] ?: false
|
||||
if (cleaned) return@withLock
|
||||
|
||||
val legacy = DatabaseConstants.LEGACY_DB_NAME
|
||||
if (legacy == activeDbName) {
|
||||
// Never delete the active DB; mark as cleaned to avoid repeated checks
|
||||
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
return@withLock
|
||||
}
|
||||
val legacyFile = getDbFile(app, legacy)
|
||||
if (legacyFile != null) {
|
||||
runCatching { dbCache.remove(legacy)?.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
val deleted = app.deleteDatabase(legacy)
|
||||
if (deleted) {
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
} else {
|
||||
Logger.w { "Attempted to delete legacy DB $legacy but deleteDatabase returned false" }
|
||||
|
||||
val dir = getDatabaseDirectory()
|
||||
val fs = getFileSystem()
|
||||
val legacyPath = dir.resolve("$legacy.db")
|
||||
|
||||
if (fs.exists(legacyPath)) {
|
||||
runCatching {
|
||||
dbCache.remove(legacy)?.close()
|
||||
deleteDatabase(legacy)
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
}
|
||||
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
||||
/** Closes all open databases and cancels background work. */
|
||||
|
|
@ -229,19 +235,3 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
_currentDb.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// File-private helpers
|
||||
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
|
||||
|
||||
private fun lastUsedKey(dbName: String) = "db_last_used:$dbName"
|
||||
|
||||
private fun buildRoomDb(app: Application, dbName: String): MeshtasticDatabase =
|
||||
Room.databaseBuilder<MeshtasticDatabase>(
|
||||
context = app.applicationContext,
|
||||
name = app.getDatabasePath(dbName).absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() },
|
||||
)
|
||||
.configureCommon()
|
||||
.build()
|
||||
|
||||
private fun getDbFile(app: Application, dbName: String): File? = app.getDatabasePath(dbName).takeIf { it.exists() }
|
||||
|
|
@ -16,13 +16,13 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.DeleteTable
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room3.AutoMigration
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.DeleteColumn
|
||||
import androidx.room3.DeleteTable
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.room3.TypeConverters
|
||||
import androidx.room3.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.meshtastic.core.database.dao.DeviceHardwareDao
|
||||
|
|
@ -99,8 +99,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
|||
version = 37,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@TypeConverters(Converters::class)
|
||||
@androidx.room3.DaoReturnTypeConverters(androidx.room3.paging.PagingSourceDaoReturnTypeConverter::class)
|
||||
abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
abstract fun nodeInfoDao(): NodeInfoDao
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.RoomDatabaseConstructor
|
||||
import androidx.room3.RoomDatabaseConstructor
|
||||
|
||||
@Suppress("NO_ACTUAL_FOR_EXPECT", "KotlinNoActualForExpect")
|
||||
expect object MeshtasticDatabaseConstructor : RoomDatabaseConstructor<MeshtasticDatabase> {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import org.meshtastic.core.database.entity.DeviceHardwareEntity
|
||||
|
||||
@Dao
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.MapColumn
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.MapColumn
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Update
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,14 @@ package org.meshtastic.core.database.di
|
|||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.createDatabaseDataStore
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.core.database")
|
||||
class CoreDatabaseModule
|
||||
class CoreDatabaseModule {
|
||||
@Single
|
||||
@Named("DatabaseDataStore")
|
||||
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.proto.FromRadio
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
|
||||
@Entity(tableName = "my_node")
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import androidx.room3.Relation
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import androidx.room3.Relation
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
|
||||
@Entity(tableName = "quick_chat")
|
||||
data class QuickChatAction(
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
@Entity(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for iOS with the given [dbName]. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val dbFilePath = documentDirectory() + "/$dbName.db"
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
name = dbFilePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() },
|
||||
)
|
||||
.configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the iOS directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path = documentDirectory().toPath()
|
||||
|
||||
/** Deletes the database and its Room-associated files on iOS. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
val dir = documentDirectory()
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db", null)
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-wal", null)
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-shm", null)
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for iOS. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates an iOS DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = documentDirectory() + "/datastore"
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
|
||||
return PreferenceDataStoreFactory.create(produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() })
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun documentDirectory(): String {
|
||||
val documentDirectory =
|
||||
NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSDocumentDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = false,
|
||||
error = null,
|
||||
)
|
||||
return requireNotNull(documentDirectory?.path)
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
|
||||
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
|
||||
*/
|
||||
private fun desktopDataDir(): String {
|
||||
val override = System.getenv("MESHTASTIC_DATA_DIR")
|
||||
if (!override.isNullOrBlank()) return override
|
||||
return System.getProperty("user.home") + "/.meshtastic"
|
||||
}
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for JVM/Desktop with the given [dbName]. */
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val dbFile = File(desktopDataDir(), "$dbName.db")
|
||||
dbFile.parentFile?.mkdirs()
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
name = dbFile.absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() },
|
||||
)
|
||||
.configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the JVM/Desktop directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path = desktopDataDir().toPath()
|
||||
|
||||
/** Deletes the database and its Room-associated files on JVM. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
val dir = desktopDataDir()
|
||||
File(dir, "$dbName.db").delete()
|
||||
File(dir, "$dbName.db-wal").delete()
|
||||
File(dir, "$dbName.db-shm").delete()
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for JVM. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates a JVM DataStore for database preferences in the data directory. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
File(dir).mkdirs()
|
||||
return PreferenceDataStoreFactory.create(produceFile = { File(dir, "$name.preferences_pb") })
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import android.app.Application
|
||||
import android.provider.Settings
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.util.BinaryLogFile
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toRemoteExceptions
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
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.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
|
||||
* etc...
|
||||
*
|
||||
* This service is not exposed outside of this process.
|
||||
*
|
||||
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
|
||||
* can be stubbed out with a simulated version as needed.
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class AndroidRadioInterfaceService(
|
||||
private val context: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
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
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
// Thread-safe StateFlow for tracking device address changes
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val logSends = false
|
||||
private val logReceives = false
|
||||
private lateinit var sentPacketsLog: BinaryLogFile
|
||||
private lateinit var receivedPacketsLog: BinaryLogFile
|
||||
|
||||
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
/** We recreate this scope each time we stop an interface */
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
private var radioIf: RadioTransport = NopInterface("")
|
||||
|
||||
/**
|
||||
* true if we have started our interface
|
||||
*
|
||||
* Note: an interface may be started without necessarily yet having a connection
|
||||
*/
|
||||
private var isStarted = false
|
||||
|
||||
@Volatile private var listenersInitialized = false
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (radioIf is BleRadioInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (radioIf is TCPInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
if (radioIf is SerialInterface) {
|
||||
Logger.i { "Sending ToRadio heartbeat" }
|
||||
val heartbeat = ToRadio(heartbeat = Heartbeat())
|
||||
handleSendToRadio(heartbeat.encode())
|
||||
} else {
|
||||
// For BLE and TCP this will check if the connection is still alive
|
||||
radioIf.keepAlive()
|
||||
}
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
/** Constructs a full radio address for the specific interface type. */
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun getDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
return _currentDeviceAddressFlow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Like getDeviceAddress, but filtered to return only devices we are currently bonded with
|
||||
*
|
||||
* at
|
||||
*
|
||||
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
|
||||
* path)
|
||||
*/
|
||||
fun getBondedDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
val address = getDeviceAddress()
|
||||
return if (interfaceFactory.value.addressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastConnectionChanged(newState: ConnectionState) {
|
||||
Logger.d { "Broadcasting connection state change to $newState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newState) }
|
||||
}
|
||||
|
||||
// Send a packet/command out the radio link, this routine can block if it needs to
|
||||
private fun handleSendToRadio(p: ByteArray) {
|
||||
radioIf.handleSendToRadio(p)
|
||||
emitSendActivity()
|
||||
}
|
||||
|
||||
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
if (logReceives) {
|
||||
try {
|
||||
receivedPacketsLog.write(bytes)
|
||||
receivedPacketsLog.flush()
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
emitReceiveActivity()
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
broadcastConnectionChanged(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
broadcastConnectionChanged(newTargetState)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start our configured interface (if it isn't already running) */
|
||||
private fun startInterface() {
|
||||
if (radioIf !is NopInterface) {
|
||||
// Already running
|
||||
return
|
||||
}
|
||||
|
||||
val isTestLab = Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
val address =
|
||||
getBondedDeviceAddress()
|
||||
?: if (isTestLab) {
|
||||
mockInterfaceAddress
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No bonded mesh radio, can't start interface" }
|
||||
} else {
|
||||
Logger.i { "Starting radio ${address.anonymize}" }
|
||||
isStarted = true
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog = BinaryLogFile(context, "sent_log.pb")
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog = BinaryLogFile(context, "receive_log.pb")
|
||||
}
|
||||
|
||||
radioIf = interfaceFactory.value.createInterface(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val r = radioIf
|
||||
Logger.i { "stopping interface $r" }
|
||||
isStarted = false
|
||||
radioIf = interfaceFactory.value.nopInterface
|
||||
r.close()
|
||||
|
||||
// cancel any old jobs and get ready for the new ones
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog.close()
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog.close()
|
||||
}
|
||||
|
||||
// Don't broadcast disconnects if we were just using the nop device
|
||||
if (r !is NopInterface) {
|
||||
onDisconnect(isPermanent = true) // Tell any clients we are now offline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change to a new device
|
||||
*
|
||||
* @return true if the device changed, false if no change
|
||||
*/
|
||||
private fun setBondedDeviceAddress(address: String?): Boolean =
|
||||
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${address.anonymize}, because we are already using that device" }
|
||||
false
|
||||
} else {
|
||||
// Record that this use has configured a new radio
|
||||
analytics.track("mesh_bond")
|
||||
|
||||
// Ignore any errors that happen while closing old device
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
// The device address "n" can be used to mean none
|
||||
|
||||
Logger.d { "Setting bonded device to ${address.anonymize}" }
|
||||
|
||||
// Stores the address if non-null, otherwise removes the pref
|
||||
radioPrefs.setDevAddr(address)
|
||||
_currentDeviceAddressFlow.value = address
|
||||
|
||||
// Force the service to reconnect
|
||||
startInterface()
|
||||
true
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
|
||||
setBondedDeviceAddress(deviceAddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
|
||||
* will not connect to a radio until this call is received.
|
||||
*/
|
||||
override fun connect() = toRemoteExceptions {
|
||||
// We don't start actually talking to our device until MeshService binds to us - this prevents
|
||||
// broadcasting connection events before MeshService is ready to receive them
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
// Do this in the IO thread because it might take a while (and we don't care about the result code)
|
||||
_serviceScope.handledLaunch { handleSendToRadio(bytes) }
|
||||
}
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private fun emitSendActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
|
||||
private fun emitReceiveActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.radio
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/** Android implementation of [RadioTransportFactory] delegating to the legacy [InterfaceFactory]. */
|
||||
@Single
|
||||
class AndroidRadioTransportFactory(
|
||||
private val context: Context,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
) : RadioTransportFactory {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
|
||||
interfaceFactory.value.createInterface(address, service)
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean = interfaceFactory.value.addressValid(address)
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ class InterfaceFactory(
|
|||
} ?: false
|
||||
|
||||
private fun splitAddress(address: String): Pair<InterfaceSpec<*>?, String> {
|
||||
if (address.isEmpty()) return Pair(null, "")
|
||||
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
|
||||
val rest = address.substring(1)
|
||||
return Pair(c, rest)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import kotlinx.coroutines.sync.withLock
|
|||
* Implements the START1/START2 + 2-byte-length + payload framing protocol used for serial and TCP communication with
|
||||
* Meshtastic radios.
|
||||
*
|
||||
* Shared between Android (`StreamInterface`/`TCPInterface`) and Desktop (`DesktopRadioInterfaceService`).
|
||||
* Shared across Android, Desktop, and iOS via `SharedRadioInterfaceService`.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
class StreamFrameCodec(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import java.net.SocketTimeoutException
|
|||
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff, heartbeat) and uses [StreamFrameCodec]
|
||||
* for the START1/START2 stream framing protocol.
|
||||
*
|
||||
* Used by both Android's `TCPInterface` and Desktop's `DesktopRadioInterfaceService`.
|
||||
* Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "MagicNumber")
|
||||
class TcpTransport(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
|
||||
/**
|
||||
* Creates [RadioTransport] instances for specific device addresses.
|
||||
*
|
||||
* Implemented per-platform to provide the correct hardware transport (BLE, Serial, TCP).
|
||||
*/
|
||||
interface RadioTransportFactory {
|
||||
/** The device types supported by this factory. */
|
||||
val supportedDeviceTypes: List<DeviceType>
|
||||
|
||||
/** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */
|
||||
fun isMockInterface(): Boolean
|
||||
|
||||
/** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */
|
||||
fun createTransport(address: String, service: RadioInterfaceService): RadioTransport
|
||||
|
||||
/** Checks if the given [address] represents a valid, supported transport type. */
|
||||
fun isAddressValid(address: String?): Boolean
|
||||
|
||||
/** Constructs a full radio address for the specific [interfaceId] and [rest] identifier. */
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
|
||||
}
|
||||
|
|
@ -32,14 +32,19 @@ kotlin {
|
|||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.repository)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.ble)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
|
||||
implementation(libs.jetbrains.lifecycle.runtime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
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.core.repository.RadioTransportFactory
|
||||
|
||||
/**
|
||||
* Shared multiplatform connection orchestrator for Meshtastic radios.
|
||||
*
|
||||
* Manages the connection lifecycle (connect, active, disconnect, reconnect loop), device address state flows, and
|
||||
* hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to a
|
||||
* platform-specific [RadioTransportFactory].
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class SharedRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val transportFactory: RadioTransportFactory,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType>
|
||||
get() = transportFactory.supportedDeviceTypes
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private var radioIf: RadioTransport? = null
|
||||
private var isStarted = false
|
||||
|
||||
@Volatile private var listenersInitialized = false
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = transportFactory.isMockInterface()
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
transportFactory.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
|
||||
|
||||
private fun getBondedDeviceAddress(): String? {
|
||||
val address = getDeviceAddress()
|
||||
return if (transportFactory.isAddressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
|
||||
|
||||
if (getBondedDeviceAddress() == sanitized && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${sanitized?.anonymize}, already using that device" }
|
||||
return false
|
||||
}
|
||||
|
||||
analytics.track("mesh_bond")
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
startInterface()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun startInterface() {
|
||||
if (radioIf != null) return
|
||||
|
||||
val address =
|
||||
getBondedDeviceAddress()
|
||||
?: if (isMockInterface()) transportFactory.toInterfaceAddress(InterfaceId.MOCK, "") else null
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No valid address to connect to." }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i { "Starting radio interface for ${address.anonymize}" }
|
||||
isStarted = true
|
||||
radioIf = transportFactory.createTransport(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val currentIf = radioIf
|
||||
Logger.i { "Stopping interface $currentIf" }
|
||||
isStarted = false
|
||||
radioIf = null
|
||||
currentIf?.close()
|
||||
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (currentIf != null) {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
radioIf?.keepAlive()
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
_serviceScope.handledLaunch {
|
||||
radioIf?.handleSendToRadio(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.d { "Broadcasting connection state change to Connected" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
_connectionState.emit(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
Logger.d { "Broadcasting connection state change to $newTargetState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newTargetState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ The module depends on the JVM variants of KMP modules:
|
|||
| `navigation/DesktopSettingsNavigation.kt` | Real settings feature composables wired into nav graph (~35 screens) |
|
||||
| `navigation/DesktopNodeNavigation.kt` | Real adaptive node list-detail + real metrics screens (logs + charts); map routes remain placeholders |
|
||||
| `navigation/DesktopMessagingNavigation.kt` | Real adaptive contacts list-detail + real Messages/Share/QuickChat route screens |
|
||||
| `radio/DesktopRadioInterfaceService.kt` | TCP, Serial/USB, and BLE transports with auto-reconnect, heartbeat, and backoff retry |
|
||||
| `radio/DesktopRadioTransportFactory.kt` | Provides TCP, Serial/USB, and BLE transports |
|
||||
| `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain |
|
||||
| `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets |
|
||||
| `ui/firmware/DesktopFirmwareScreen.kt` | Placeholder firmware screen (native DFU is Android-only) |
|
||||
|
|
@ -83,7 +83,7 @@ The module depends on the JVM variants of KMP modules:
|
|||
- [x] Wire `feature:node` composables into the nav graph (node list with shared ViewModel + NodeItem)
|
||||
- [x] Wire `feature:messaging` composables into the nav graph (contacts list with shared ViewModel)
|
||||
- [x] Add JetBrains Material 3 Adaptive `ListDetailPaneScaffold` to node and messaging screens
|
||||
- [x] Implement TCP transport (`DesktopRadioInterfaceService`) with auto-reconnect and backoff retry
|
||||
- [x] Implement TCP transport (`DesktopRadioTransportFactory`) with auto-reconnect and backoff retry
|
||||
- [x] Implement mesh service controller (`DesktopMeshServiceController`) with full `want_config` handshake
|
||||
- [x] Create connections screen using shared `feature:connections` with dynamic transport detection
|
||||
- [x] Replace 5 placeholder config screens with real desktop implementations (Device, Position, Network, Security, ExtNotification)
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ import org.meshtastic.core.repository.MeshServiceNotifications
|
|||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.desktop.radio.DesktopRadioInterfaceService
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
import org.meshtastic.desktop.stub.NoopLocationRepository
|
||||
|
|
@ -112,10 +112,9 @@ fun desktopModule() = module {
|
|||
@Suppress("LongMethod")
|
||||
private fun desktopPlatformStubsModule() = module {
|
||||
single<ServiceRepository> { org.meshtastic.core.service.ServiceRepositoryImpl() }
|
||||
single<RadioInterfaceService> {
|
||||
DesktopRadioInterfaceService(
|
||||
single<RadioTransportFactory> {
|
||||
DesktopRadioTransportFactory(
|
||||
dispatchers = get(),
|
||||
radioPrefs = get(),
|
||||
scanner = get(),
|
||||
bluetoothRepository = get(),
|
||||
connectionFactory = get(),
|
||||
|
|
|
|||
|
|
@ -26,22 +26,14 @@ import androidx.datastore.preferences.core.emptyPreferences
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.room.Room
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.database.DatabaseProvider
|
||||
import org.meshtastic.core.database.MeshtasticDatabase
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
|
||||
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
|
||||
|
|
@ -72,52 +64,6 @@ private fun createPreferencesDataStore(name: String, scope: CoroutineScope): Dat
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop Room KMP database provider. Builds a single file-backed SQLite database using [MeshtasticDatabaseConstructor]
|
||||
* and [BundledSQLiteDriver] (both KMP-ready).
|
||||
*/
|
||||
class DesktopDatabaseManager :
|
||||
DatabaseProvider,
|
||||
DatabaseManager {
|
||||
private val dir = desktopDataDir()
|
||||
private val dbName = "$dir/meshtastic.db"
|
||||
|
||||
private val db: MeshtasticDatabase by lazy {
|
||||
FileSystem.SYSTEM.createDirectories(dir.toPath())
|
||||
Room.databaseBuilder<MeshtasticDatabase>(name = dbName) { MeshtasticDatabaseConstructor.initialize() }
|
||||
.configureCommon()
|
||||
.build()
|
||||
}
|
||||
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> by lazy { MutableStateFlow(db) }
|
||||
|
||||
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(db)
|
||||
|
||||
private val _cacheLimit = MutableStateFlow(DEFAULT_CACHE_LIMIT)
|
||||
override val cacheLimit: StateFlow<Int> = _cacheLimit
|
||||
|
||||
override fun getCurrentCacheLimit(): Int = _cacheLimit.value
|
||||
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
_cacheLimit.value = limit.coerceIn(MIN_LIMIT, MAX_LIMIT)
|
||||
}
|
||||
|
||||
override suspend fun switchActiveDatabase(address: String?) {
|
||||
// Desktop uses a single database — no per-device switching
|
||||
}
|
||||
|
||||
override fun hasDatabaseFor(address: String?): Boolean {
|
||||
// Desktop always has the single database available
|
||||
return !address.isNullOrBlank() && address != "n"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_CACHE_LIMIT = 100
|
||||
private const val MIN_LIMIT = 1
|
||||
private const val MAX_LIMIT = 100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic [LifecycleOwner] that stays permanently in [Lifecycle.State.RESUMED]. Replaces Android's
|
||||
* `ProcessLifecycleOwner` for desktop.
|
||||
|
|
@ -139,7 +85,6 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
|
|||
* Provides all platform-specific bindings that the real KMP `commonMain` implementations need:
|
||||
* - Named [DataStore]<[Preferences]> instances (12 preference stores + 1 core preferences store)
|
||||
* - Proto [DataStore] instances (LocalConfig, ModuleConfig, ChannelSet, LocalStats)
|
||||
* - [DatabaseProvider] and [DatabaseManager] via Room KMP
|
||||
* - [Lifecycle] (`ProcessLifecycle`)
|
||||
* - [BuildConfigProvider]
|
||||
*/
|
||||
|
|
@ -147,8 +92,6 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
|
|||
fun desktopPlatformModule() = module {
|
||||
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
|
||||
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// -- Build config --
|
||||
single<BuildConfigProvider> {
|
||||
object : BuildConfigProvider {
|
||||
|
|
@ -163,11 +106,6 @@ fun desktopPlatformModule() = module {
|
|||
|
||||
// -- Process Lifecycle (stays RESUMED forever on desktop) --
|
||||
single(named("ProcessLifecycle")) { DesktopProcessLifecycleOwner().lifecycle }
|
||||
|
||||
// -- Database (Room KMP with BundledSQLiteDriver) --
|
||||
single { DesktopDatabaseManager() }
|
||||
single<DatabaseProvider> { get<DesktopDatabaseManager>() }
|
||||
single<DatabaseManager> { get<DesktopDatabaseManager>() }
|
||||
}
|
||||
|
||||
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
|
||||
|
|
|
|||
|
|
@ -1,260 +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.desktop.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.transport.TcpTransport
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
|
||||
/**
|
||||
* Desktop implementation of [RadioInterfaceService] with real TCP transport.
|
||||
*
|
||||
* Delegates all TCP socket management, stream framing, reconnect logic, and heartbeat to the shared [TcpTransport] from
|
||||
* `core:network`. Desktop supports TCP and BLE connections.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class DesktopRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val scanner: org.meshtastic.core.ble.BleScanner,
|
||||
private val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository,
|
||||
private val connectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
|
||||
listOf(
|
||||
org.meshtastic.core.model.DeviceType.TCP,
|
||||
org.meshtastic.core.model.DeviceType.BLE,
|
||||
org.meshtastic.core.model.DeviceType.USB,
|
||||
)
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
override var serviceScope: CoroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private set
|
||||
|
||||
private var transport: TcpTransport? = null
|
||||
private var bleTransport: DesktopBleInterface? = null
|
||||
private var serialTransport: org.meshtastic.core.network.SerialTransport? = null
|
||||
|
||||
init {
|
||||
// Observe radioPrefs to handle asynchronous loads from DataStore
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
}
|
||||
// Auto-connect if we have a valid address and are disconnected
|
||||
if (addr != null && _connectionState.value == ConnectionState.Disconnected) {
|
||||
Logger.i { "DesktopRadio: Auto-connecting to saved address ${addr.anonymize}" }
|
||||
startConnection(addr)
|
||||
}
|
||||
}
|
||||
.launchIn(serviceScope)
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
|
||||
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
|
||||
|
||||
// region RadioInterfaceService Implementation
|
||||
|
||||
override fun connect() {
|
||||
val address = getDeviceAddress()
|
||||
if (address.isNullOrBlank() || address == "n") {
|
||||
Logger.w { "DesktopRadio: No address configured, skipping connect" }
|
||||
return
|
||||
}
|
||||
startConnection(address)
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
|
||||
|
||||
if (_currentDeviceAddressFlow.value == sanitized && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "DesktopRadio: Already connected to ${sanitized?.anonymize}, ignoring" }
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.i { "DesktopRadio: Setting device address to ${sanitized?.anonymize}" }
|
||||
|
||||
// Stop any existing connection
|
||||
stopInterface()
|
||||
|
||||
// Persist and update address
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
|
||||
// Start connection if we have a valid address
|
||||
if (sanitized != null && sanitized != "n") {
|
||||
startConnection(sanitized)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
serviceScope.handledLaunch {
|
||||
transport?.sendPacket(bytes)
|
||||
bleTransport?.handleSendToRadio(bytes)
|
||||
serialTransport?.handleSendToRadio(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.i { "DesktopRadio: Connected" }
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
val newState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newState) {
|
||||
Logger.i { "DesktopRadio: Disconnected (permanent=$isPermanent, error=$errorMessage)" }
|
||||
_connectionState.value = newState
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
serviceScope.launch(dispatchers.io) {
|
||||
_receivedData.emit(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Connection Management
|
||||
|
||||
private fun startConnection(address: String) {
|
||||
if (address.startsWith("t")) {
|
||||
startTcpConnection(address.removePrefix("t"))
|
||||
} else if (address.startsWith("s")) {
|
||||
startSerialConnection(address.removePrefix("s"))
|
||||
} else if (address.startsWith("x")) {
|
||||
startBleConnection(address.removePrefix("x"))
|
||||
} else {
|
||||
// Assume BLE if no prefix, or prefix is not supported
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
startBleConnection(stripped)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSerialConnection(portName: String) {
|
||||
transport?.stop()
|
||||
bleTransport?.close()
|
||||
serialTransport?.close()
|
||||
|
||||
val serial = org.meshtastic.core.network.SerialTransport(portName = portName, service = this)
|
||||
serialTransport = serial
|
||||
if (!serial.startConnection()) {
|
||||
onDisconnect(isPermanent = true, errorMessage = "Failed to connect to $portName")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBleConnection(address: String) {
|
||||
transport?.stop()
|
||||
bleTransport?.close()
|
||||
|
||||
bleTransport =
|
||||
DesktopBleInterface(
|
||||
serviceScope = serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = this,
|
||||
address = address,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startTcpConnection(address: String) {
|
||||
transport?.stop()
|
||||
|
||||
val tcpTransport =
|
||||
TcpTransport(
|
||||
dispatchers = dispatchers,
|
||||
scope = serviceScope,
|
||||
listener =
|
||||
object : TcpTransport.Listener {
|
||||
override fun onConnected() {
|
||||
onConnect()
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
|
||||
override fun onPacketReceived(bytes: ByteArray) {
|
||||
handleFromRadio(bytes)
|
||||
}
|
||||
},
|
||||
logTag = "DesktopRadio",
|
||||
)
|
||||
transport = tcpTransport
|
||||
tcpTransport.start(address)
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
transport?.stop()
|
||||
transport = null
|
||||
|
||||
bleTransport?.close()
|
||||
bleTransport = null
|
||||
|
||||
serialTransport?.close()
|
||||
serialTransport = null
|
||||
|
||||
// Recreate the service scope
|
||||
serviceScope.cancel("stopping interface")
|
||||
serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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.desktop.radio
|
||||
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.network.SerialTransport
|
||||
import org.meshtastic.core.network.radio.TCPInterface
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
class DesktopRadioTransportFactory(
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : RadioTransportFactory {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean {
|
||||
val spec = address?.getOrNull(0) ?: return false
|
||||
return spec == InterfaceId.TCP.id ||
|
||||
spec == InterfaceId.SERIAL.id ||
|
||||
spec == InterfaceId.BLUETOOTH.id ||
|
||||
address.startsWith("!")
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
|
||||
if (address.startsWith(InterfaceId.TCP.id)) {
|
||||
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
|
||||
} else if (address.startsWith(InterfaceId.SERIAL.id)) {
|
||||
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
|
||||
} else if (address.startsWith(InterfaceId.BLUETOOTH.id)) {
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
|
||||
)
|
||||
} else {
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = stripped,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +110,8 @@ Based on the latest codebase investigation, the following steps are proposed to
|
|||
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
|
||||
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
|
||||
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
|
||||
| **Transport UI Unification** | ✅ Done | `RadioInterfaceService` provides dynamic transport capability to shared UI |
|
||||
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
|
||||
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
||||
|
||||
## Navigation Parity Note
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
|
|||
3. **UI Interop Strategies.** When a Compose Multiplatform equivalent doesn't exist (e.g., Maps, Camera), use standard interop APIs rather than extracting the entire screen to native code. Use `AndroidView` for Android, `UIKitView` for iOS, `SwingPanel` for JVM/Desktop, and `HtmlView` for Web (`wasmJs`). Always wrap these in a shared `commonMain` interface contract (like `LocalBarcodeScannerProvider`).
|
||||
4. **Stubs are a valid first implementation.** Every target starts with no-op stubs, then graduates to real implementations.
|
||||
5. **Feature modules stay target-agnostic in `commonMain`.** Platform UI goes in platform source sets. Keep the UI layer dumb and rely on shared ViewModels (Unidirectional Data Flow) to drive state.
|
||||
6. **Transport is a pluggable adapter.** BLE, serial, TCP, MQTT all implement `RadioInterfaceService`.
|
||||
6. **Transport is a pluggable adapter.** BLE, serial, TCP, MQTT all implement `RadioTransport` and are orchestrated by a shared `RadioInterfaceService`.
|
||||
7. **CI validates every target.** If a module declares `jvm()`, CI compiles it. No exceptions. Run tests on appropriate host runners (macOS for iOS, Linux for JVM/Android) to catch platform regressions.
|
||||
8. **Test in `commonTest` first.** ViewModel and business logic tests belong in `commonTest` so every target runs them. Use shared `core:testing` utilities to minimize duplication.
|
||||
9. **Zero Platform Leaks.** Never import `java.*` or `android.*` inside `commonMain`. Use KMP-native alternatives like `kotlinx-datetime` and `Okio`.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ jetbrains-lifecycle = "2.10.0"
|
|||
navigation = "2.9.7"
|
||||
navigation3 = "1.1.0-alpha04"
|
||||
paging = "3.4.2"
|
||||
room = "2.8.4"
|
||||
room = "3.0.0-alpha01"
|
||||
savedstate = "1.4.0"
|
||||
koin = "4.2.0"
|
||||
koin-annotations = "2.1.0"
|
||||
|
|
@ -109,11 +109,11 @@ jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:n
|
|||
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
||||
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
|
||||
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" }
|
||||
androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }
|
||||
androidx-room-paging = { module = "androidx.room3:room3-paging", version.ref = "room" }
|
||||
androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
|
||||
androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" }
|
||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.5.0" }
|
||||
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
|
||||
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
|
||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
|
||||
|
|
@ -244,7 +244,7 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve
|
|||
# Build Logic
|
||||
android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }
|
||||
android-tools-common = { module = "com.android.tools:common", version = "32.1.0" }
|
||||
androidx-room-gradlePlugin = { module = "androidx.room:room-gradle-plugin", version.ref = "room" }
|
||||
androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" }
|
||||
compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
|
||||
compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" }
|
||||
datadog-gradlePlugin = { module = "com.datadoghq.dd-sdk-android-gradle-plugin:com.datadoghq.dd-sdk-android-gradle-plugin.gradle.plugin", version.ref = "datadog-gradle" }
|
||||
|
|
@ -296,7 +296,7 @@ datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin", version.ref = "da
|
|||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
|
||||
wire = { id = "com.squareup.wire", version.ref = "wire" }
|
||||
room = { id = "androidx.room", version.ref = "room" }
|
||||
room = { id = "androidx.room3", version.ref = "room" }
|
||||
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||||
test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" }
|
||||
dependency-guard = { id = "com.dropbox.dependency-guard", version.ref = "dependency-guard" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue