feat: Migrate to Room 3.0 and update related documentation and tracks (#4865)

This commit is contained in:
James Rich 2026-03-20 16:40:08 -05:00 committed by GitHub
parent 6cdd10d936
commit c4087c2ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1097 additions and 921 deletions

View file

@ -16,8 +16,8 @@
*/
package org.meshtastic.core.database
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.room3.Room
import androidx.room3.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule

View file

@ -16,7 +16,7 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first

View file

@ -16,7 +16,7 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first

View file

@ -16,7 +16,7 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first

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("dummy.db").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

@ -16,9 +16,6 @@
*/
package org.meshtastic.core.database.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.core.database")
class CoreDatabaseAndroidModule
@Module class CoreDatabaseAndroidModule

View file

@ -16,7 +16,7 @@
*/
package org.meshtastic.core.database
import androidx.room.TypeConverter
import androidx.room3.TypeConverter
import co.touchlab.kermit.Logger
import kotlinx.serialization.json.Json
import okio.ByteString

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

@ -16,10 +16,12 @@
*/
package org.meshtastic.core.database
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.room.Room
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -29,49 +31,65 @@ 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.database.MeshtasticDatabase.Companion.configureCommon
import org.meshtastic.core.di.CoroutineDispatchers
import java.io.File
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class])
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
open class DatabaseManager(private val app: Application, private val dispatchers: CoroutineDispatchers) :
DatabaseProvider,
open class DatabaseManager(
@Named("DatabaseDataStore") private val datastore: DataStore<Preferences>,
private val dispatchers: CoroutineDispatchers,
) : DatabaseProvider,
SharedDatabaseManager {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
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.
private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit())
override val cacheLimit: StateFlow<Int> = _cacheLimit
override val cacheLimit: StateFlow<Int> =
datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
.stateIn(managerScope, SharingStarted.Eagerly, DatabaseConstants.DEFAULT_CACHE_LIMIT)
// 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 = getCurrentCacheLimit()
}
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)
}
init {
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
}
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
override val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName()))
_currentDb
.filterNotNull()
.stateIn(
managerScope,
SharingStarted.Eagerly,
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
)
private val _currentAddress = MutableStateFlow<String?>(null)
val currentAddress: StateFlow<String?> = _currentAddress
@ -99,13 +117,12 @@ open class DatabaseManager(private val app: Application, private val dispatchers
// Build/open Room DB off the main thread
val db =
dbCache[dbName]
?: withContext(dispatchers.io) { buildRoomDb(app, dbName) }.also { dbCache[dbName] = it }
?: 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
// even on first run after upgrade where no timestamp might exist yet.
previousDbName?.let { markLastUsed(it) }
// Defer LRU eviction so switch is not blocked by filesystem work
@ -131,26 +148,35 @@ open class DatabaseManager(private val app: Application, private val dispatchers
override fun hasDatabaseFor(address: String?): Boolean {
if (address.isNullOrBlank() || address == "n") return false
val dbName = buildDbName(address)
return getDbFile(app, dbName) != null
val path = getDatabaseDirectory().resolve("$dbName.db")
return getFileSystem().exists(path)
}
private fun markLastUsed(dbName: String) {
prefs.edit().putLong(lastUsedKey(dbName), nowMillis).apply()
managerScope.launch { datastore.edit { it[lastUsedKey(dbName)] = nowMillis } }
}
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 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 base = app.getDatabasePath(DatabaseConstants.LEGACY_DB_NAME)
val dir = base.parentFile ?: return emptyList()
val names = dir.listFiles()?.mapNotNull { f -> f.name } ?: emptyList()
return names
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) }
.filterNot { it.endsWith("-wal") || it.endsWith("-shm") }
.filter { it.endsWith(".db") }
.map { it.removeSuffix(".db") }
.distinct()
}
@ -160,65 +186,45 @@ open class DatabaseManager(private val app: Application, private val dispatchers
// 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 }
Logger.d {
"LRU check: limit=$limit, active=${anonymizeDbName(
activeDbName,
)}, deviceDbs=${deviceDbs.joinToString(", ") {
anonymizeDbName(it)
}}"
}
if (deviceDbs.size <= limit) return@withLock
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
Logger.d {
"LRU lastUsed(ms): ${usageSnapshot.entries.joinToString(", ") { (name, ts) ->
"${anonymizeDbName(name)}=$ts"
}}"
}
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
Logger.i { "LRU victims: ${victims.joinToString(", ") { anonymizeDbName(it) }}" }
victims.forEach { name ->
runCatching { dbCache.remove(name)?.close() }
.onFailure { Logger.w(it) { "Failed to close database $name" } }
app.deleteDatabase(name)
prefs.edit().remove(lastUsedKey(name)).apply()
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)}" }
}
}
override fun getCurrentCacheLimit(): Int = prefs
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
override fun setCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
if (clamped == getCurrentCacheLimit()) return
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
_cacheLimit.value = clamped
// Enforce asynchronously with current active DB protected
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) } ?: defaultDbName()
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = active) }
}
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
if (prefs.getBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, false)) return@withLock
val cleaned = datastore.data.first()[legacyCleanedKey] ?: false
if (cleaned) return@withLock
val legacy = DatabaseConstants.LEGACY_DB_NAME
if (legacy == activeDbName) {
// Never delete the active DB; mark as cleaned to avoid repeated checks
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
datastore.edit { it[legacyCleanedKey] = true }
return@withLock
}
val legacyFile = getDbFile(app, legacy)
if (legacyFile != null) {
runCatching { dbCache.remove(legacy)?.close() }
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
val deleted = app.deleteDatabase(legacy)
if (deleted) {
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
} else {
Logger.w { "Attempted to delete legacy DB $legacy but deleteDatabase returned false" }
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)}" }
}
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
datastore.edit { it[legacyCleanedKey] = true }
}
/** Closes all open databases and cancels background work. */
@ -229,19 +235,3 @@ open class DatabaseManager(private val app: Application, private val dispatchers
_currentDb.value = null
}
}
// File-private helpers
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
private fun lastUsedKey(dbName: String) = "db_last_used:$dbName"
private fun buildRoomDb(app: Application, dbName: String): MeshtasticDatabase =
Room.databaseBuilder<MeshtasticDatabase>(
context = app.applicationContext,
name = app.getDatabasePath(dbName).absolutePath,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
.build()
private fun getDbFile(app: Application, dbName: String): File? = app.getDatabasePath(dbName).takeIf { it.exists() }

View file

@ -16,13 +16,13 @@
*/
package org.meshtastic.core.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.room3.AutoMigration
import androidx.room3.Database
import androidx.room3.DeleteColumn
import androidx.room3.DeleteTable
import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import org.meshtastic.core.database.dao.DeviceHardwareDao
@ -99,8 +99,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
version = 37,
exportSchema = true,
)
@androidx.room.ConstructedBy(MeshtasticDatabaseConstructor::class)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@TypeConverters(Converters::class)
@androidx.room3.DaoReturnTypeConverters(androidx.room3.paging.PagingSourceDaoReturnTypeConverter::class)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao

View file

@ -16,7 +16,7 @@
*/
package org.meshtastic.core.database
import androidx.room.RoomDatabaseConstructor
import androidx.room3.RoomDatabaseConstructor
@Suppress("NO_ACTUAL_FOR_EXPECT", "KotlinNoActualForExpect")
expect object MeshtasticDatabaseConstructor : RoomDatabaseConstructor<MeshtasticDatabase> {

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType

View file

@ -16,9 +16,9 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.MeshLog

View file

@ -16,13 +16,13 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.MapColumn
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.database.entity.MetadataEntity

View file

@ -17,12 +17,12 @@
package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import androidx.room3.Dao
import androidx.room3.MapColumn
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.common.util.nowMillis

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.QuickChatAction

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity

View file

@ -18,7 +18,14 @@ package org.meshtastic.core.database.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.database.createDatabaseDataStore
@Module
@ComponentScan("org.meshtastic.core.database")
class CoreDatabaseModule
class CoreDatabaseModule {
@Single
@Named("DatabaseDataStore")
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
}

View file

@ -16,9 +16,9 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DeviceHardware

View file

@ -16,9 +16,9 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DeviceVersion

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.Index
import androidx.room3.PrimaryKey
import co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.FromRadio

View file

@ -16,8 +16,8 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import org.meshtastic.core.model.MyNodeInfo
@Entity(tableName = "my_node")

View file

@ -16,12 +16,12 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import androidx.room3.ColumnInfo
import androidx.room3.Embedded
import androidx.room3.Entity
import androidx.room3.Index
import androidx.room3.PrimaryKey
import androidx.room3.Relation
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis

View file

@ -16,12 +16,12 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import androidx.room3.ColumnInfo
import androidx.room3.Embedded
import androidx.room3.Entity
import androidx.room3.Index
import androidx.room3.PrimaryKey
import androidx.room3.Relation
import okio.ByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket

View file

@ -16,9 +16,9 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
@Entity(tableName = "quick_chat")
data class QuickChatAction(

View file

@ -16,10 +16,10 @@
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import org.meshtastic.proto.Position
@Entity(

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,70 @@
/*
* 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") })
}