feat: Migrate to Room 3.0 and update related documentation and tracks (#4865)

This commit is contained in:
James Rich 2026-03-20 16:40:08 -05:00 committed by GitHub
parent 6cdd10d936
commit c4087c2ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1097 additions and 921 deletions

View file

@ -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)
}
}

View file

@ -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`.

View file

@ -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"
}

View file

@ -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.

View file

@ -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.

View file

@ -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`.

View file

@ -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"
}

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,5 @@
# Track migrate_room3_20260320 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View 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."
}

View 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)

View 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.

View file

@ -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

View file

@ -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

View file

@ -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.
---

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.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) })

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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() }

View file

@ -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

View file

@ -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> {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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)
}

View file

@ -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") })
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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(

View file

@ -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(

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) }
}
}
}

View file

@ -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)

View file

@ -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(),

View file

@ -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. */

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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

View file

@ -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`.

View file

@ -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" }