Repository: thundernest/k-9
Issue: Two notifications for the same message

### 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

5.910

### Where did you get the app from?

Other

### Android version

doesn't matter

### Device model

_No response_

### Steps to reproduce

Use an IMAP server that sometimes sends multiple fetch responses for a single message.

Example:
```text
RealImapConnection: conn66388302>>> 8 UID FETCH 98594 (UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc reply-to message-id references in-reply-to X-K9mail-Identity Chat-Version)])
RealImapConnection: conn66388302<<<#null# [5320, FETCH, [UID, 98594, INTERNALDATE, 30-Jan-2022 18:02:23 +0100, RFC822.SIZE, 143729, BODY, [HEADER.FIELDS, [date, subject, from, content-type, to, cc, reply-to, message-id, references, in-reply-to, X-K9mail-Identity, Chat-Version]], {header fields} , FLAGS, [NonJunk]]]
RealImapConnection: conn66388302<<<#null# [5320, FETCH, [FLAGS, [NonJunk], UID, 98594]]
RealImapConnection: conn66388302<<<#8# [OK, FETCH complete]
```

### Expected behavior

The two FETCH responses should be combined and only be counted as one new message.


### Actual behavior

~~K-9 Mail's sync code currently counts this as 2 new messages and downloads the same message twice~~

~~The app will also create two notifications for that message.~~

The "new message" callback is invoked twice leading to two notifications being created.

### Logs

_No response_

Changed files:
app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt
app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt
app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt
app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt
app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt
app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt

diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt
index 190144862..c29c7791f 100644
--- a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt
+++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt
@@ -27,7 +27,9 @@ internal class NewMailNotificationController(
     fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) {
         val notificationData = newMailNotificationManager.addNewMailNotification(account, message, silent)
 
-        processNewMailNotificationData(notificationData)
+        if (notificationData != null) {
+            processNewMailNotificationData(notificationData)
+        }
     }
 
     fun removeNewMailNotifications(
diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt
index 8e1a8c3fe..3edfdb6ab 100644
--- a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt
+++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt
@@ -38,10 +38,10 @@ internal class NewMailNotificationManager(
         )
     }
 
-    fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean): NewMailNotificationData {
+    fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean): NewMailNotificationData? {
         val content = contentCreator.createFromMessage(account, message)
 
-        val result = notificationRepository.addNotification(account, content, timestamp = now())
+        val result = notificationRepository.addNotification(account, content, timestamp = now()) ?: return null
 
         val singleNotificationData = createSingleNotificationData(
             account = account,
diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt
index 50cd1be99..db99f4034 100644
--- a/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt
+++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt
@@ -2,7 +2,6 @@ package com.fsck.k9.notification
 
 import com.fsck.k9.Account
 import com.fsck.k9.controller.MessageReference
-import com.fsck.k9.core.BuildConfig
 
 internal const val MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = 8
 
@@ -30,15 +29,45 @@ internal class NotificationDataStore {
     }
 
     @Synchronized
-    fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult {
+    fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult? {
         val notificationData = getNotificationData(account)
         val messageReference = content.messageReference
 
-        if (BuildConfig.DEBUG && notificationData.contains(messageReference)) {
-            throw AssertionError("Notification for message $messageReference already exists")
+        val activeNotification = notificationData.activeNotifications.firstOrNull { notificationHolder ->
+            notificationHolder.content.messageReference == messageReference
         }
+        val inactiveNotification = notificationData.inactiveNotifications.firstOrNull { inactiveNotificationHolder ->
+            inactiveNotificationHolder.content.messageReference == messageReference
+        }
+
+        return if (activeNotification != null) {
+            val newActiveNotification = activeNotification.copy(content = content)
+            val notificationHolder = activeNotification.copy(
+                content = content
+            )
+
+            val operations = emptyList<NotificationStoreOperation>()
+
+            val newActiveNotifications = notificationData.activeNotifications
+                .replace(activeNotification, newActiveNotification)
+            val newNotificationData = notificationData.copy(
+                activeNotifications = newActiveNotifications
+            )
+            notificationDataMap[account.uuid] = newNotificationData
+
+            AddNotificationResult.newNotification(newNotificationData, operations, notificationHolder)
+        } else if (inactiveNotification != null) {
+            val newInactiveNotification = inactiveNotification.copy(content = content)
+            val newInactiveNotifications = notificationData.inactiveNotifications
+                .replace(inactiveNotification, newInactiveNotification)
 
-        return if (notificationData.isMaxNumberOfActiveNotificationsReached) {
+            val newNotificationData = notificationData.copy(
+                inactiveNotifications = newInactiveNotifications
+            )
+            notificationDataMap[account.uuid] = newNotificationData
+
+            null
+        } else if (notificationData.isMaxNumberOfActiveNotificationsReached) {
             val lastNotificationHolder = notificationData.activeNotifications.last()
             val inactiveNotificationHolder = lastNotificationHolder.toInactiveNotificationHolder()
 
@@ -175,14 +204,15 @@ internal class NotificationDataStore {
         throw AssertionError("getNewNotificationId() called with no free notification ID")
     }
 
-    private fun NotificationData.contains(messageReference: MessageReference): Boolean {
-        return activeNotifications.any { it.content.messageReference == messageReference } ||
-            inactiveNotifications.any { it.content.messageReference == messageReference }
-    }
-
     private fun NotificationHolder.toInactiveNotificationHolder() = InactiveNotificationHolder(timestamp, content)
 
     private fun InactiveNotificationHolder.toNotificationHolder(notificationId: Int): NotificationHolder {
         return NotificationHolder(notificationId, timestamp, content)
     }
+
+    private fun <T> List<T>.replace(old: T, new: T): List<T> {
+        return map { element ->
+            if (element === old) new else element
+        }
+    }
 }
diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt
index f624810c9..ddad7bb96 100644
--- a/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt
+++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt
@@ -37,8 +37,8 @@ internal class NotificationRepository(
     }
 
     @Synchronized
-    fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult {
-        return notificationDataStore.addNotification(account, content, timestamp).also { result ->
+    fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult? {
+        return notificationDataStore.addNotification(account, content, timestamp)?.also { result ->
             persistNotificationDataStoreChanges(
                 account = account,
                 operations = result.notificationStoreOperations,
diff --git a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt
index 72e8a193d..105f49649 100644
--- a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt
+++ b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt
@@ -49,6 +49,7 @@ class NewMailNotificationManagerTest {
 
         val result = manager.addNewMailNotification(account, message, silent = false)
 
+        assertNotNull(result)
         assertThat(result.singleNotificationData.first().content).isEqualTo(
             NotificationContent(
                 messageReference = createMessageReference("msg-1"),
@@ -85,6 +86,7 @@ class NewMailNotificationManagerTest {
 
         val result = manager.addNewMailNotification(account, messageTwo, silent = false)
 
+        assertNotNull(result)
         assertThat(result.singleNotificationData.first().content).isEqualTo(
             NotificationContent(
                 messageReference = createMessageReference("msg-2"),
@@ -121,6 +123,7 @@ class NewMailNotificationManagerTest {
 
         val result = manager.addNewMailNotification(account, message, silent = false)
 
+        assertNotNull(result)
         val notificationId = NotificationIds.getSingleMessageNotificationId(account, index = 0)
         assertThat(result.cancelNotificationIds).isEqualTo(listOf(notificationId))
         assertThat(result.singleNotificationData.first().notificationId).isEqualTo(notificationId)
@@ -196,6 +199,7 @@ class NewMailNotificationManagerTest {
             messageUid = "msg-2"
         )
         val dataTwo = manager.addNewMailNotification(account, messageTwo, silent = true)
+        assertNotNull(dataTwo)
         val notificationIdTwo = dataTwo.singleNotificationData.first().notificationId
         val messageThree = addMessageToNotificationContentCreator(
             sender = "Alice",
diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt
index a07463909..57ffc8aba 100644
--- a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt
+++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt
@@ -3,12 +3,8 @@ package com.fsck.k9.notification
 import com.fsck.k9.Account
 import com.fsck.k9.RobolectricTest
 import com.fsck.k9.controller.MessageReference
-import com.fsck.k9.core.BuildConfig
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertNotNull
-import org.junit.Assert.fail
-import org.junit.Assume.assumeFalse
-import org.junit.Assume.assumeTrue
 import org.junit.Test
 
 private const val ACCOUNT_UUID = "1-2-3"
@@ -26,6 +22,7 @@ class NotificationDataStoreTest : RobolectricTest() {
 
         val result = notificationDataStore.addNotification(account, content, TIMESTAMP)
 
+        assertNotNull(result)
         assertThat(result.shouldCancelNotification).isFalse()
 
         val holder = result.notificationHolder
@@ -48,6 +45,7 @@ class NotificationDataStoreTest : RobolectricTest() {
 
         val result = notificationDataStore.addNotification(account, createNotificationContent("9"), TIMESTAMP)
 
+        assertNotNull(result)
         assertThat(result.shouldCancelNotification).isTrue()
         assertThat(result.cancelNotificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0))
     }
@@ -107,19 +105,23 @@ class NotificationDataStoreTest : RobolectricTest() {
     fun testNewMessagesCount() {
         val contentOne = createNotificationContent("1")
         val resultOne = notificationDataStore.addNotification(account, contentOne, TIMESTAMP)
+        assertNotNull(resultOne)
         assertThat(resultOne.notificationData.newMessagesCount).isEqualTo(1)
 
         val contentTwo = createNotificationContent("2")
         val resultTwo = notificationDataStore.addNotification(account, contentTwo, TIMESTAMP)
+        assertNotNull(resultTwo)
         assertThat(resultTwo.notificationData.newMessagesCount).isEqualTo(2)
     }
 
     @Test
     fun testIsSingleMessageNotification() {
         val resultOne = notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP)
+        assertNotNull(resultOne)
         assertThat(resultOne.notificationData.isSingleMessageNotification).isTrue()
 
         val resultTwo = notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP)
+        assertNotNull(resultTwo)
         assertThat(resultTwo.notificationData.isSingleMessageNotification).isFalse()
     }
 
@@ -128,37 +130,58 @@ class NotificationDataStoreTest : RobolectricTest() {
         val content = createNotificationContent("1")
         val addResult = notificationDataStore.addNotification(account, content, TIMESTAMP)
 
+        assertNotNull(addResult)
         assertThat(addResult.notificationData.activeNotifications.first()).isEqualTo(addResult.notificationHolder)
     }
 
     @Test
-    fun `adding notification for same message twice should throw in debug build`() {
-        assumeTrue(BuildConfig.DEBUG)
-
+    fun `adding notification for message with active notification should update notification`() {
         val content1 = createNotificationContent("1")
         val content2 = createNotificationContent("1")
-        notificationDataStore.addNotification(account, content1, TIMESTAMP)
 
-        try {
-            notificationDataStore.addNotification(account, content2, TIMESTAMP)
-            fail("Exception expected")
-        } catch (e: AssertionError) {
-            assertThat(e).hasMessageThat().matches("Notification for message .+ already exists")
+        val resultOne = notificationDataStore.addNotification(account, content1, TIMESTAMP)
+        val resultTwo = notificationDataStore.addNotification(account, content2, TIMESTAMP)
+
+        assertNotNull(resultOne)
+        assertNotNull(resultTwo)
+        assertThat(resultTwo.notificationData.activeNotifications).hasSize(1)
+        assertThat(resultTwo.notificationData.activeNotifications.first().content).isSameInstanceAs(content2)
+        assertThat(resultTwo.notificationStoreOperations).isEmpty()
+        with(resultTwo.notificationHolder) {
+            assertThat(notificationId).isEqualTo(resultOne.notificationHolder.notificationId)
+            assertThat(timestamp).isEqualTo(resultOne.notificationHolder.timestamp)
+            assertThat(content).isSameInstanceAs(content2)
         }
+        assertThat(resultTwo.shouldCancelNotification).isFalse()
     }
 
     @Test
-    fun `adding notification for same message twice should add another notification in release build`() {
-        assumeFalse(BuildConfig.DEBUG)
+    fun `adding notification for message with inactive notification should update notificationData`() {
+        notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP)
+        notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP)
+        val latestNotificationContent = createNotificationContent("9")
+        notificationDataStore.addNotification(account, latestNotificationContent, TIMESTAMP)
+        val content = createNotificationContent("1")
 
-        val content1 = createNotificationContent("1")
-        val content2 = createNotificationContent("1")
+        val resultOne = notificationDataStore.addNotification(account, content, TIMESTAMP)
 
-        val addResult1 = notificationDataStore.addNotification(account, content1, TIMESTAMP)
-        val addResult2 = notificationDataStore.addNotification(account, content2, TIMESTAMP)
+        assertThat(resultOne).isNull()
 
-        assertThat(addResult1.notificationHolder.notificationId)
-            .isNotEqualTo(addResult2.notificationHolder.notificationId)
+        val resultTwo = notificationDataStore.removeNotifications(account) {
+            listOf(latestNotificationContent.messageReference)
+        }
+
+        assertNotNull(resultTwo)
+        val notificationHolder = resultTwo.notificationData.activeNotifications.first { notificationHolder ->
+            notificationHolder.content.messageReference == content.messageReference
+        }
+        assertThat(notificationHolder.content).isSameInstanceAs(content)
     }
 
     private fun createAccount(): Account {