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

Issue:
App crashes when I tap on "show accounts" in sidebar
### 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

8.0b2

### Where did you get the app from?

Google Play

### Android version

14 (patch level 09-24)

### Device model

Oneplus Nord 3 5G

### Steps to reproduce

1. Go to "show accounts" in sidebar
2. See that app crashes

### Expected behavior

Account menu should be shown

### Actual behavior

App crashes

### Logs

[log_2024_10_10_10_19_11.log](https://github.com/user-attachments/files/17324855/log_2024_10_10_10_19_11.log)

Make email an required field in "Message composition options" screen
Right now it's possible to remove the email address of the default identity via *Settings → [Account] → Sending mail → Composition defaults* (see #8301). It's not useful to have an identity without an email address and so a lot of places expect an email address to be there. We need to stop saving changes made in the "Message composition options" screen unless there's a valid email address.


Discussion hints:
Thanks for reporting this issue. I was able to reproduce it.
@Chartman123, I was wondering why this was happening and I thought it might be due to one of your accounts having no email set up or a format that our current code can't understand. We attempt to split the email at the @ sign and use the first 2 characters of the domain name. I added a fix, but I'd love to know what actually caused the issue, was it indeed an empty email field or an unexpected domain format? Maybe you could report back and let me know what you've found out?

Yes, this might be the reason... I edited one of my accounts and removed the mail address and name as I wanted to only use my created Identities. After that change the app worked still fine until the next start of the app
Thanks, the fix should work for your specific use case.
I've created issue #8303 for the bug that allowed removing the email address from the default identity.

@Chartman123: You can remove an identity completely by going to *Settings → [Account] → Sending mail → Manage identities*. Long-press the identity you want to remove and select *Remove* from the popup menu.
@cketti yes, but I wanted to use it the other way round because in the Identities dialog you have more options than in the default settings of the account.
I don't understand. Can you elaborate?
Not at the moment as I can't show you the screenshots because of the bug ;) But I'll post them as soon as possible :)


Pull request: Don't allow identities without a syntactically valid email address
- Don't allow the user to enter an invalid email address in the *composition defaults* screen.
- Rewrite existing identities to make sure there's a syntactically valid email address.

Fixes #8301
Fixes #8303


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

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 04a55d5062e..c5d1c034c29 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
@@ -19,7 +19,7 @@
 
 
 public class K9StoragePersister implements StoragePersister {
-    private static final int DB_VERSION = 25;
+    private static final int DB_VERSION = 26;
     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/StorageMigrationTo26.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo26.kt
new file mode 100644
index 00000000000..642a93998bb
--- /dev/null
+++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo26.kt
@@ -0,0 +1,48 @@
+package com.fsck.k9.preferences.migration
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.EmailAddressValidator
+
+/**
+ * Make sure identities are using a syntactically valid email address.
+ *
+ * Previously, we didn't validate input in the "Composition defaults" screen, and so there was no guarantee that the
+ * `email` values contained a valid email address.
+ */
+class StorageMigrationTo26(
+    private val db: SQLiteDatabase,
+    private val migrationsHelper: StorageMigrationHelper,
+) {
+    private val emailAddressValidator = EmailAddressValidator()
+
+    fun fixIdentities() {
+        val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+        if (accountUuidsListValue.isNullOrEmpty()) {
+            return
+        }
+
+        val accountUuids = accountUuidsListValue.split(",")
+        for (accountUuid in accountUuids) {
+            fixIdentitiesInAccount(accountUuid)
+        }
+    }
+
+    private fun fixIdentitiesInAccount(accountUuid: String) {
+        var identityIndex = 0
+
+        while (true) {
+            val email = migrationsHelper.readValue(db, "$accountUuid.email.$identityIndex") ?: break
+
+            val trimmedEmail = email.trim()
+            val newEmail = if (emailAddressValidator.isValidAddressOnly(trimmedEmail)) {
+                trimmedEmail
+            } else {
+                "please.edit@invalid"
+            }
+
+            migrationsHelper.writeValue(db, "$accountUuid.email.$identityIndex", newEmail)
+
+            identityIndex++
+        }
+    }
+}
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 4f824dff843..c33e60f1c93 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
@@ -32,5 +32,6 @@ internal object StorageMigrations {
         if (oldVersion < 23) StorageMigrationTo23(db, migrationsHelper).renameSendClientId()
         if (oldVersion < 24) StorageMigrationTo24(db, migrationsHelper).removeLegacyAuthenticationModes()
         if (oldVersion < 25) StorageMigrationTo25(db, migrationsHelper).convertToAuthTypeNone()
+        if (oldVersion < 26) StorageMigrationTo26(db, migrationsHelper).fixIdentities()
     }
 }
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java
deleted file mode 100644
index bfd07562a61..00000000000
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.fsck.k9.activity.setup;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.CompoundButton;
-import android.widget.EditText;
-import android.widget.LinearLayout;
-
-import androidx.annotation.NonNull;
-import app.k9mail.legacy.account.Account;
-import com.fsck.k9.Preferences;
-import com.fsck.k9.ui.R;
-import com.fsck.k9.ui.base.K9Activity;
-import com.google.android.material.checkbox.MaterialCheckBox;
-import com.google.android.material.radiobutton.MaterialRadioButton;
-
-
-public class AccountSetupComposition extends K9Activity {
-
-    private static final String EXTRA_ACCOUNT = "account";
-
-    private Account mAccount;
-
-    private EditText mAccountSignature;
-    private EditText mAccountEmail;
-    private EditText mAccountAlwaysBcc;
-    private EditText mAccountName;
-    private MaterialCheckBox mAccountSignatureUse;
-    private MaterialRadioButton mAccountSignatureBeforeLocation;
-    private MaterialRadioButton mAccountSignatureAfterLocation;
-    private LinearLayout mAccountSignatureLayout;
-
-
-    public static void actionEditCompositionSettings(Activity context, String accountUuid) {
-        Intent intent = new Intent(context, AccountSetupComposition.class);
-        intent.setAction(Intent.ACTION_EDIT);
-        intent.putExtra(EXTRA_ACCOUNT, accountUuid);
-        context.startActivity(intent);
-    }
-
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT);
-        mAccount = Preferences.getPreferences().getAccount(accountUuid);
-
-        setLayout(R.layout.account_setup_composition);
-        setTitle(R.string.account_settings_composition_title);
-
-        if (getSupportActionBar() != null) {
-            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
-        }
-
-        /*
-         * If we're being reloaded we override the original account with the one
-         * we saved
-         */
-        if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) {
-            accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT);
-            mAccount = Preferences.getPreferences().getAccount(accountUuid);
-        }
-
-        mAccountName = findViewById(R.id.account_name);
-        mAccountName.setText(mAccount.getSenderName());
-
-        mAccountEmail = findViewById(R.id.account_email);
-        mAccountEmail.setText(mAccount.getEmail());
-
-        mAccountAlwaysBcc = findViewById(R.id.account_always_bcc);
-        mAccountAlwaysBcc.setText(mAccount.getAlwaysBcc());
-
-        mAccountSignatureLayout = findViewById(R.id.account_signature_layout);
-
-        mAccountSignatureUse = findViewById(R.id.account_signature_use);
-        boolean useSignature = mAccount.getSignatureUse();
-        mAccountSignatureUse.setChecked(useSignature);
-        mAccountSignatureUse.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
-            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
-                if (isChecked) {
-                    mAccountSignatureLayout.setVisibility(View.VISIBLE);
-                    mAccountSignature.setText(mAccount.getSignature());
-                    boolean isSignatureBeforeQuotedText = mAccount.isSignatureBeforeQuotedText();
-                    mAccountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText);
-                    mAccountSignatureAfterLocation.setChecked(!isSignatureBeforeQuotedText);
-                } else {
-                    mAccountSignatureLayout.setVisibility(View.GONE);
-                }
-            }
-        });
-
-        mAccountSignature = findViewById(R.id.account_signature);
-
-        mAccountSignatureBeforeLocation = findViewById(R.id.account_signature_location_before_quoted_text);
-        mAccountSignatureAfterLocation = findViewById(R.id.account_signature_location_after_quoted_text);
-
-        if (useSignature) {
-            mAccountSignature.setText(mAccount.getSignature());
-
-            boolean isSignatureBeforeQuotedText = mAccount.isSignatureBeforeQuotedText();
-            mAccountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText);
-            mAccountSignatureAfterLocation.setChecked(!isSignatureBeforeQuotedText);
-        } else {
-            mAccountSignatureLayout.setVisibility(View.GONE);
-        }
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            finish();
-            return true;
-        }
-
-        return super.onOptionsItemSelected(item);
-    }
-
-    private void saveSettings() {
-        mAccount.setEmail(mAccountEmail.getText().toString());
-        mAccount.setAlwaysBcc(mAccountAlwaysBcc.getText().toString());
-        mAccount.setSenderName(mAccountName.getText().toString());
-        mAccount.setSignatureUse(mAccountSignatureUse.isChecked());
-        if (mAccountSignatureUse.isChecked()) {
-            mAccount.setSignature(mAccountSignature.getText().toString());
-            boolean isSignatureBeforeQuotedText = mAccountSignatureBeforeLocation.isChecked();
-            mAccount.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText);
-        }
-
-        Preferences.getPreferences().saveAccount(mAccount);
-    }
-
-    @Override
-    public void onStop() {
-        // TODO: Instead of saving the changes when the activity is stopped, add buttons to explicitly save or discard
-        //  changes.
-        saveSettings();
-        super.onStop();
-    }
-
-    @Override
-    public void onSaveInstanceState(Bundle outState) {
-        super.onSaveInstanceState(outState);
-        outState.putSerializable(EXTRA_ACCOUNT, mAccount.getUuid());
-    }
-}
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt
new file mode 100644
index 00000000000..f80c1fd45f0
--- /dev/null
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.kt
@@ -0,0 +1,150 @@
+package com.fsck.k9.activity.setup
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.core.view.isVisible
+import androidx.core.widget.doAfterTextChanged
+import app.k9mail.legacy.account.Account
+import com.fsck.k9.EmailAddressValidator
+import com.fsck.k9.Preferences
+import com.fsck.k9.ui.R
+import com.fsck.k9.ui.base.K9Activity
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.radiobutton.MaterialRadioButton
+import org.koin.android.ext.android.inject
+
+class AccountSetupComposition : K9Activity() {
+    private val emailAddressValidator: EmailAddressValidator by inject()
+
+    private lateinit var account: Account
+
+    private lateinit var accountSignature: EditText
+    private lateinit var accountEmail: EditText
+    private lateinit var accountAlwaysBcc: EditText
+    private lateinit var accountSenderName: EditText
+    private lateinit var accountSignatureUse: MaterialCheckBox
+    private lateinit var accountSignatureBeforeLocation: MaterialRadioButton
+    private lateinit var accountSignatureAfterLocation: MaterialRadioButton
+    private lateinit var accountSignatureLayout: LinearLayout
+
+    private var isSaveActionEnabled = false
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setLayout(R.layout.account_setup_composition)
+        setTitle(R.string.account_settings_composition_label)
+        supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+        val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("Missing account UUID")
+        account = Preferences.getPreferences().getAccount(accountUuid) ?: error("Couldn't find account")
+
+        accountSenderName = findViewById(R.id.account_name)
+        accountEmail = findViewById(R.id.account_email)
+        accountAlwaysBcc = findViewById(R.id.account_always_bcc)
+        accountSignatureLayout = findViewById(R.id.account_signature_layout)
+        accountSignatureUse = findViewById(R.id.account_signature_use)
+        accountSignature = findViewById(R.id.account_signature)
+        accountSignatureBeforeLocation = findViewById(R.id.account_signature_location_before_quoted_text)
+        accountSignatureAfterLocation = findViewById(R.id.account_signature_location_after_quoted_text)
+
+        accountSenderName.setText(account.senderName)
+        accountEmail.setText(account.email)
+        accountAlwaysBcc.setText(account.alwaysBcc)
+
+        val useSignature = account.signatureUse
+        accountSignatureUse.isChecked = useSignature
+        accountSignatureUse.setOnCheckedChangeListener { _, isChecked ->
+            if (isChecked) {
+                accountSignatureLayout.isVisible = true
+                accountSignature.setText(account.signature)
+
+                val isSignatureBeforeQuotedText = account.isSignatureBeforeQuotedText
+                accountSignatureBeforeLocation.isChecked = isSignatureBeforeQuotedText
+                accountSignatureAfterLocation.isChecked = !isSignatureBeforeQuotedText
+            } else {
+                accountSignatureLayout.isVisible = false
+            }
+        }
+
+        if (useSignature) {
+            accountSignature.setText(account.signature)
+
+            val isSignatureBeforeQuotedText = account.isSignatureBeforeQuotedText
+            accountSignatureBeforeLocation.setChecked(isSignatureBeforeQuotedText)
+            accountSignatureAfterLocation.setChecked(!isSignatureBeforeQuotedText)
+        } else {
+            accountSignatureLayout.isVisible = false
+        }
+
+        setTextChangedListeners()
+        validateFields()
+    }
+
+    private fun setTextChangedListeners() {
+        accountEmail.doAfterTextChanged { validateFields() }
+    }
+
+    private fun validateFields() {
+        val valid = isValidEmailAddress(accountEmail)
+
+        isSaveActionEnabled = valid
+        invalidateOptionsMenu()
+    }
+
+    private fun isValidEmailAddress(textView: EditText): Boolean {
+        return emailAddressValidator.isValidAddressOnly(textView.text.trim())
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.account_setup_composition_menu, menu)
+        return true
+    }
+
+    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+        menu.findItem(R.id.account_setup_composition_save).isEnabled = isSaveActionEnabled
+        return true
+    }
+
+    @Suppress("ReturnCount")
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        if (item.itemId == android.R.id.home) {
+            finish()
+            return true
+        } else if (item.itemId == R.id.account_setup_composition_save) {
+            saveSettings()
+            finish()
+            return true
+        }
+
+        return super.onOptionsItemSelected(item)
+    }
+
+    private fun saveSettings() {
+        account.email = accountEmail.text.toString().trim()
+        account.alwaysBcc = accountAlwaysBcc.text.toString().takeUnless { it.isNotBlank() }
+        account.senderName = accountSenderName.text.toString().takeUnless { it.isBlank() }
+        account.signatureUse = accountSignatureUse.isChecked
+        if (accountSignatureUse.isChecked) {
+            account.signature = accountSignature.text.toString()
+            account.isSignatureBeforeQuotedText = accountSignatureBeforeLocation.isChecked
+        }
+
+        Preferences.getPreferences().saveAccount(account)
+    }
+
+    companion object {
+        private const val EXTRA_ACCOUNT = "account"
+
+        fun actionEditCompositionSettings(context: Activity, accountUuid: String?) {
+            val intent = Intent(context, AccountSetupComposition::class.java)
+            intent.setAction(Intent.ACTION_EDIT)
+            intent.putExtra(EXTRA_ACCOUNT, accountUuid)
+            context.startActivity(intent)
+        }
+    }
+}
diff --git a/legacy/ui/legacy/src/main/res/menu/account_setup_composition_menu.xml b/legacy/ui/legacy/src/main/res/menu/account_setup_composition_menu.xml
new file mode 100644
index 00000000000..2e8e0808b6c
--- /dev/null
+++ b/legacy/ui/legacy/src/main/res/menu/account_setup_composition_menu.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    >
+    <item
+        android:id="@+id/account_setup_composition_save"
+        android:title="@string/edit_identity_save"
+        app:showAsAction="always"
+        />
+</menu>


Test patch:
diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo26Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo26Test.kt
new file mode 100644
index 00000000000..651d1bb2a63
--- /dev/null
+++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo26Test.kt
@@ -0,0 +1,82 @@
+package com.fsck.k9.preferences.migration
+
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.key
+import com.fsck.k9.preferences.createPreferencesDatabase
+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 StorageMigrationTo26Test {
+    private val database = createPreferencesDatabase()
+    private val migrationHelper = DefaultStorageMigrationHelper()
+    private val migration = StorageMigrationTo26(database, migrationHelper)
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun `valid email addresses should not be changed`() {
+        val accountOne = createAccount("email.0" to "one@domain.example")
+        val accountTwo = createAccount("email.0" to "two@domain.example")
+        writeAccountUuids(accountOne, accountTwo)
+
+        migration.fixIdentities()
+
+        assertThat(migrationHelper.readAllValues(database)).all {
+            key("$accountOne.email.0").isEqualTo("one@domain.example")
+            key("$accountTwo.email.0").isEqualTo("two@domain.example")
+        }
+    }
+
+    @Test
+    fun `valid email addresses with additional spaces should be trimmed`() {
+        val account = createAccount(
+            "email.0" to " valid@domain.example ",
+        )
+        writeAccountUuids(account)
+
+        migration.fixIdentities()
+
+        assertThat(migrationHelper.readAllValues(database))
+            .key("$account.email.0").isEqualTo("valid@domain.example")
+    }
+
+    @Test
+    fun `invalid email addresses should be replaced`() {
+        val account = createAccount(
+            "email.0" to "",
+            "email.1" to "invalid",
+        )
+        writeAccountUuids(account)
+
+        migration.fixIdentities()
+
+        assertThat(migrationHelper.readAllValues(database)).all {
+            key("$account.email.0").isEqualTo("please.edit@invalid")
+            key("$account.email.1").isEqualTo("please.edit@invalid")
+        }
+    }
+
+    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
+    }
+}