Run the database migrations again when a database is upgraded

Upgrading over an older install crashed before the login screen with
`SQLiteException: no such column: SessionData.position`. The session, cache
and push databases were opened at their new schema version but their tables
were never migrated.

`SqlCipherDriverFactory` overrides `AndroidSqliteDriver.Callback.onUpgrade` so
the database key can be replaced, and never called through to the base
implementation - which is the only thing that runs `schema.migrate`. Every
SQLDelight migration in the app was skipped as a result.

The override now calls `super.onUpgrade` after the re-key, so a database that
crosses the re-key version is still re-encrypted before its tables are
migrated. It moved out of an anonymous object into a named `MigrationCallback`
so the behaviour can be tested.

Part of #7509
This commit is contained in:
hayaksi1
2026-08-25 21:41:17 +03:00
parent a2d3488426
commit 362acd6cab
3 changed files with 82 additions and 7 deletions
+4
View File
@@ -5,6 +5,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
import extension.testCommonDependencies
plugins {
id("io.element.android-library")
}
@@ -27,4 +29,6 @@ dependencies {
implementation(libs.google.tink)
implementation(projects.libraries.androidutils)
testCommonDependencies(libs)
}
@@ -51,12 +51,28 @@ class SqlCipherDriverFactory(
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
}
val factory = SupportOpenHelperFactory(key)
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
schema
) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
}
})
return AndroidSqliteDriver(
schema = schema,
context = context,
name = name,
factory = factory,
callback = MigrationCallback(schema, onUpgradeCallback),
)
}
}
/**
* Runs [onUpgradeCallback] and then the schema migrations themselves.
*
* @param schema The SQLite DB schema, whose migrations are applied by [AndroidSqliteDriver.Callback.onUpgrade].
* @param onUpgradeCallback Called before the migrations run, so that the database key can be replaced first.
*/
internal class MigrationCallback(
schema: SqlSchema<QueryResult.Value<Unit>>,
private val onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)?,
) : AndroidSqliteDriver.Callback(schema) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
super.onUpgrade(db, oldVersion, newVersion)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.encrypteddb
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import org.junit.Test
class MigrationCallbackTest {
@Test
fun `onUpgrade migrates the schema`() {
val schema = RecordingSchema()
MigrationCallback(schema, onUpgradeCallback = null).onUpgrade(mockk(relaxed = true), 9, 11)
assertThat(schema.migrations).containsExactly(9L to 11L)
}
@Test
fun `onUpgrade replaces the database key before migrating the schema`() {
val order = mutableListOf<String>()
val schema = RecordingSchema(onMigrate = { order.add("migrate") })
MigrationCallback(schema, onUpgradeCallback = { _, _, _ -> order.add("rekey") })
.onUpgrade(mockk(relaxed = true), 9, 11)
assertThat(order).containsExactly("rekey", "migrate").inOrder()
}
}
private class RecordingSchema(
private val onMigrate: () -> Unit = {},
) : SqlSchema<QueryResult.Value<Unit>> {
val migrations = mutableListOf<Pair<Long, Long>>()
override val version = 11L
override fun create(driver: SqlDriver) = QueryResult.Unit
override fun migrate(
driver: SqlDriver,
oldVersion: Long,
newVersion: Long,
vararg callbacks: AfterVersion,
): QueryResult.Value<Unit> {
migrations.add(oldVersion to newVersion)
onMigrate()
return QueryResult.Unit
}
}