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

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