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

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

View file

@ -42,10 +42,11 @@ 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
@Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class])
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
open class DatabaseManager(private val app: Application, private val dispatchers: CoroutineDispatchers) :
DatabaseProvider,
SharedDatabaseManager {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@ -69,7 +70,7 @@ open class DatabaseManager(private val app: Application, private val dispatchers
}
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
val currentDb: StateFlow<MeshtasticDatabase> =
override val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName()))
private val _currentAddress = MutableStateFlow<String?>(null)
@ -119,7 +120,7 @@ open class DatabaseManager(private val app: Application, private val dispatchers
private val limitedIo = dispatchers.io.limitedParallelism(4)
/** Execute [block] with the current DB instance. */
suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
val db = _currentDb.value ?: return@withContext null
val active = buildDbName(_currentAddress.value)
markLastUsed(active)
@ -127,7 +128,7 @@ open class DatabaseManager(private val app: Application, private val dispatchers
}
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {
override fun hasDatabaseFor(address: String?): Boolean {
if (address.isNullOrBlank() || address == "n") return false
val dbName = buildDbName(address)
return getDbFile(app, dbName) != null

View file

@ -0,0 +1,31 @@
/*
* 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 kotlinx.coroutines.flow.StateFlow
/**
* Provides multiplatform access to the current [MeshtasticDatabase] and a safe transactional helper. Platform
* implementations manage the concrete lifecycle (Room on Android, etc.).
*/
interface DatabaseProvider {
/** Reactive stream of the currently active database instance. */
val currentDb: StateFlow<MeshtasticDatabase>
/** Execute [block] against the current database, returning `null` if no database is available. */
suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T?
}

View file

@ -109,7 +109,7 @@ interface NodeInfoDao {
val incomingKey = incomingNode.publicKey
val incomingHasKey = (incomingKey?.size ?: 0) == KEY_SIZE
val existingHasKey = (existingKey?.size ?: 0) == KEY_SIZE && existingKey != NodeEntity.ERROR_BYTE_STRING
val existingHasKey = existingKey.size == KEY_SIZE && existingKey != NodeEntity.ERROR_BYTE_STRING
return when {
incomingHasKey -> {
@ -143,7 +143,7 @@ interface NodeInfoDao {
val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET
val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET
val isDefaultName = incomingNode.user.long_name?.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) == true
val isDefaultName = incomingNode.user.long_name.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
if (hasExistingUser && isPlaceholder && isDefaultName) {
return incomingNode.copy(

View file

@ -27,6 +27,7 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.Position
import org.meshtastic.core.model.MeshLog as ExternalMeshLog
/**
* Represents a log entry in the database.
@ -83,3 +84,23 @@ data class MeshLog(
const val NODE_NUM_LOCAL = 0
}
}
fun MeshLog.asExternalModel() = ExternalMeshLog(
uuid = uuid,
message_type = message_type,
received_date = received_date,
raw_message = raw_message,
fromNum = fromNum,
portNum = portNum,
fromRadio = fromRadio,
)
fun ExternalMeshLog.asEntity() = MeshLog(
uuid = uuid,
message_type = message_type,
received_date = received_date,
raw_message = raw_message,
fromNum = fromNum,
portNum = portNum,
fromRadio = fromRadio,
)

View file

@ -163,7 +163,7 @@ data class NodeEntity(
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
get() = (publicKey ?: user.public_key).size > 0
fun setPosition(p: WirePosition, defaultTime: Int = currentTime()) {
position = p.copy(time = if (p.time != 0) p.time else defaultTime)
@ -216,8 +216,8 @@ data class NodeEntity(
user =
MeshUser(
id = user.id,
longName = user.long_name ?: "",
shortName = user.short_name ?: "",
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
)
@ -228,10 +228,10 @@ data class NodeEntity(
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view ?: 0,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { it.isValid() },
snr = snr,