refactor: migrate :core:database to Room Kotlin Multiplatform (#4702)

This commit is contained in:
James Rich 2026-03-03 20:44:34 -06:00 committed by GitHub
parent 744db2d5bd
commit 6a858acb4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 406 additions and 264 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.room.Room
@ -24,6 +23,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
import java.io.IOException
@RunWith(AndroidJUnit4::class)
@ -40,17 +40,23 @@ class MeshtasticDatabaseTest {
@Test
@Throws(IOException::class)
fun migrateAll() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
// Create earliest version of the database.
helper.createDatabase(TEST_DB, 3).apply { close() }
// Open latest version of the database. Room validates the schema
// once all migrations execute.
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
MeshtasticDatabase::class.java,
TEST_DB,
Room.databaseBuilder<MeshtasticDatabase>(
context = context,
name = context.getDatabasePath(TEST_DB).absolutePath,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
.build()
.apply { openHelper.writableDatabase.close() }
.apply {
openHelper.writableDatabase
close()
}
}
}

View file

@ -32,6 +32,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Node
@ -197,7 +198,12 @@ class NodeInfoDaoTest {
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build()
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao()
nodeInfoDao.apply {

View file

@ -33,6 +33,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
@ -82,7 +83,12 @@ class PacketDaoTest {
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build()
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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 org.junit.Assert.assertEquals

View file

@ -17,8 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
@ -29,13 +29,16 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class MigrationTest {
private lateinit var database: MeshtasticDatabase
private lateinit var packetDao: PacketDao
@ -57,8 +60,13 @@ class MigrationTest {
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build()
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao = database.packetDao()
}

View file

@ -20,7 +20,6 @@ import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.room.Room
import androidx.room.RoomDatabase
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -36,9 +35,9 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
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 java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager
@ -93,7 +92,7 @@ constructor(
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?.openHelper?.databaseName
val previousDbName = _currentDb.value?.let { buildDbName(_currentAddress.value) }
// Fast path: no-op if already on this address
if (_currentAddress.value == address && _currentDb.value != null) {
@ -126,9 +125,9 @@ constructor(
/** Execute [block] with the current DB instance. */
suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
val active = _currentDb.value?.openHelper?.databaseName ?: return@withContext null
val db = _currentDb.value ?: return@withContext null
val active = buildDbName(_currentAddress.value)
markLastUsed(active)
val db = _currentDb.value ?: return@withContext null // Use the cached current DB
block(db)
}
@ -200,7 +199,7 @@ constructor(
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()
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) } ?: defaultDbName()
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = active) }
}
@ -235,113 +234,18 @@ constructor(
}
}
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
const val LEGACY_DB_CLEANED_KEY: String = "legacy_db_cleaned"
// 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)
// File-private helpers
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
private fun normalizeAddress(addr: String?): String {
val u = addr?.trim()?.uppercase()
val normalized =
when {
u.isNullOrBlank() -> "DEFAULT"
u == "N" || u == "NULL" -> "DEFAULT"
else -> u.replace(":", "")
}
return normalized
}
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)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.fallbackToDestructiveMigration(false)
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() }
/**
* Compute which DBs to evict using LRU policy.
*
* Rules:
* - Only consider device-specific DBs (exclude legacy and default)
* - Never evict the active DB
* - If number of device DBs is within the limit, evict none
* - Otherwise evict the (size - limit) least-recently-used DBs
*
* Pass a precomputed [lastUsedMsByDb] snapshot to avoid redundant IO/lookups.
*/
internal fun selectEvictionVictims(
dbNames: List<String>,
activeDbName: String,
limit: Int,
lastUsedMsByDb: Map<String, Long>,
): List<String> {
val deviceDbNames =
dbNames.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
val victims =
if (limit < 1 || deviceDbNames.size <= limit) {
emptyList()
} else {
val candidates = deviceDbNames.filter { it != activeDbName }
if (candidates.isEmpty()) {
emptyList()
} else {
val toEvict = deviceDbNames.size - limit
candidates.sortedBy { lastUsedMsByDb[it] ?: 0L }.take(toEvict)
}
}
return victims
}

View file

@ -0,0 +1,104 @@
/*
* 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 okio.ByteString.Companion.encodeUtf8
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
const val LEGACY_DB_CLEANED_KEY: String = "legacy_db_cleaned"
// 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
}
fun normalizeAddress(addr: String?): String {
val u = addr?.trim()?.uppercase()
val normalized =
when {
u.isNullOrBlank() -> "DEFAULT"
u == "N" || u == "NULL" -> "DEFAULT"
else -> u.replace(":", "")
}
return normalized
}
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
DatabaseConstants.DEFAULT_DB_NAME
} else {
"${DatabaseConstants.DB_PREFIX}_${shortSha1(normalizeAddress(address))}"
}
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)
}
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,
) + ""
}
/** Compute which DBs to evict using LRU policy. */
internal fun selectEvictionVictims(
dbNames: List<String>,
activeDbName: String,
limit: Int,
lastUsedMsByDb: Map<String, Long>,
): List<String> {
val deviceDbNames =
dbNames.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
val victims =
if (limit < 1 || deviceDbNames.size <= limit) {
emptyList()
} else {
val candidates = deviceDbNames.filter { it != activeDbName }
if (candidates.isEmpty()) {
emptyList()
} else {
val toEvict = deviceDbNames.size - limit
candidates.sortedBy { lastUsedMsByDb[it] ?: 0L }.take(toEvict)
}
}
return victims
}

View file

@ -16,15 +16,15 @@
*/
package org.meshtastic.core.database
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
@ -99,6 +99,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
version = 37,
exportSchema = true,
)
@androidx.room.ConstructedBy(MeshtasticDatabaseConstructor::class)
@TypeConverters(Converters::class)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao
@ -116,11 +117,11 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase =
Room.databaseBuilder(context.applicationContext, MeshtasticDatabase::class.java, "meshtastic_database")
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.fallbackToDestructiveMigration(false)
.build()
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.room.RoomDatabaseConstructor
@Suppress("NO_ACTUAL_FOR_EXPECT", "KotlinNoActualForExpect")
expect object MeshtasticDatabaseConstructor : RoomDatabaseConstructor<MeshtasticDatabase> {
override fun initialize(): MeshtasticDatabase
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.dao
import androidx.room.Dao

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.dao
import androidx.room.Dao

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.entity
import androidx.room.ColumnInfo

View file

@ -1,68 +0,0 @@
/*
* 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.di
import android.app.Application
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.database.dao.PacketDao
import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
abstract class DatabaseModule {
@Binds
@Singleton
abstract fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
companion object {
@Provides
@Singleton
fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app)
@Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao()
@Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao()
@Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao()
@Provides
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao()
@Provides
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao()
@Provides
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao()
@Provides
fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao =
database.tracerouteNodePositionDao()
}
}