docs/developer/preference-migration-guide.md
This document explains how to update and migrate preferences in Thunderbird for Android.
Thunderbird for Android uses a dedicated SQLite database to store its preferences as key-value pairs.
Both the database file and the primary table are named preferences_storage. This is managed by the
K9StoragePersister.
preferences_storagepreferences_storageprimkey (TEXT), value (TEXT)legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.javalegacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.ktYou must implement a preference migration whenever you:
If you are adding a completely new preference, you don't necessarily need a migration unless you need to populate it with a default value that depends on other existing settings.
To add a new preference:
GeneralSettingsManager or a specific preference manager to read and write the value.Storage system directly, use StorageEditor to save the value.[!NOTE] If the new preference requires a default value that depends on existing settings, you must follow the migration guide below and bump the database version.
val editor = preferences.createStorageEditor()
editor.putBoolean("my_new_preference", true)
editor.commit()
The DB_VERSION constant in K9StoragePersister.java must be incremented by exactly 1. This version increase is what triggers the migration process.
// legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
public class K9StoragePersister implements StoragePersister {
private static final int DB_VERSION = 29; // Increment this
// ...
}
Create a new Kotlin class in legacy/storage/src/main/java/com/fsck/k9/preferences/migration/ named StorageMigrationToXX.kt, where XX is the new version number.
Use the StorageMigrationHelper to interact with the database.
/**
* Describe the purpose of the migration here.
*/
class StorageMigrationToXX(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun runMigration() {
val oldValue = migrationsHelper.readValue(db, "old_key")
if (oldValue != null) {
// Perform transformation if needed
migrationsHelper.insertValue(db, "new_key", oldValue)
migrationsHelper.writeValue(db, "old_key", null) // Deletes old_key
}
}
}
Add the new migration to the StorageMigrations.upgradeDatabase() method.
// legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt
internal object StorageMigrations {
@JvmStatic
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: StorageMigrationHelper) {
val oldVersion = db.version
// ... existing migrations ...
if (oldVersion < XX) StorageMigrationToXX(db, migrationsHelper).runMigration()
}
}
Preferences or other high-level application classes inside migrations, as their behavior might change over time. Use StorageMigrationHelper for direct database access.Writing unit tests for preference migrations is mandatory to ensure data integrity and prevent regressions. These tests use Robolectric to provide a realistic Android environment.
Your test class should be located in legacy/storage/src/test/java/com/fsck/k9/preferences/migration/ and named
StorageMigrationToXXTest.kt.
Basic template for a migration test:
@RunWith(RobolectricTestRunner::class)
class StorageMigrationToXXTest {
private val database = createPreferencesDatabase()
private val migrationHelper = DefaultStorageMigrationHelper()
private val migration = StorageMigrationToXX(database, migrationHelper)
@After
fun tearDown() {
database.close()
}
@Test
fun `migration should rename old_key to new_key`() {
// Arrange: Insert old data into the database
migrationHelper.insertValue(database, "old_key", "some value")
// Act: Run the migration
migration.runMigration()
// Assert: Verify the results
val values = migrationHelper.readAllValues(database)
assertThat(values).key("new_key").isEqualTo("some value")
assertThat(values).doesNotContainKey("old_key")
}
}
migrationHelper.insertValue().@After method to avoid resource leaks between tests.Use readValue to get the old value, insertValue to set the new key, and writeValue(db, key, null) to remove the old key.
Account preferences are prefixed with the account UUID. You can find all account UUIDs by reading the accountUuids key.
val accountUuids = migrationsHelper.readValue(db, "accountUuids")?.split(",") ?: emptyList()
for (uuid in accountUuids) {
val key = "$uuid.some_preference"
// ...
}
Simply use migrationsHelper.writeValue(db, "obsolete_key", null).