Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
Fix migration to ensure avatar type is set
### Checklist

- [x] I have used the search function to see if someone else has already submitted the same bug report.
- [x] I will describe the problem with as much detail as possible.

### App

Thunderbird for Android

### App version

12.0b4

### Where did you get the app from?

Other

### Android version

Android 15

### Device model

Oneplus 10 Pro

### Steps to reproduce

App version downloaded from github
  
1. Open app.
2. Select settings from side panel.
3. Select email account (gmail).
4. Select general settings.

App crashes at step 4.



### Expected behavior

The general settings window will display.

### Actual behavior

The general settings window fails to display. Android displays the dialog "Thunderbird keeps stopping, app info / close" after repeating the steps a few times.

### Logs

`--------- beginning of crash
07-28 22:36:36.243   526   526 E AndroidRuntime: FATAL EXCEPTION: main
07-28 22:36:36.243   526   526 E AndroidRuntime: Process: net.thunderbird.android.beta, PID: 526
07-28 22:36:36.243   526   526 E AndroidRuntime: java.lang.IllegalArgumentException: Monogram value is required
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountAvatarDataMapper.toDomain(DefaultAccountAvatarDataMapper.kt:13)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountAvatarDataMapper.toDomain(DefaultAccountAvatarDataMapper.kt:8)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountProfileDataMapper.toDomain(DefaultAccountProfileDataMapper.kt:16)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountProfileDataMapper.toDomain(DefaultAccountProfileDataMapper.kt:8)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.app.common.account.data.DefaultAccountProfileLocalDataSource$getById-8irbSJM$$inlined$map$1$2.emit(Emitters.kt:52)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at net.thunderbird.app.common.account.data.DefaultLegacyAccountWrapperManager$getById-8irbSJM$$inlined$map$1$2.emit(Emitters.kt:50)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at kotlinx.coroutines.flow.FlowKt__ChannelsKt.emitAllImpl$FlowKt__ChannelsKt(Channels.kt:33)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at kotlinx.coroutines.flow.FlowKt__ChannelsKt.access$emitAllImpl$FlowKt__ChannelsKt(Channels.kt:1)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at kotlinx.coroutines.flow.FlowKt__ChannelsKt$emitAllImpl$1.invokeSuspend(Channels.kt:0)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at android.os.Handler.handleCallback(Handler.java:973)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at android.os.Handler.dispatchMessage(Handler.java:100)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at android.os.Looper.loopOnce(Looper.java:282)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at android.os.Looper.loop(Looper.java:387)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at android.app.ActivityThread.main(ActivityThread.java:9500)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at java.lang.reflect.Method.invoke(Native Method)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:600)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1005)
07-28 22:36:36.243   526   526 E AndroidRuntime: 	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@54f5c7a, Dispatchers.Main.immediate]`


Discussion hints:
I have the same problem and did some digging into the code and app data.

When I tried to reproduce the issue in a fresh install (`fossDebug` build from beta branch), everything worked correctly. Only my main install (beta from GitHub, regularly updated since the first release branded as Thunderbird) crashed when clicking "General" in any account settings. Looking through the code I found that there were some changes to the profile indicator (#9271). This required adding a database migration (#9332) that would add `.avatarType` and `.avatarMonogram` preference keys to accounts in order to prevent this crash (originally reported as #9330).

I did not debug that [migration](https://github.com/thunderbird/thunderbird-android/blob/60372f9b064e87be0071e33e5e2b718ec55ed8e6/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo27.kt), but it looks like something is wrong with it. I checked the database on my phone and somehow **the migration to version 27 added only `.avatarType`, but not `.avatarMonogram`**:

```
~ $ sudo sqlite3 --readonly /data/data/net.thunderbird.android.beta/databases/preferences_storage
SQLite version 3.50.4 2025-07-30 19:33:53
Enter ".help" for usage hints.
sqlite> PRAGMA user_version;
27
sqlite> SELECT * FROM preferences_storage WHERE primkey LIKE '%.avatar%';
dbe32f58-f3ee-4596-b27e-a74464b42895.avatarType|MONOGRAM
537a3c59-4da0-495e-8cc9-9a1a0a863772.avatarType|MONOGRAM
b2ae8bd3-3046-48bc-bb11-3ac3b4a04d12.avatarType|MONOGRAM
0308d0e4-7659-46d8-befe-ec78911c1b7d.avatarType|MONOGRAM
4190cde2-4a56-4c66-8827-e2e29d81f172.avatarType|MONOGRAM
sqlite> .exit
```

My suspicion is that the migration doesn't really do anything, because `readAvatarType()` may return an empty string if there is no `.avatarType` (and it will likely not exist if the migration has to be executed), making the first comparison in `if` false (`"" == "MONOGRAM"`)

https://github.com/thunderbird/thunderbird-android/blob/60372f9b064e87be0071e33e5e2b718ec55ed8e6/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo27.kt#L26-L34

https://github.com/thunderbird/thunderbird-android/blob/60372f9b064e87be0071e33e5e2b718ec55ed8e6/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo27.kt#L56-L58

I'm not sure whether this analysis is correct, because the whole migration would be skipped and `.avatarType` somehow appeared in my DB. It is possible that something else set it and saved to the database (an unrelated settings change?).
Thanks for the report, we need to investigate.
The migration failed to add the `avatarType` in case it doesn't exist yet. Preparing an update.

Pull request: Fix database migration missing avatar type
Resolves #9547 


Test command: ./gradlew :legacy:storage:testDebugUnitTest --tests "com.fsck.k9.preferences.migration.StorageMigrationTo28Test"

Fix patch:
diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java b/legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
index ea46efd33f2..6f82d178612 100644
--- a/legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
+++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
@@ -23,7 +23,7 @@
 import net.thunderbird.core.preference.storage.StorageUpdater;
 
 public class K9StoragePersister implements StoragePersister {
-    private static final int DB_VERSION = 27;
+    private static final int DB_VERSION = 28;
     private static final String DB_NAME = "preferences_storage";
 
     private final Context context;
diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo28.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo28.kt
new file mode 100644
index 00000000000..4b6bf552866
--- /dev/null
+++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo28.kt
@@ -0,0 +1,94 @@
+package com.fsck.k9.preferences.migration
+
+import android.database.sqlite.SQLiteDatabase
+import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
+
+/**
+ * Migration to ensure all accounts have an avatar type set.
+ * This fixes an issue where migration 27 might not have set the avatar type correctly.
+ */
+@Suppress("TooManyFunctions")
+class StorageMigrationTo28(
+    private val db: SQLiteDatabase,
+    private val migrationsHelper: StorageMigrationHelper,
+) {
+    fun ensureAvatarSet() {
+        val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids")
+        if (accountUuidsValue.isNullOrEmpty()) {
+            return
+        }
+
+        val accountUuids = accountUuidsValue.split(",")
+        for (accountUuid in accountUuids) {
+            ensureAvatarTypeForAccount(accountUuid)
+        }
+    }
+
+    private fun ensureAvatarTypeForAccount(accountUuid: String) {
+        var avatarType = readAvatarType(accountUuid)
+        val avatarMonogram = readAvatarMonogram(accountUuid)
+
+        if (avatarType.isEmpty()) {
+            avatarType = AvatarTypeDto.MONOGRAM.name
+            insertAvatarType(accountUuid, avatarType)
+        }
+
+        if (avatarType == AvatarTypeDto.MONOGRAM.name && avatarMonogram.isEmpty()) {
+            val monogram = generateAvatarMonogram(accountUuid)
+            insertAvatarMonogram(accountUuid, monogram)
+        }
+    }
+
+    private fun generateAvatarMonogram(accountUuid: String): String {
+        val name = readName(accountUuid)
+        val email = readEmail(accountUuid)
+        return getAvatarMonogram(name, email)
+    }
+
+    private fun getAvatarMonogram(name: String?, email: String?): String {
+        return if (name != null && name.isNotEmpty()) {
+            composeAvatarMonogram(name)
+        } else if (email != null && email.isNotEmpty()) {
+            composeAvatarMonogram(email)
+        } else {
+            AVATAR_MONOGRAM_DEFAULT
+        }
+    }
+
+    private fun composeAvatarMonogram(name: String): String {
+        return name.replace(" ", "").take(2).uppercase()
+    }
+
+    private fun readAvatarType(accountUuid: String): String {
+        return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_TYPE_KEY") ?: ""
+    }
+
+    private fun readAvatarMonogram(accountUuid: String): String {
+        return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY") ?: ""
+    }
+
+    private fun readName(accountUuid: String): String {
+        return migrationsHelper.readValue(db, "$accountUuid.$NAME_KEY") ?: ""
+    }
+
+    private fun readEmail(accountUuid: String): String {
+        return migrationsHelper.readValue(db, "$accountUuid.$EMAIL_KEY") ?: ""
+    }
+
+    private fun insertAvatarType(accountUuid: String, avatarType: String) {
+        migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_TYPE_KEY", avatarType)
+    }
+
+    private fun insertAvatarMonogram(accountUuid: String, monogram: String) {
+        migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY", monogram)
+    }
+
+    private companion object {
+        const val NAME_KEY = "name.0"
+        const val EMAIL_KEY = "email.0"
+        const val AVATAR_TYPE_KEY = "avatarType"
+        const val AVATAR_MONOGRAM_KEY = "avatarMonogram"
+
+        private const val AVATAR_MONOGRAM_DEFAULT = "XX"
+    }
+}
diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt
index 553d73675de..70a071c6394 100644
--- a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt
+++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt
@@ -34,5 +34,6 @@ internal object StorageMigrations {
         if (oldVersion < 25) StorageMigrationTo25(db, migrationsHelper).convertToAuthTypeNone()
         if (oldVersion < 26) StorageMigrationTo26(db, migrationsHelper).fixIdentities()
         if (oldVersion < 27) StorageMigrationTo27(db, migrationsHelper).addAvatarMonogram()
+        if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet()
     }
 }


Test patch:
diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo28Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo28Test.kt
new file mode 100644
index 00000000000..f103e10c59b
--- /dev/null
+++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo28Test.kt
@@ -0,0 +1,157 @@
+package com.fsck.k9.preferences.migration
+
+import assertk.assertThat
+import assertk.assertions.doesNotContainKey
+import assertk.assertions.isEqualTo
+import assertk.assertions.key
+import com.fsck.k9.preferences.createPreferencesDatabase
+import java.util.UUID
+import kotlin.test.Test
+import net.thunderbird.core.logging.legacy.Log
+import net.thunderbird.core.logging.testing.TestLogger
+import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
+import org.junit.After
+import org.junit.Before
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class StorageMigrationTo28Test {
+    private val database = createPreferencesDatabase()
+    private val migrationHelper = DefaultStorageMigrationHelper()
+    private val migration = StorageMigrationTo28(database, migrationHelper)
+
+    @Before
+    fun setUp() {
+        Log.logger = TestLogger()
+    }
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun `avatar type should be set for accounts with no avatar type`() {
+        val accountUuid = createAccount(
+            "avatarType" to null,
+            "avatarMonogram" to "AB",
+        )
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.MONOGRAM)
+        assertAvatarMonogram(accountUuid, "AB")
+    }
+
+    @Test
+    fun `avatar type should not be set for accounts with existing avatar type`() {
+        val accountUuid = createAccount(
+            "avatarType" to AvatarTypeDto.IMAGE.name,
+        )
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.IMAGE)
+        assertThat(migrationHelper.readAllValues(database))
+            .doesNotContainKey("$accountUuid.avatarMonogram")
+    }
+
+    @Test
+    fun `avatar monogram should be set for accounts with MONOGRAM avatar type and no monogram`() {
+        val accountUuid = createAccount(
+            "avatarType" to AvatarTypeDto.MONOGRAM.name,
+            "avatarMonogram" to null,
+            "name.0" to "John Doe",
+            "email.0" to "test@example.com",
+        )
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.MONOGRAM)
+        assertAvatarMonogram(accountUuid, "JO")
+    }
+
+    @Test
+    fun `avatar monogram should be added for accounts with no monogram and name but email`() {
+        val accountUuid = createAccount(
+            "avatarType" to AvatarTypeDto.MONOGRAM.name,
+            "avatarMonogram" to null,
+            "name.0" to null,
+            "email.0" to "test@example.com",
+        )
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.MONOGRAM)
+        assertAvatarMonogram(accountUuid, "TE")
+    }
+
+    @Test
+    fun `avatar monogram should be added for accounts with MONOGRAM avatar type and no monogram, name and email`() {
+        val accountUuid = createAccount(
+            "avatarType" to AvatarTypeDto.MONOGRAM.name,
+            "avatarMonogram" to null,
+            "name.0" to null,
+            "email.0" to null,
+        )
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.MONOGRAM)
+        assertAvatarMonogram(accountUuid, "XX")
+    }
+
+    @Test
+    fun `avatar type and monogram should be set for accounts with missing avatar type and monogram`() {
+        val accountUuid = createAccount(
+            "avatarType" to null,
+            "avatarMonogram" to null,
+            "name.0" to "John Doe",
+            "email.0" to "test@example.com",
+        )
+
+        writeAccountUuids(accountUuid)
+
+        migration.ensureAvatarSet()
+
+        assertAvatarType(accountUuid, AvatarTypeDto.MONOGRAM)
+        assertAvatarMonogram(accountUuid, "JO")
+    }
+
+    private fun assertAvatarType(
+        accountUuid: String,
+        expectedAvatarType: AvatarTypeDto,
+    ) {
+        assertThat(migrationHelper.readAllValues(database))
+            .key("$accountUuid.avatarType").isEqualTo(expectedAvatarType.name)
+    }
+
+    private fun assertAvatarMonogram(
+        accountUuid: String,
+        expectedMonogram: String,
+    ) {
+        assertThat(migrationHelper.readAllValues(database))
+            .key("$accountUuid.avatarMonogram").isEqualTo(expectedMonogram)
+    }
+
+    private fun writeAccountUuids(vararg accounts: String) {
+        val accountUuids = accounts.joinToString(separator = ",")
+        migrationHelper.insertValue(database, "accountUuids", accountUuids)
+    }
+
+    private fun createAccount(vararg pairs: Pair<String, String?>): String {
+        val accountUuid = UUID.randomUUID().toString()
+
+        for ((key, value) in pairs) {
+            migrationHelper.insertValue(database, "$accountUuid.$key", value)
+        }
+
+        return accountUuid
+    }
+}