From 4d9dfb99dbae1bf476c3d8d70a05d09149c4203f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:47:32 +0300 Subject: [PATCH] feat(db): migrate to androidx.room3 3.0.2 with SQLCipherDriver (#994) Co-authored-by: M57 --- app/build.gradle.kts | 5 +- .../data/local/ChatMessageDao.kt | 8 +- .../data/local/ChatMessageEntity.kt | 8 +- .../data/local/HermesDatabase.kt | 125 +++++++++--------- .../local/HermesDatabaseMigrationsTest.kt | 73 ++++++++++ gradle/libs.versions.toml | 11 +- 6 files changed, 153 insertions(+), 77 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55d12195..3d2b6043 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -128,7 +128,7 @@ kotlin { jvmToolchain(21) } -room { +room3 { schemaDirectory("$projectDir/schemas") } @@ -180,9 +180,8 @@ dependencies { implementation(libs.androidx.datastore) 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.ktx) implementation(libs.sqlcipher) ksp(libs.androidx.room.compiler) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt index 733f54c2..9c110c13 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -1,9 +1,9 @@ package com.m57.hermescontrol.data.local -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 @Dao interface ChatMessageDao { diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index aa2e4c1e..dcfebf18 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -1,9 +1,9 @@ package com.m57.hermescontrol.data.local -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 /** * Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage] diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index bd366f91..48b4b922 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -1,12 +1,13 @@ package com.m57.hermescontrol.data.local import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import androidx.room3.Database +import androidx.room3.Room +import androidx.room3.RoomDatabase +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import net.zetetic.database.sqlcipher.driver.SQLCipherDriver import java.io.File @Database( @@ -21,6 +22,51 @@ abstract class HermesDatabase : RoomDatabase() { @Volatile 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 = instance ?: synchronized(this) { // SQLCipher can't open plaintext SQLite databases — if an old @@ -31,68 +77,27 @@ abstract class HermesDatabase : RoomDatabase() { dbFile.delete() } - // Load SQLCipher native library before creating the factory + // Load SQLCipher native library before creating the driver System.loadLibrary("sqlcipher") - val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword()) - - val migration2to3 = - object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - 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", - ) - } - } + val driver = + SQLCipherDriver( + AuthManager.getDatabasePassword(), + null, + null, + ) instance ?: Room .databaseBuilder( context.applicationContext, HermesDatabase::class.java, "hermes_control.db", - ).openHelperFactory(factory) + ).setDriver(driver) .addMigrations( - migration2to3, - migration3to4, - migration4to5, - migration5to6, - ) - .fallbackToDestructiveMigration(false) + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + ).fallbackToDestructiveMigration(false) .build() .also { instance = it } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt new file mode 100644 index 00000000..35453938 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt @@ -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(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(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(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(relaxed = true) + HermesDatabase.MIGRATION_5_6.migrate(connection) + verify(exactly = 1) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT", + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f07e15a..88a795f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ securityCrypto = "1.0.0" mockk = "1.14.11" turbine = "1.2.1" coil = "2.7.0" -room = "2.7.1" +room = "3.0.2" sqlcipher = "4.18.0" datastore = "1.1.2" 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-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } -# Database (Room) -androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +# Database (Room 3) +androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } # 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" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", 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" }