chore: Upgrade to Room 3.0 and refactor related components

This commit is contained in:
James Rich 2026-03-20 15:25:50 -05:00
parent 785b698542
commit ef8c5878ff
13 changed files with 600 additions and 0 deletions

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room3.Room
import androidx.room3.RoomDatabase
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
/** Returns a [RoomDatabase.Builder] configured for Android with the given [dbName]. */
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
val app = ContextServices.app
val dbFile = app.getDatabasePath(dbName)
return Room.databaseBuilder<MeshtasticDatabase>(
context = app.applicationContext,
name = dbFile.absolutePath,
factory = { MeshtasticDatabaseConstructor.initialize() }
).configureCommon()
}
/** Returns the Android directory where database files are stored. */
actual fun getDatabaseDirectory(): Path {
val app = ContextServices.app
return app.getDatabasePath("").parentFile!!.absolutePath.toPath()
}
/** Deletes the Android database using the platform-specific deleteDatabase helper. */
actual fun deleteDatabase(dbName: String) {
ContextServices.app.deleteDatabase(dbName)
}
/** Returns the system FileSystem for Android. */
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
/** Creates an Android DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { ContextServices.app.preferencesDataStoreFile(name) }
)

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.room3.RoomDatabase
import okio.FileSystem
import okio.Path
/** Returns a [RoomDatabase.Builder] configured for the current platform with the given [dbName]. */
expect fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase>
/** Returns the platform-specific directory where database files are stored. */
expect fun getDatabaseDirectory(): Path
/** Deletes the database with the given [dbName] and its associated files (e.g., -wal, -shm). */
expect fun deleteDatabase(dbName: String)
/** Returns the [FileSystem] to use for database file operations. */
expect fun getFileSystem(): FileSystem
/** Creates a platform-specific [DataStore] for database-related preferences. */
expect fun createDatabaseDataStore(name: String): DataStore<Preferences>

View file

@ -0,0 +1,233 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.booleanPreferencesKey
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class])
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
open class DatabaseManager(
@Named("DatabaseDataStore") private val datastore: DataStore<Preferences>,
private val dispatchers: CoroutineDispatchers
) :
DatabaseProvider,
SharedDatabaseManager {
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val mutex = Mutex()
private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY)
private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
// Expose the DB cache limit as a reactive stream so UI can observe changes.
override val cacheLimit: StateFlow<Int> = datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
.stateIn(managerScope, SharingStarted.Eagerly, DatabaseConstants.DEFAULT_CACHE_LIMIT)
override fun getCurrentCacheLimit(): Int = cacheLimit.value
override fun setCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
managerScope.launch {
datastore.edit { it[cacheLimitKey] = clamped }
// Enforce asynchronously with current active DB protected
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) }
?: DatabaseConstants.DEFAULT_DB_NAME
enforceCacheLimit(activeDbName = active)
}
}
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
override val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb.filterNotNull().stateIn(
managerScope,
SharingStarted.Eagerly,
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build()
)
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. */
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
val dbName = buildDbName(address)
// Remember the previously active DB name (any) so we can record its last-used time as well.
val previousDbName = _currentDb.value?.let { buildDbName(_currentAddress.value) }
// Fast path: no-op if already on this address
if (_currentAddress.value == address && _currentDb.value != null) {
markLastUsed(dbName)
return@withLock
}
// Build/open Room DB off the main thread
val db =
dbCache[dbName]
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
_currentDb.value = db
_currentAddress.value = address
markLastUsed(dbName)
// Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp
previousDbName?.let { markLastUsed(it) }
// Defer LRU eviction so switch is not blocked by filesystem work
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = dbName) }
// One-time cleanup: remove legacy DB if present and not active
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
private val limitedIo = dispatchers.io.limitedParallelism(4)
/** Execute [block] with the current DB instance. */
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)
block(db)
}
/** Returns true if a database exists for the given device address. */
override fun hasDatabaseFor(address: String?): Boolean {
if (address.isNullOrBlank() || address == "n") return false
val dbName = buildDbName(address)
val path = getDatabaseDirectory().resolve("$dbName.db")
return getFileSystem().exists(path)
}
private fun markLastUsed(dbName: String) {
managerScope.launch {
datastore.edit { it[lastUsedKey(dbName)] = nowMillis }
}
}
private suspend fun lastUsed(dbName: String): Long {
val key = lastUsedKey(dbName)
val v = datastore.data.first()[key] ?: 0L
return if (v == 0L) {
val path = getDatabaseDirectory().resolve("$dbName.db")
getFileSystem().metadataOrNull(path)?.lastModifiedAtMillis ?: 0L
} else {
v
}
}
private fun listExistingDbNames(): List<String> {
val dir = getDatabaseDirectory()
val fs = getFileSystem()
if (!fs.exists(dir)) return emptyList()
return fs.list(dir).map { it.name }
.filter { it.startsWith(DatabaseConstants.DB_PREFIX) }
.filter { it.endsWith(".db") }
.map { it.removeSuffix(".db") }
.distinct()
}
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
val limit = getCurrentCacheLimit()
val all = listExistingDbNames()
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
val deviceDbs =
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
if (deviceDbs.size <= limit) return@withLock
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
victims.forEach { name ->
runCatching {
dbCache.remove(name)?.close()
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
}.onFailure { Logger.w(it) { "Failed to evict database $name" } }
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
}
}
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
val cleaned = datastore.data.first()[legacyCleanedKey] ?: false
if (cleaned) return@withLock
val legacy = DatabaseConstants.LEGACY_DB_NAME
if (legacy == activeDbName) {
datastore.edit { it[legacyCleanedKey] = true }
return@withLock
}
val dir = getDatabaseDirectory()
val fs = getFileSystem()
val legacyPath = dir.resolve("$legacy.db")
if (fs.exists(legacyPath)) {
runCatching {
dbCache.remove(legacy)?.close()
deleteDatabase(legacy)
}.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
}
datastore.edit { it[legacyCleanedKey] = true }
}
/** Closes all open databases and cancels background work. */
fun close() {
managerScope.cancel()
dbCache.values.forEach { it.close() }
dbCache.clear()
_currentDb.value = null
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
/** Returns a [RoomDatabase.Builder] configured for iOS with the given [dbName]. */
@OptIn(ExperimentalForeignApi::class)
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
val dbFilePath = documentDirectory() + "/$dbName.db"
return Room.databaseBuilder<MeshtasticDatabase>(
name = dbFilePath,
factory = { MeshtasticDatabaseConstructor.initialize() }
).configureCommon()
}
/** Returns the iOS directory where database files are stored. */
actual fun getDatabaseDirectory(): Path = documentDirectory().toPath()
/** Deletes the database and its Room-associated files on iOS. */
actual fun deleteDatabase(dbName: String) {
val dir = documentDirectory()
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db", null)
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-wal", null)
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-shm", null)
}
/** Returns the system FileSystem for iOS. */
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
/** Creates an iOS DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = documentDirectory() + "/datastore"
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
return PreferenceDataStoreFactory.create(
produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() }
)
}
@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
return requireNotNull(documentDirectory?.path)
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
import java.io.File
/**
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
*/
private fun desktopDataDir(): String {
val override = System.getenv("MESHTASTIC_DATA_DIR")
if (!override.isNullOrBlank()) return override
return System.getProperty("user.home") + "/.meshtastic"
}
/** Returns a [RoomDatabase.Builder] configured for JVM/Desktop with the given [dbName]. */
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
val dbFile = File(desktopDataDir(), "$dbName.db")
dbFile.parentFile?.mkdirs()
return Room.databaseBuilder<MeshtasticDatabase>(
name = dbFile.absolutePath,
factory = { MeshtasticDatabaseConstructor.initialize() }
).configureCommon()
}
/** Returns the JVM/Desktop directory where database files are stored. */
actual fun getDatabaseDirectory(): Path = desktopDataDir().toPath()
/** Deletes the database and its Room-associated files on JVM. */
actual fun deleteDatabase(dbName: String) {
val dir = desktopDataDir()
File(dir, "$dbName.db").delete()
File(dir, "$dbName.db-wal").delete()
File(dir, "$dbName.db-shm").delete()
}
/** Returns the system FileSystem for JVM. */
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
/** Creates a JVM DataStore for database preferences in the data directory. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
File(dir).mkdirs()
return PreferenceDataStoreFactory.create(
produceFile = { File(dir, "$name.preferences_pb") }
)
}