feat(db): migrate to androidx.room3 3.0.2 with SQLCipherDriver (#994)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-27 13:47:32 +03:00
1 parent 680b4082ca
commit 4d9dfb99db
6 files changed
+153 -77

No files matched your search

+2 -3
View File
@@ -128,7 +128,7 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
} }
room { room3 {
schemaDirectory("$projectDir/schemas") schemaDirectory("$projectDir/schemas")
} }
@@ -180,9 +180,8 @@ dependencies {
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation("androidx.startup:startup-runtime:1.1.1") implementation("androidx.startup:startup-runtime:1.1.1")
// Local database (Room) — message persistence // Local database (Room 3) — message persistence
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.sqlcipher) implementation(libs.sqlcipher)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
@@ -1,9 +1,9 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import androidx.room.Dao import androidx.room3.Dao
import androidx.room.Insert import androidx.room3.Insert
import androidx.room.OnConflictStrategy import androidx.room3.OnConflictStrategy
import androidx.room.Query import androidx.room3.Query
@Dao @Dao
interface ChatMessageDao { interface ChatMessageDao {
@@ -1,9 +1,9 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import androidx.room.ColumnInfo import androidx.room3.ColumnInfo
import androidx.room.Entity import androidx.room3.Entity
import androidx.room.Index import androidx.room3.Index
import androidx.room.PrimaryKey import androidx.room3.PrimaryKey
/** /**
* Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage] * Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage]
@@ -1,12 +1,13 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room3.Database
import androidx.room.Room import androidx.room3.Room
import androidx.room.RoomDatabase import androidx.room3.RoomDatabase
import androidx.room.migration.Migration import androidx.room3.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.SQLiteConnection
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import androidx.sqlite.execSQL
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import java.io.File import java.io.File
@Database( @Database(
@@ -21,6 +22,51 @@ abstract class HermesDatabase : RoomDatabase() {
@Volatile @Volatile
private var instance: HermesDatabase? = null private var instance: HermesDatabase? = null
val MIGRATION_2_3: Migration =
object : Migration(2, 3) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
val MIGRATION_3_4: Migration =
object : Migration(3, 4) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `reasoning_text` TEXT NOT NULL DEFAULT ''",
)
}
}
val MIGRATION_4_5: Migration =
object : Migration(4, 5) {
override suspend fun migrate(connection: SQLiteConnection) {
// Issue #842: tool rows now carry the gateway's
// tool call id (`call_00_...`) so REST transcript
// rows can be matched 1:1 against their live WS
// bubbles instead of fragile result-content
// canonicalization.
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `tool_call_id` TEXT NOT NULL DEFAULT ''",
)
}
}
val MIGRATION_5_6: Migration =
object : Migration(5, 6) {
override suspend fun migrate(connection: SQLiteConnection) {
// Issue #904: timeline markers (display_kind) keep
// their tag through the Room cache so a cached
// marker never degrades back into a user bubble.
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT",
)
}
}
fun get(context: Context): HermesDatabase = fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
// SQLCipher can't open plaintext SQLite databases — if an old // SQLCipher can't open plaintext SQLite databases — if an old
@@ -31,68 +77,27 @@ abstract class HermesDatabase : RoomDatabase() {
dbFile.delete() dbFile.delete()
} }
// Load SQLCipher native library before creating the factory // Load SQLCipher native library before creating the driver
System.loadLibrary("sqlcipher") System.loadLibrary("sqlcipher")
val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword()) val driver =
SQLCipherDriver(
val migration2to3 = AuthManager.getDatabasePassword(),
object : Migration(2, 3) { null,
override fun migrate(db: SupportSQLiteDatabase) { null,
db.execSQL( )
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
val migration3to4 =
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `reasoning_text` TEXT NOT NULL DEFAULT ''",
)
}
}
val migration4to5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// Issue #842: tool rows now carry the gateway's
// tool call id (`call_00_...`) so REST transcript
// rows can be matched 1:1 against their live WS
// bubbles instead of fragile result-content
// canonicalization.
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `tool_call_id` TEXT NOT NULL DEFAULT ''",
)
}
}
val migration5to6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Issue #904: timeline markers (display_kind) keep
// their tag through the Room cache so a cached
// marker never degrades back into a user bubble.
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT",
)
}
}
instance ?: Room instance ?: Room
.databaseBuilder( .databaseBuilder(
context.applicationContext, context.applicationContext,
HermesDatabase::class.java, HermesDatabase::class.java,
"hermes_control.db", "hermes_control.db",
).openHelperFactory(factory) ).setDriver(driver)
.addMigrations( .addMigrations(
migration2to3, MIGRATION_2_3,
migration3to4, MIGRATION_3_4,
migration4to5, MIGRATION_4_5,
migration5to6, MIGRATION_5_6,
) ).fallbackToDestructiveMigration(false)
.fallbackToDestructiveMigration(false)
.build() .build()
.also { instance = it } .also { instance = it }
} }
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.data.local
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class HermesDatabaseMigrationsTest {
@Before
fun setUp() {
mockkStatic("androidx.sqlite.SQLite")
}
@After
fun tearDown() {
unmockkStatic("androidx.sqlite.SQLite")
}
@Test
fun migration2to3_executesIndexCreationOnSQLiteConnection() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_2_3.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
@Test
fun migration3to4_executesReasoningColumnAdditionOnSQLiteConnection() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_3_4.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `reasoning_text` TEXT NOT NULL DEFAULT ''",
)
}
}
@Test
fun migration4to5_executesToolCallIdColumnAdditionOnSQLiteConnection() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_4_5.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `tool_call_id` TEXT NOT NULL DEFAULT ''",
)
}
}
@Test
fun migration5to6_executesDisplayKindColumnAdditionOnSQLiteConnection() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_5_6.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT",
)
}
}
}
+5 -6
View File
@@ -26,7 +26,7 @@ securityCrypto = "1.0.0"
mockk = "1.14.11" mockk = "1.14.11"
turbine = "1.2.1" turbine = "1.2.1"
coil = "2.7.0" coil = "2.7.0"
room = "2.7.1" room = "3.0.2"
sqlcipher = "4.18.0" sqlcipher = "4.18.0"
datastore = "1.1.2" datastore = "1.1.2"
latex = "1.5.3" latex = "1.5.3"
@@ -76,10 +76,9 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
# Database (Room) # Database (Room 3)
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" }
# Native Compose LaTeX rendering # Native Compose LaTeX rendering
@@ -95,5 +94,5 @@ coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
room = { id = "androidx.room", version.ref = "room" } room = { id = "androidx.room3", version.ref = "room" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }