feat: per device DB manager (#3641)

This commit is contained in:
Mac DeCourcy
2025-11-09 08:54:21 -08:00
committed by GitHub
parent f0b9a0ff75
commit cb8d1871c9
16 changed files with 643 additions and 86 deletions

View File

@@ -18,7 +18,16 @@
package com.geeksville.mesh
import android.app.Application
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.prefs.mesh.MeshPrefs
import timber.log.Timber
/**
@@ -33,9 +42,22 @@ class MeshUtilApplication : Application() {
override fun onCreate() {
super.onCreate()
initializeMaps(this)
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
CoroutineScope(Dispatchers.Default).launch {
entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress)
}
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppEntryPoint {
fun databaseManager(): DatabaseManager
fun meshPrefs(): MeshPrefs
}
fun logAssert(executeReliableWrite: Boolean) {
if (!executeReliableWrite) {
val ex = AssertionError("Assertion failed")

View File

@@ -58,6 +58,7 @@ import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
@@ -138,6 +139,8 @@ class MeshService : Service() {
@Inject lateinit var nodeRepository: NodeRepository
@Inject lateinit var databaseManager: DatabaseManager
@Inject lateinit var mqttRepository: MQTTRepository
@Inject lateinit var serviceNotifications: MeshServiceNotifications
@@ -2008,8 +2011,14 @@ class MeshService : Service() {
"SetDeviceAddress: Device address changed from ${currentAddr.anonymize} to ${deviceAddr.anonymize}",
)
meshPrefs.deviceAddress = deviceAddr
clearDatabases()
clearNotifications()
serviceScope.handledLaunch {
// Clear only in-memory caches to avoid cross-device bleed
discardNodeDB()
// Switch active on-disk DB to device-specific database
databaseManager.switchActiveDatabase(deviceAddr)
// Do not clear packet DB here; messages are per-device and should persist
clearNotifications()
}
} else {
Timber.d("SetDeviceAddress: Device address is unchanged, ignoring.")
}

View File

@@ -34,6 +34,9 @@ dependencies {
implementation(projects.core.prefs)
implementation(projects.core.proto)
// Needed because core:data references MeshtasticDatabase (supertype RoomDatabase)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.core.location.altitude)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber)

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
interface NodeInfoReadDataSource {
fun myNodeInfoFlow(): Flow<MyNodeEntity?>
fun nodeDBbyNumFlow(): Flow<Map<Int, NodeWithRelations>>
fun getNodesFlow(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>>
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity>
suspend fun getUnknownNodes(): List<NodeEntity>
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
interface NodeInfoWriteDataSource {
suspend fun upsert(node: NodeEntity)
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>)
suspend fun clearNodeDB()
suspend fun deleteNode(num: Int)
suspend fun deleteNodes(nodeNums: List<Int>)
suspend fun deleteMetadata(num: Int)
suspend fun upsert(metadata: MetadataEntity)
suspend fun setNodeNotes(num: Int, notes: String)
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SwitchingNodeInfoReadDataSource @Inject constructor(private val dbManager: DatabaseManager) :
NodeInfoReadDataSource {
override fun myNodeInfoFlow(): Flow<MyNodeEntity?> =
dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().getMyNodeInfo() }
override fun nodeDBbyNumFlow(): Flow<Map<Int, NodeWithRelations>> =
dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().nodeDBbyNum() }
override fun getNodesFlow(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>> = dbManager.currentDb.flatMapLatest { db ->
db.nodeInfoDao()
.getNodes(
sort = sort,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = hopsAwayMax,
lastHeardMin = lastHeardMin,
)
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) }
override suspend fun getUnknownNodes(): List<NodeEntity> = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() }
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SwitchingNodeInfoWriteDataSource
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {
override suspend fun upsert(node: NodeEntity) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
override suspend fun clearNodeDB() =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo() } }
override suspend fun deleteNode(num: Int) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
override suspend fun deleteNodes(nodeNums: List<Int>) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
override suspend fun deleteMetadata(num: Int) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
override suspend fun upsert(metadata: MetadataEntity) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
override suspend fun setNodeNotes(num: Int, notes: String) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoReadDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoWriteDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface NodeDataSourceModule {
@Binds @Singleton
fun bindNodeInfoReadDataSource(impl: SwitchingNodeInfoReadDataSource): NodeInfoReadDataSource
@Binds @Singleton
fun bindNodeInfoWriteDataSource(impl: SwitchingNodeInfoWriteDataSource): NodeInfoWriteDataSource
}

View File

@@ -17,14 +17,14 @@
package org.meshtastic.core.data.repository
import dagger.Lazy
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.MeshProtos
@@ -37,16 +37,16 @@ import javax.inject.Inject
class MeshLogRepository
@Inject
constructor(
private val meshLogDaoLazy: Lazy<MeshLogDao>,
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
private val meshLogDao by lazy { meshLogDaoLazy.get() }
fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> =
meshLogDao.getAllLogs(maxItems).flowOn(dispatchers.io).conflate()
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItems) }.flowOn(dispatchers.io).conflate()
fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> =
meshLogDao.getAllLogsInReceiveOrder(maxItems).flowOn(dispatchers.io).conflate()
fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItems) }
.flowOn(dispatchers.io)
.conflate()
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
@@ -106,8 +106,10 @@ constructor(
}
.getOrNull()
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = meshLogDao
.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = dbManager.currentDb
.flatMapLatest {
it.meshLogDao().getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
}
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.flowOn(dispatchers.io)
@@ -116,8 +118,10 @@ constructor(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
maxItem: Int = MAX_MESH_PACKETS,
): Flow<List<MeshLog>> =
meshLogDao.getLogsFrom(nodeNum, portNum, maxItem).distinctUntilChanged().flowOn(dispatchers.io)
): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, maxItem) }
.distinctUntilChanged()
.flowOn(dispatchers.io)
/*
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
@@ -130,14 +134,16 @@ constructor(
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)
suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { meshLogDao.insert(log) }
suspend fun insert(log: MeshLog) =
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().insert(log) }
suspend fun deleteAll() = withContext(dispatchers.io) { meshLogDao.deleteAll() }
suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() }
suspend fun deleteLog(uuid: String) = withContext(dispatchers.io) { meshLogDao.deleteLog(uuid) }
suspend fun deleteLog(uuid: String) =
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLog(uuid) }
suspend fun deleteLogs(nodeNum: Int, portNum: Int) =
withContext(dispatchers.io) { meshLogDao.deleteLogs(nodeNum, portNum) }
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLogs(nodeNum, portNum) }
companion object {
private const val MAX_ITEMS = 500

View File

@@ -30,7 +30,8 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
@@ -49,13 +50,14 @@ class NodeRepository
@Inject
constructor(
processLifecycle: Lifecycle,
private val nodeInfoDao: NodeInfoDao,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
) {
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?> =
nodeInfoDao
.getMyNodeInfo()
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
@@ -69,12 +71,13 @@ constructor(
val myId: StateFlow<String?>
get() = _myId
fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
fun getNodeDBbyNum() =
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
// A map from nodeNum to Node
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
nodeInfoDao
.nodeDBbyNum()
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
.onEach {
val ourNodeInfo = it.values.firstOrNull()
@@ -116,8 +119,8 @@ constructor(
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoDao
.getNodes(
) = nodeInfoReadDataSource
.getNodesFlow(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
@@ -128,40 +131,46 @@ constructor(
.flowOn(dispatchers.io)
.conflate()
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) }
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node) }
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
withContext(dispatchers.io) { nodeInfoDao.installConfig(mi, nodes) }
withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi, nodes) }
suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() }
suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB() }
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
nodeInfoDao.deleteNode(num)
nodeInfoDao.deleteMetadata(num)
nodeInfoWriteDataSource.deleteNode(num)
nodeInfoWriteDataSource.deleteMetadata(num)
}
suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
nodeInfoDao.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoDao.deleteMetadata(it) }
nodeInfoWriteDataSource.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) }
}
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
withContext(dispatchers.io) { nodeInfoDao.getNodesOlderThan(lastHeard) }
withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard) }
suspend fun getUnknownNodes(): List<NodeEntity> = withContext(dispatchers.io) { nodeInfoDao.getUnknownNodes() }
suspend fun getUnknownNodes(): List<NodeEntity> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes() }
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(metadata) }
suspend fun insertMetadata(metadata: MetadataEntity) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(metadata) }
val onlineNodeCount: Flow<Int> =
nodeInfoDao
.nodeDBbyNum()
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
.flowOn(dispatchers.io)
.conflate()
val totalNodeCount: Flow<Int> =
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate()
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count() }
.flowOn(dispatchers.io)
.conflate()
suspend fun setNodeNotes(num: Int, notes: String) =
withContext(dispatchers.io) { nodeInfoDao.setNodeNotes(num, notes) }
withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) }
}

View File

@@ -17,45 +17,54 @@
package org.meshtastic.core.data.repository
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.PacketDao
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.Portnums.PortNum
import javax.inject.Inject
class PacketRepository @Inject constructor(private val packetDaoLazy: Lazy<PacketDao>) {
private val packetDao by lazy { packetDaoLazy.get() }
class PacketRepository
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
fun getWaypoints(): Flow<List<Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllPackets(PortNum.WAYPOINT_APP_VALUE) }
fun getWaypoints(): Flow<List<Packet>> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE)
fun getContacts(): Flow<Map<String, Packet>> = packetDao.getContactKeys()
fun getContacts(): Flow<Map<String, Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() }
suspend fun getMessageCount(contact: String): Int =
withContext(Dispatchers.IO) { packetDao.getMessageCount(contact) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) }
suspend fun getUnreadCount(contact: String): Int = withContext(Dispatchers.IO) { packetDao.getUnreadCount(contact) }
suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) }
fun getUnreadCountTotal(): Flow<Int> = packetDao.getUnreadCountTotal()
fun getUnreadCountTotal(): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() }
suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(Dispatchers.IO) { packetDao.clearUnreadCount(contact, timestamp) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) }
suspend fun getQueuedPackets(): List<DataPacket>? = withContext(Dispatchers.IO) { packetDao.getQueuedPackets() }
suspend fun getQueuedPackets(): List<DataPacket>? =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) { packetDao.insert(packet) }
suspend fun insert(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
suspend fun getMessagesFrom(contact: String, getNode: suspend (String?) -> Node) = withContext(Dispatchers.IO) {
packetDao.getMessagesFrom(contact).mapLatest { packets ->
suspend fun getMessagesFrom(contact: String, getNode: suspend (String?) -> Node) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getMessagesFrom(contact).mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
@@ -68,43 +77,53 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: Lazy<Packe
}
suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) =
withContext(Dispatchers.IO) { packetDao.updateMessageStatus(d, m) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) }
suspend fun updateMessageId(d: DataPacket, id: Int) =
withContext(Dispatchers.IO) { packetDao.updateMessageId(d, id) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) }
suspend fun getPacketById(requestId: Int) = withContext(Dispatchers.IO) { packetDao.getPacketById(requestId) }
suspend fun getPacketById(requestId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(requestId) }
suspend fun getPacketByPacketId(packetId: Int) =
withContext(Dispatchers.IO) { packetDao.getPacketByPacketId(packetId) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
suspend fun deleteMessages(uuidList: List<Long>) = withContext(Dispatchers.IO) {
for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query
packetDao.deleteMessages(chunk)
suspend fun deleteMessages(uuidList: List<Long>) = withContext(dispatchers.io) {
for (chunk in uuidList.chunked(500)) {
// Fetch DAO per chunk to avoid holding a stale reference if the active DB switches
dbManager.currentDb.value.packetDao().deleteMessages(chunk)
}
}
suspend fun deleteContacts(contactList: List<String>) =
withContext(Dispatchers.IO) { packetDao.deleteContacts(contactList) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) }
suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) { packetDao.deleteWaypoint(id) }
suspend fun deleteWaypoint(id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) }
suspend fun delete(packet: Packet) = withContext(Dispatchers.IO) { packetDao.delete(packet) }
suspend fun delete(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) }
suspend fun update(packet: Packet) = withContext(Dispatchers.IO) { packetDao.update(packet) }
suspend fun update(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(packet) }
fun getContactSettings(): Flow<Map<String, ContactSettings>> = packetDao.getContactSettings()
fun getContactSettings(): Flow<Map<String, ContactSettings>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactSettings() }
suspend fun getContactSettings(contact: String) =
withContext(Dispatchers.IO) { packetDao.getContactSettings(contact) ?: ContactSettings(contact) }
suspend fun getContactSettings(contact: String) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getContactSettings(contact) ?: ContactSettings(contact)
}
suspend fun setMuteUntil(contacts: List<String>, until: Long) =
withContext(Dispatchers.IO) { packetDao.setMuteUntil(contacts, until) }
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setMuteUntil(contacts, until) }
suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) { packetDao.insert(reaction) }
suspend fun insertReaction(reaction: ReactionEntity) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) }
suspend fun clearPacketDB() = withContext(Dispatchers.IO) { packetDao.deleteAll() }
suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }
suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings)
}
}

View File

@@ -17,10 +17,10 @@
package org.meshtastic.core.data.repository
import dagger.Lazy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Inject
@@ -28,19 +28,20 @@ import javax.inject.Inject
class QuickChatActionRepository
@Inject
constructor(
private val quickChatDaoLazy: Lazy<QuickChatActionDao>,
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
private val quickChatActionDao by lazy { quickChatDaoLazy.get() }
fun getAllActions() = dbManager.currentDb.flatMapLatest { it.quickChatActionDao().getAll() }.flowOn(dispatchers.io)
fun getAllActions() = quickChatActionDao.getAll().flowOn(dispatchers.io)
suspend fun upsert(action: QuickChatAction) =
withContext(dispatchers.io) { dbManager.currentDb.value.quickChatActionDao().upsert(action) }
suspend fun upsert(action: QuickChatAction) = withContext(dispatchers.io) { quickChatActionDao.upsert(action) }
suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.quickChatActionDao().deleteAll() }
suspend fun deleteAll() = withContext(dispatchers.io) { quickChatActionDao.deleteAll() }
suspend fun delete(action: QuickChatAction) =
withContext(dispatchers.io) { dbManager.currentDb.value.quickChatActionDao().delete(action) }
suspend fun delete(action: QuickChatAction) = withContext(dispatchers.io) { quickChatActionDao.delete(action) }
suspend fun setItemPosition(uuid: Long, newPos: Int) =
withContext(dispatchers.io) { quickChatActionDao.updateActionPosition(uuid, newPos) }
suspend fun setItemPosition(uuid: Long, newPos: Int) = withContext(dispatchers.io) {
dbManager.currentDb.value.quickChatActionDao().updateActionPosition(uuid, newPos)
}
}

View File

@@ -0,0 +1,218 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
class DatabaseManager @Inject constructor(private val app: Application) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
// Expose the DB cache limit as a reactive stream so UI can observe changes.
private val _cacheLimit = MutableStateFlow(getCacheLimit())
val cacheLimit: StateFlow<Int> = _cacheLimit
// 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 = getCacheLimit()
}
}
init {
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
}
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName()))
private val _currentAddress = MutableStateFlow<String?>(null)
val currentAddress: StateFlow<String?> = _currentAddress
private val dbCache = mutableMapOf<String, MeshtasticDatabase>() // key = dbName
/** Initialize the active database for [address]. */
suspend fun init(address: String?) = switchActiveDatabase(address)
/** Switch active database to the one associated with [address]. Serialized via mutex. */
suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
val dbName = buildDbName(address)
// Fast path: no-op if already on this address
if (_currentAddress.value == address && _currentDb.value != null) {
markLastUsed(dbName)
return
}
// Build/open Room DB off the main thread
val db =
dbCache[dbName]
?: withContext(Dispatchers.IO) { buildRoomDb(app, dbName) }.also { dbCache[dbName] = it }
_currentDb.value = db
_currentAddress.value = address
markLastUsed(dbName)
// Defer LRU eviction so switch is not blocked by filesystem work
managerScope.launch(Dispatchers.IO) { enforceCacheLimit(activeDbName = dbName) }
Timber.i("Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}")
}
/** Execute [block] with the current DB instance. */
fun <T> withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
private fun markLastUsed(dbName: String) {
prefs.edit().putLong(lastUsedKey(dbName), System.currentTimeMillis()).apply()
}
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 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
.filter { it.startsWith(DatabaseConstants.DB_PREFIX) }
.filterNot { it.endsWith("-wal") || it.endsWith("-shm") }
.distinct()
}
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
val limit = getCacheLimit()
val all = listExistingDbNames()
if (all.size <= limit) return
val victims = all.filter { it != activeDbName }.sortedBy { lastUsed(it) }.take(all.size - limit)
victims.forEach { name ->
runCatching { dbCache.remove(name)?.close() }
.onFailure { Timber.w(it, "Failed to close database %s", name) }
app.deleteDatabase(name)
prefs.edit().remove(lastUsedKey(name)).apply()
Timber.i("Evicted cached DB ${anonymizeDbName(name)}")
}
}
fun getCacheLimit(): Int = prefs
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
fun setCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
if (clamped == getCacheLimit()) return
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
_cacheLimit.value = clamped
// Enforce asynchronously with current active DB protected
val active = _currentDb.value?.openHelper?.databaseName ?: defaultDbName()
managerScope.launch(Dispatchers.IO) { enforceCacheLimit(activeDbName = active) }
}
}
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
const val LEGACY_DB_NAME: String = DB_PREFIX
const val DEFAULT_DB_NAME: String = "${DB_PREFIX}_default"
const val CACHE_LIMIT_KEY: String = "node_db_cache_limit"
const val DEFAULT_CACHE_LIMIT: Int = 3
const val MIN_CACHE_LIMIT: Int = 1
const val MAX_CACHE_LIMIT: Int = 10
// Display/truncation and hash sizing for DB names
const val DB_NAME_HASH_LEN: Int = 10
const val DB_NAME_SEPARATOR_LEN: Int = 1
const val DB_NAME_SUFFIX_LEN: Int = 3
// Address anonymization sizing
const val ADDRESS_ANON_SHORT_LEN: Int = 4
const val ADDRESS_ANON_EDGE_LEN: Int = 2
}
// File-private helpers (kept outside the class to reduce class function count)
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
private fun normalizeAddress(addr: String?): String = addr?.uppercase()?.replace(":", "") ?: "DEFAULT"
private fun shortSha1(s: String): String = MessageDigest.getInstance("SHA-1")
.digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(DatabaseConstants.DB_NAME_HASH_LEN)
private fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
defaultDbName()
} else {
"${DatabaseConstants.DB_PREFIX}_${shortSha1(normalizeAddress(address))}"
}
private fun lastUsedKey(dbName: String) = "db_last_used:$dbName"
private fun anonymizeAddress(address: String?): String = when {
address == null -> "null"
address.length <= DatabaseConstants.ADDRESS_ANON_SHORT_LEN -> address
else ->
address.take(DatabaseConstants.ADDRESS_ANON_EDGE_LEN) +
"" +
address.takeLast(DatabaseConstants.ADDRESS_ANON_EDGE_LEN)
}
private fun anonymizeDbName(name: String): String =
if (name == DatabaseConstants.LEGACY_DB_NAME || name == DatabaseConstants.DEFAULT_DB_NAME) {
name
} else {
name.take(
DatabaseConstants.DB_PREFIX.length +
DatabaseConstants.DB_NAME_SEPARATOR_LEN +
DatabaseConstants.DB_NAME_SUFFIX_LEN,
) + ""
}
private fun buildRoomDb(app: Application, dbName: String): MeshtasticDatabase =
Room.databaseBuilder(app.applicationContext, MeshtasticDatabase::class.java, dbName)
.fallbackToDestructiveMigration(false)
.build()
private fun getDbFile(app: Application, dbName: String): File? = app.getDatabasePath(dbName).takeIf { it.exists() }

View File

@@ -499,6 +499,8 @@
<string name="allow_input_source">Allow input source</string>
<string name="send_bell">Send bell</string>
<string name="messages">Messages</string>
<string name="device_db_cache_limit">Device DB cache limit</string>
<string name="device_db_cache_limit_summary">Max device databases to keep on this phone</string>
<string name="detection_sensor_config">Detection Sensor Config</string>
<string name="detection_sensor_enabled">Detection Sensor enabled</string>
<string name="minimum_broadcast_seconds">Minimum broadcast (seconds)</string>

View File

@@ -64,7 +64,9 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
@@ -307,6 +309,22 @@ fun SettingsScreen(
) {
showThemePickerDialog = true
}
// Node DB cache limit (App setting)
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val nodeName = ourNode?.user?.shortName ?: ""

View File

@@ -37,6 +37,8 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
@@ -71,6 +73,7 @@ constructor(
private val uiPrefs: UiPrefs,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
) : ViewModel() {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
@@ -106,6 +109,14 @@ constructor(
val appVersionName
get() = buildConfigProvider.versionName
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)
}
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}