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:
1 parent
680b4082ca
commit
4d9dfb99db
6 files changed
+153
-77
No files matched your search
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
Reference in new issue
Block a user