From cb8d1871c90c31cd97e096f4a12c5995e4c6a413 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Sun, 9 Nov 2025 08:54:21 -0800 Subject: [PATCH] feat: per device DB manager (#3641) --- .../geeksville/mesh/MeshUtilApplication.kt | 22 ++ .../geeksville/mesh/service/MeshService.kt | 13 +- core/data/build.gradle.kts | 3 + .../data/datasource/NodeInfoReadDataSource.kt | 41 ++++ .../datasource/NodeInfoWriteDataSource.kt | 40 ++++ .../SwitchingNodeInfoReadDataSource.kt | 60 +++++ .../SwitchingNodeInfoWriteDataSource.kt | 60 +++++ .../core/data/di/NodeDataSourceModule.kt | 38 +++ .../core/data/repository/MeshLogRepository.kt | 38 +-- .../core/data/repository/NodeRepository.kt | 55 +++-- .../core/data/repository/PacketRepository.kt | 87 ++++--- .../repository/QuickChatActionRepository.kt | 23 +- .../core/database/DatabaseManager.kt | 218 ++++++++++++++++++ core/strings/src/main/res/values/strings.xml | 2 + .../feature/settings/SettingsScreen.kt | 18 ++ .../feature/settings/SettingsViewModel.kt | 11 + 16 files changed, 643 insertions(+), 86 deletions(-) create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index e6a744cdd..11feaa972 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -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") diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index d7e3694dc..080903543 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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.") } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 20af2f624..1c0b72496 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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) diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt new file mode 100644 index 000000000..1e77cf25a --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt @@ -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 . + */ + +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 + + fun nodeDBbyNumFlow(): Flow> + + fun getNodesFlow( + sort: String, + filter: String, + includeUnknown: Boolean, + hopsAwayMax: Int, + lastHeardMin: Int, + ): Flow> + + suspend fun getNodesOlderThan(lastHeard: Int): List + + suspend fun getUnknownNodes(): List +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt new file mode 100644 index 000000000..e4f33ad95 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt @@ -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 . + */ + +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) + + suspend fun clearNodeDB() + + suspend fun deleteNode(num: Int) + + suspend fun deleteNodes(nodeNums: List) + + suspend fun deleteMetadata(num: Int) + + suspend fun upsert(metadata: MetadataEntity) + + suspend fun setNodeNotes(num: Int, notes: String) +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt new file mode 100644 index 000000000..622da459a --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt @@ -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 . + */ + +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 = + dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().getMyNodeInfo() } + + override fun nodeDBbyNumFlow(): Flow> = + dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().nodeDBbyNum() } + + override fun getNodesFlow( + sort: String, + filter: String, + includeUnknown: Boolean, + hopsAwayMax: Int, + lastHeardMin: Int, + ): Flow> = dbManager.currentDb.flatMapLatest { db -> + db.nodeInfoDao() + .getNodes( + sort = sort, + filter = filter, + includeUnknown = includeUnknown, + hopsAwayMax = hopsAwayMax, + lastHeardMin = lastHeardMin, + ) + } + + override suspend fun getNodesOlderThan(lastHeard: Int): List = + dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } + + override suspend fun getUnknownNodes(): List = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt new file mode 100644 index 000000000..76f9d04a2 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt @@ -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 . + */ + +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) = + 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) = + 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) } } +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt new file mode 100644 index 000000000..42a50e980 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt @@ -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 . + */ + +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 +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt index 901c76e9d..ad86d08bb 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt @@ -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, + private val dbManager: DatabaseManager, private val dispatchers: CoroutineDispatchers, ) { - private val meshLogDao by lazy { meshLogDaoLazy.get() } - fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = - 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> = - meshLogDao.getAllLogsInReceiveOrder(maxItems).flowOn(dispatchers.io).conflate() + fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = 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> = meshLogDao - .getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) + fun getTelemetryFrom(nodeNum: Int): Flow> = 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> = - meshLogDao.getLogsFrom(nodeNum, portNum, maxItem).distinctUntilChanged().flowOn(dispatchers.io) + ): Flow> = 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 diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt index 6aaad7f3b..56ac9e2e0 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt @@ -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 = - nodeInfoDao - .getMyNodeInfo() + nodeInfoReadDataSource + .myNodeInfoFlow() .flowOn(dispatchers.io) .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) @@ -69,12 +71,13 @@ constructor( val myId: StateFlow 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> = - 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) = - 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) = 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 = - withContext(dispatchers.io) { nodeInfoDao.getNodesOlderThan(lastHeard) } + withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard) } - suspend fun getUnknownNodes(): List = withContext(dispatchers.io) { nodeInfoDao.getUnknownNodes() } + suspend fun getUnknownNodes(): List = + 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 = - nodeInfoDao - .nodeDBbyNum() + nodeInfoReadDataSource + .nodeDBbyNumFlow() .mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } } .flowOn(dispatchers.io) .conflate() val totalNodeCount: Flow = - 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) } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index d39c36147..54a88d0e3 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -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) { - private val packetDao by lazy { packetDaoLazy.get() } +class PacketRepository +@Inject +constructor( + private val dbManager: DatabaseManager, + private val dispatchers: CoroutineDispatchers, +) { + fun getWaypoints(): Flow> = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllPackets(PortNum.WAYPOINT_APP_VALUE) } - fun getWaypoints(): Flow> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE) - - fun getContacts(): Flow> = packetDao.getContactKeys() + fun getContacts(): Flow> = + 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 = packetDao.getUnreadCountTotal() + fun getUnreadCountTotal(): Flow = + 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? = withContext(Dispatchers.IO) { packetDao.getQueuedPackets() } + suspend fun getQueuedPackets(): List? = + 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) = withContext(Dispatchers.IO) { - for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query - packetDao.deleteMessages(chunk) + suspend fun deleteMessages(uuidList: List) = 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) = - 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> = packetDao.getContactSettings() + fun getContactSettings(): Flow> = + 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, 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, newSettings: List) = - packetDao.migrateChannelsByPSK(oldSettings, newSettings) + withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings) + } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepository.kt index 2a4069d63..0d58d6b7f 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepository.kt @@ -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, + 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) + } } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt new file mode 100644 index 000000000..23b380a6c --- /dev/null +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -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 . + */ + +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 = _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(null) + val currentDb: StateFlow = + _currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName())) + + private val _currentAddress = MutableStateFlow(null) + val currentAddress: StateFlow = _currentAddress + + private val dbCache = mutableMapOf() // 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 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 { + 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() } diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml index 2bb9f0af7..0365fe458 100644 --- a/core/strings/src/main/res/values/strings.xml +++ b/core/strings/src/main/res/values/strings.xml @@ -499,6 +499,8 @@ Allow input source Send bell Messages + Device DB cache limit + Max device databases to keep on this phone Detection Sensor Config Detection Sensor enabled Minimum broadcast (seconds) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 891dcd778..aa9c8d893 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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 ?: "" diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 0dc5ceb4e..749b40d93 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -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 = nodeRepository.myNodeInfo @@ -106,6 +109,14 @@ constructor( val appVersionName get() = buildConfigProvider.versionName + // Device DB cache limit (bounded by DatabaseConstants) + val dbCacheLimit: StateFlow = 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) } }