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

Issue:
Last update crashes when starting the app 
### 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 version

6.901

### Where did you get the app from?

Google Play

### Android version

14

### Device model

Poco F5

### Steps to reproduce

After the update I try to open the app

### Expected behavior

Open the app and check my mail

### Actual behavior

The app crashes and doesn't start.

### Logs

![Screenshot_2024-05-18-23-17-58-480_com miui bugreport](https://github.com/thunderbird/thunderbird-android/assets/2278584/4f34d951-a903-4f32-b9d4-ae2407a91657)
![Screenshot_2024-05-18-23-18-06-220_com miui bugreport](https://github.com/thunderbird/thunderbird-android/assets/2278584/cd62369f-6699-456c-8cf3-9d40f282eeb9)



Discussion hints:
Uninstalling and reinstalling worked for me but I had to setup all the accounts again.
Yes, you're right, uninstalling and reinstalling again fixes the issue. 
Thank you very much.

Pull request: Fix server settings where username or password contains line break
See https://github.com/thunderbird/thunderbird-android/issues/7855#issuecomment-2139379998 for steps on how to reproduce the crash.

Fixes #7855

Test command: ./gradlew :app:storage:testDebugUnitTest --tests "com.fsck.k9.preferences.migrations.StorageMigrationTo22Test"

Fix patch:
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
index e3f19a5c8af..a3527b4df75 100644
--- a/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
@@ -4,7 +4,6 @@
 import java.util.HashMap;
 import java.util.Map;
 
-import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
@@ -13,18 +12,18 @@
 
 import androidx.annotation.NonNull;
 import com.fsck.k9.helper.Utility;
+import com.fsck.k9.preferences.migrations.DefaultStorageMigrationsHelper;
 import com.fsck.k9.preferences.migrations.StorageMigrations;
 import com.fsck.k9.preferences.migrations.StorageMigrationsHelper;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
 import timber.log.Timber;
 
 
 public class K9StoragePersister implements StoragePersister {
-    private static final int DB_VERSION = 21;
+    private static final int DB_VERSION = 22;
     private static final String DB_NAME = "preferences_storage";
 
     private final Context context;
+    private final StorageMigrationsHelper migrationsHelper = new DefaultStorageMigrationsHelper();
 
     public K9StoragePersister(Context context) {
         this.context = context;
@@ -168,84 +167,4 @@ private Map<String, String> readAllValues(SQLiteDatabase database) {
 
         return loadedValues;
     }
-
-    private String readValue(SQLiteDatabase mDb, String key) {
-        Cursor cursor = null;
-        String value = null;
-        try {
-            cursor = mDb.query(
-                    "preferences_storage",
-                    new String[] {"value"},
-                    "primkey = ?",
-                    new String[] {key},
-                    null,
-                    null,
-                    null);
-
-            if (cursor.moveToNext()) {
-                value = cursor.getString(0);
-                Timber.d("Loading key '%s', value = '%s'", key, value);
-            }
-        } finally {
-            Utility.closeQuietly(cursor);
-        }
-
-        return value;
-    }
-
-    private void writeValue(SQLiteDatabase mDb, String key, String value) {
-        if (value == null) {
-            mDb.delete("preferences_storage", "primkey = ?", new String[] { key });
-            return;
-        }
-
-        ContentValues cv = new ContentValues();
-        cv.put("primkey", key);
-        cv.put("value", value);
-
-        long result = mDb.update("preferences_storage", cv, "primkey = ?", new String[] { key });
-
-        if (result == -1) {
-            Timber.e("Error writing key '%s', value = '%s'", key, value);
-        }
-    }
-
-    private void insertValue(SQLiteDatabase mDb, String key, String value) {
-        if (value == null) {
-            return;
-        }
-
-        ContentValues cv = new ContentValues();
-        cv.put("primkey", key);
-        cv.put("value", value);
-
-        long result = mDb.insert("preferences_storage", null, cv);
-
-        if (result == -1) {
-            Timber.e("Error writing key '%s', value = '%s'", key, value);
-        }
-    }
-
-    private StorageMigrationsHelper migrationsHelper = new StorageMigrationsHelper() {
-        @Override
-        public void writeValue(@NotNull SQLiteDatabase db, @NotNull String key, String value) {
-            K9StoragePersister.this.writeValue(db, key, value);
-        }
-
-        @NotNull
-        @Override
-        public Map<String, String> readAllValues(@NotNull SQLiteDatabase db) {
-            return K9StoragePersister.this.readAllValues(db);
-        }
-
-        @Override
-        public String readValue(@NotNull SQLiteDatabase db, @NotNull String key) {
-            return K9StoragePersister.this.readValue(db, key);
-        }
-
-        @Override
-        public void insertValue(@NotNull SQLiteDatabase db, @NotNull String key, @Nullable String value) {
-            K9StoragePersister.this.insertValue(db, key, value);
-        }
-    };
 }
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/DefaultStorageMigrationsHelper.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/DefaultStorageMigrationsHelper.kt
new file mode 100644
index 00000000000..6a1aedd6c0a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/DefaultStorageMigrationsHelper.kt
@@ -0,0 +1,82 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.content.contentValuesOf
+import app.k9mail.core.android.common.database.getStringOrThrow
+import app.k9mail.core.android.common.database.map
+import timber.log.Timber
+
+class DefaultStorageMigrationsHelper : StorageMigrationsHelper {
+    override fun readAllValues(db: SQLiteDatabase): Map<String, String> {
+        return db.query(TABLE_NAME, arrayOf(KEY_COLUMN, VALUE_COLUMN), null, null, null, null, null).use {
+            it.map { cursor ->
+                val key = cursor.getStringOrThrow(KEY_COLUMN)
+                val value = cursor.getStringOrThrow(VALUE_COLUMN)
+                Timber.d("Loading key '%s', value = '%s'", key, value)
+
+                key to value
+            }
+        }.toMap()
+    }
+
+    override fun readValue(db: SQLiteDatabase, key: String): String? {
+        return db.query(
+            TABLE_NAME,
+            arrayOf(VALUE_COLUMN),
+            "$KEY_COLUMN = ?",
+            arrayOf(key),
+            null,
+            null,
+            null,
+        ).use { cursor ->
+            if (cursor.moveToNext()) {
+                cursor.getStringOrThrow(VALUE_COLUMN).also { value ->
+                    Timber.d("Loading key '%s', value = '%s'", key, value)
+                }
+            } else {
+                null
+            }
+        }
+    }
+
+    override fun writeValue(db: SQLiteDatabase, key: String, value: String?) {
+        if (value == null) {
+            db.delete(TABLE_NAME, "$KEY_COLUMN = ?", arrayOf(key))
+            return
+        }
+
+        val values = contentValuesOf(
+            KEY_COLUMN to key,
+            VALUE_COLUMN to value,
+        )
+
+        val result = db.update(TABLE_NAME, values, "$KEY_COLUMN = ?", arrayOf(key))
+
+        if (result == -1) {
+            Timber.e("Error writing key '%s', value = '%s'", key, value)
+        }
+    }
+
+    override fun insertValue(db: SQLiteDatabase, key: String, value: String?) {
+        if (value == null) {
+            return
+        }
+
+        val values = contentValuesOf(
+            KEY_COLUMN to key,
+            VALUE_COLUMN to value,
+        )
+
+        val result = db.insert(TABLE_NAME, null, values)
+
+        if (result == -1L) {
+            Timber.e("Error writing key '%s', value = '%s'", key, value)
+        }
+    }
+
+    companion object {
+        private const val TABLE_NAME = "preferences_storage"
+        private const val KEY_COLUMN = "primkey"
+        private const val VALUE_COLUMN = "value"
+    }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22.kt
new file mode 100644
index 00000000000..8e5f06823ae
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22.kt
@@ -0,0 +1,78 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+
+/**
+ * Fix server settings by removing line breaks from username and password.
+ */
+class StorageMigrationTo22(
+    private val db: SQLiteDatabase,
+    private val migrationsHelper: StorageMigrationsHelper,
+) {
+    fun fixServerSettings() {
+        val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+        if (accountUuidsListValue.isNullOrEmpty()) {
+            return
+        }
+
+        val accountUuids = accountUuidsListValue.split(",")
+        for (accountUuid in accountUuids) {
+            fixServerSettingsForAccount(accountUuid)
+        }
+    }
+
+    private fun fixServerSettingsForAccount(accountUuid: String) {
+        val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
+        val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
+
+        val adapter = createJsonAdapter()
+
+        adapter.fromJson(incomingServerSettingsJson)?.let { settings ->
+            createFixedServerSettings(settings)?.let { newSettings ->
+                val json = adapter.toJson(newSettings)
+                migrationsHelper.writeValue(db, "$accountUuid.incomingServerSettings", json)
+            }
+        }
+
+        adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
+            createFixedServerSettings(settings)?.let { newSettings ->
+                val json = adapter.toJson(newSettings)
+                migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
+            }
+        }
+    }
+
+    private fun createFixedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
+        val username = serverSettings["username"] as? String
+        val password = serverSettings["password"] as? String
+        val newUsername = username?.stripLineBreaks()
+        val newPassword = password?.stripLineBreaks()
+
+        return if (username != newUsername || password != newPassword) {
+            serverSettings.toMutableMap().apply {
+                this["username"] = newUsername
+                this["password"] = newPassword
+
+                // This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
+                this["port"] = (serverSettings["port"] as? Double)?.toInt()
+            }
+        } else {
+            null
+        }
+    }
+
+    private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
+        val moshi = Moshi.Builder().build()
+
+        return moshi.adapter<Map<String, Any?>>(
+            Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
+        ).serializeNulls()
+    }
+}
+
+private val LINE_BREAK = "[\\r\\n]".toRegex()
+
+private fun String.stripLineBreaks() = replace(LINE_BREAK, replacement = "")
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt
index ebddafd6c4c..2d34a7d3fa5 100644
--- a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt
@@ -28,5 +28,6 @@ internal object StorageMigrations {
         if (oldVersion < 19) StorageMigrationTo19(db, migrationsHelper).markGmailAccounts()
         if (oldVersion < 20) StorageMigrationTo20(db, migrationsHelper).fixIdentities()
         if (oldVersion < 21) StorageMigrationTo21(db, migrationsHelper).createPostRemoveNavigationSetting()
+        if (oldVersion < 22) StorageMigrationTo22(db, migrationsHelper).fixServerSettings()
     }
 }


Test patch:
diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/PreferencesDatabaseTestHelper.kt b/app/storage/src/test/java/com/fsck/k9/preferences/PreferencesDatabaseTestHelper.kt
new file mode 100644
index 00000000000..da6860d9a87
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/preferences/PreferencesDatabaseTestHelper.kt
@@ -0,0 +1,22 @@
+package com.fsck.k9.preferences
+
+import android.database.sqlite.SQLiteDatabase
+
+private const val TABLE_NAME = "preferences_storage"
+private const val PRIMARY_KEY_COLUMN = "primkey"
+private const val VALUE_COLUMN = "value"
+
+fun createPreferencesDatabase(): SQLiteDatabase {
+    val database = SQLiteDatabase.create(null)
+
+    database.execSQL(
+        """
+        CREATE TABLE $TABLE_NAME (
+          $PRIMARY_KEY_COLUMN TEXT PRIMARY KEY ON CONFLICT REPLACE,
+          $VALUE_COLUMN TEXT
+        )
+        """.trimIndent(),
+    )
+
+    return database
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22Test.kt b/app/storage/src/test/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22Test.kt
new file mode 100644
index 00000000000..f5eb0f7a456
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/preferences/migrations/StorageMigrationTo22Test.kt
@@ -0,0 +1,165 @@
+package com.fsck.k9.preferences.migrations
+
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.key
+import com.fsck.k9.preferences.createPreferencesDatabase
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import java.util.UUID
+import kotlin.test.Test
+import org.junit.After
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class StorageMigrationTo22Test {
+    private val database = createPreferencesDatabase()
+    private val migrationsHelper = DefaultStorageMigrationsHelper()
+    private val migration = StorageMigrationTo22(database, migrationsHelper)
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    @Suppress("LongMethod")
+    fun `fixServerSettings() should retain values while removing line breaks from username and password`() {
+        val accountOne = createAccount(
+            "incomingServerSettings" to toJson(
+                "type" to "imap",
+                "host" to "irrelevant.invalid",
+                "port" to 993,
+                "connectionSecurity" to "SSL_TLS_REQUIRED",
+                "authenticationType" to "PLAIN",
+                "username" to "user\n",
+                "password" to "pass\nword",
+                "clientCertificateAlias" to null,
+            ),
+            "outgoingServerSettings" to toJson(
+                "type" to "smtp",
+                "host" to "irrelevant.invalid",
+                "port" to 465,
+                "connectionSecurity" to "SSL_TLS_REQUIRED",
+                "authenticationType" to "PLAIN",
+                "username" to "",
+                "password" to null,
+                "clientCertificateAlias" to null,
+            ),
+        )
+        val accountTwo = createAccount(
+            "incomingServerSettings" to toJson(
+                "type" to "imap",
+                "host" to "irrelevant.test",
+                "port" to 143,
+                "connectionSecurity" to "NONE",
+                "authenticationType" to "XOAUTH2",
+                "username" to "user@domain.example\r\n",
+                "password" to null,
+                "clientCertificateAlias" to null,
+            ),
+            "outgoingServerSettings" to toJson(
+                "type" to "smtp",
+                "host" to "irrelevant.test",
+                "port" to 587,
+                "connectionSecurity" to "STARTTLS_REQUIRED",
+                "authenticationType" to "CRAM_MD5",
+                "username" to "username",
+                "password" to "password",
+                "clientCertificateAlias" to "not-null",
+            ),
+        )
+        writeAccountUuids(accountOne, accountTwo)
+
+        migration.fixServerSettings()
+
+        assertThat(migrationsHelper.readAllValues(database)).all {
+            key("$accountOne.incomingServerSettings").isEqualTo(
+                """
+                {
+                    "type": "imap",
+                    "host": "irrelevant.invalid",
+                    "port": 993,
+                    "connectionSecurity": "SSL_TLS_REQUIRED",
+                    "authenticationType": "PLAIN",
+                    "username": "user",
+                    "password": "password",
+                    "clientCertificateAlias": null
+                }
+                """.toCompactJson(),
+            )
+            key("$accountOne.outgoingServerSettings").isEqualTo(
+                """
+                {
+                    "type": "smtp",
+                    "host": "irrelevant.invalid",
+                    "port": 465,
+                    "connectionSecurity": "SSL_TLS_REQUIRED",
+                    "authenticationType": "PLAIN",
+                    "username": "",
+                    "password": null,
+                    "clientCertificateAlias": null
+                }
+                """.toCompactJson(),
+            )
+
+            key("$accountTwo.incomingServerSettings").isEqualTo(
+                """
+                {
+                    "type": "imap",
+                    "host": "irrelevant.test",
+                    "port": 143,
+                    "connectionSecurity": "NONE",
+                    "authenticationType": "XOAUTH2",
+                    "username": "user@domain.example",
+                    "password": null,
+                    "clientCertificateAlias": null
+                }
+                """.toCompactJson(),
+            )
+            key("$accountTwo.outgoingServerSettings").isEqualTo(
+                """
+                {
+                    "type": "smtp",
+                    "host": "irrelevant.test",
+                    "port": 587,
+                    "connectionSecurity": "STARTTLS_REQUIRED",
+                    "authenticationType": "CRAM_MD5",
+                    "username": "username",
+                    "password": "password",
+                    "clientCertificateAlias": "not-null"
+                }
+                """.toCompactJson(),
+            )
+        }
+    }
+
+    private fun writeAccountUuids(vararg accounts: String) {
+        val accountUuids = accounts.joinToString(separator = ",")
+        migrationsHelper.insertValue(database, "accountUuids", accountUuids)
+    }
+
+    private fun createAccount(vararg pairs: Pair<String, String>): String {
+        val accountUuid = UUID.randomUUID().toString()
+
+        for ((key, value) in pairs) {
+            migrationsHelper.insertValue(database, "$accountUuid.$key", value)
+        }
+
+        return accountUuid
+    }
+
+    private fun toJson(vararg pairs: Pair<String, Any?>): String {
+        val moshi = Moshi.Builder().build()
+        val adapter = moshi.adapter<Map<String, Any?>>(
+            Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
+        ).serializeNulls()
+
+        return adapter.toJson(pairs.toMap()) ?: error("Failed to create JSON")
+    }
+
+    // Note: This only works for JSON strings where keys and values don't contain any spaces
+    private fun String.toCompactJson(): String = replace(" ", "").replace("\n", "")
+}