Repository: element-hq/element-x-android (language: kotlin)

Issue:
"Verification complete" snackbar is shown too much
1. Login to an account and verify the session.
2. The snackbar is displayed, this is fine
3. Pause and resume the app
4. The Snackbar is displayed again.

3 and 4 can be repeated indefinitely.

Kill the app and restarting it fixes the issue.

A tentative fix was done in #811, but this was not enough apparently.


Discussion hints:
nan

Pull request: Make sure Snackbars are only displayed once
<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Type of change

- [ ] Feature
- [x] Bugfix
- [ ] Technical
- [ ] Other :

## Content

- Made `SnackbarDispatcher.snackbarMessage` a Flow backed by a queue.
- Added `SnackbarMessage.isDisplayed` property to check if the message is already being displayed.
- Clears the `SnackbarDispatcher` queue too when the coroutine displaying the current message is cancelled.

## Motivation and context

Fixes #928.

## Tests

<!-- Explain how you tested your development -->
In an unverified session:
- Go to Settings -> Complete verification.
- Finish the verification.
- See the snackbar being displayed.
- Go back to the room list. No snackbar should be displayed here.

An alternative test is to go to your home screen and then back to the app.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): 11.

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [x] Pull request is based on the develop branch
- [x] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [x] You've made a self review of your PR


Test command: ./gradlew :libraries:designsystem:testDebugUnitTest --tests "io.element.android.libraries.designsystem.utils.SnackbarDispatcherTests"

Fix patch:
diff --git a/changelog.d/928.bugfix b/changelog.d/928.bugfix
new file mode 100644
index 00000000000..98a4cd34e06
--- /dev/null
+++ b/changelog.d/928.bugfix
@@ -0,0 +1,1 @@
+Make sure Snackbars are only displayed once.
diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts
index 35b7a65cb4c..f6eacf87462 100644
--- a/libraries/designsystem/build.gradle.kts
+++ b/libraries/designsystem/build.gradle.kts
@@ -43,5 +43,11 @@ android {
 
         ksp(libs.showkase.processor)
         kspTest(libs.showkase.processor)
+
+        testImplementation(libs.test.junit)
+        testImplementation(libs.coroutines.test)
+        testImplementation(libs.molecule.runtime)
+        testImplementation(libs.test.truth)
+        testImplementation(libs.test.turbine)
     }
 }
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
index c689d7e5479..9458bf748ac 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
@@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp
 import io.element.android.libraries.designsystem.components.button.ButtonVisuals
 import io.element.android.libraries.designsystem.theme.components.IconSource
 import io.element.android.libraries.designsystem.theme.components.Snackbar
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.atomic.AtomicBoolean
 
 /**
  * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
  */
 class SnackbarDispatcher {
-    private val mutex = Mutex()
-
-    private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
-    val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
+    private val queueMutex = Mutex()
+    private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
+    val snackbarMessage: Flow<SnackbarMessage?> = flow {
+        while (currentCoroutineContext().isActive) {
+            queueMutex.lock()
+            emit(snackBarMessageQueue.firstOrNull())
+        }
+    }
 
     suspend fun post(message: SnackbarMessage) {
-        mutex.withLock {
-            _snackbarMessage.update { message }
+        if (snackBarMessageQueue.isEmpty()) {
+            snackBarMessageQueue.add(message)
+            if (queueMutex.isLocked) queueMutex.unlock()
+        } else {
+            snackBarMessageQueue.add(message)
         }
     }
 
-    suspend fun clear() {
-        mutex.withLock {
-            _snackbarMessage.update { null }
+    fun clear() {
+        if (snackBarMessageQueue.isNotEmpty()) {
+            snackBarMessageQueue.removeFirstOrNull()
+            if (queueMutex.isLocked) queueMutex.unlock()
         }
     }
 }
@@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) {
     }
 }
 
+/**
+ * Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations.
+ */
 @Composable
 fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
     val snackbarHostState = remember { SnackbarHostState() }
     val snackbarMessageText = snackbarMessage?.let {
         stringResource(id = snackbarMessage.messageResId)
-    }
+    } ?: return snackbarHostState
+
     val dispatcher = LocalSnackbarDispatcher.current
-    LaunchedEffect(snackbarMessage) {
-        if (snackbarMessageText == null) return@LaunchedEffect
-        launch {
-            snackbarHostState.showSnackbar(
-                message = snackbarMessageText,
-                duration = snackbarMessage.duration,
-            )
-            if (isActive) {
+    LaunchedEffect(snackbarMessageText) {
+        // If the message wasn't already displayed, do it now, and mark it as displayed
+        // This will prevent the message from appearing in any other active SnackbarHosts
+        if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
+            try {
+                snackbarHostState.showSnackbar(
+                    message = snackbarMessageText,
+                    duration = snackbarMessage.duration,
+                )
+                // The snackbar item was displayed and dismissed, clear its message
+                dispatcher.clear()
+            } catch (e: CancellationException) {
+                // The snackbar was being displayed when the coroutine was cancelled,
+                // so we need to clear its message
                 dispatcher.clear()
+                throw e
             }
         }
     }
     return snackbarHostState
 }
 
+/**
+ * A message to be displayed in a [Snackbar].
+ * @param messageResId The message to be displayed.
+ * @param duration The duration of the message. The default value is [SnackbarDuration.Short].
+ * @param actionResId The action text to be displayed. The default value is `null`.
+ * @param isDisplayed Used to track if the current message is already displayed or not.
+ * @param action The action to be performed when the action is clicked.
+ */
 data class SnackbarMessage(
     @StringRes val messageResId: Int,
     val duration: SnackbarDuration = SnackbarDuration.Short,
     @StringRes val actionResId: Int? = null,
+    val isDisplayed: AtomicBoolean = AtomicBoolean(false),
     val action: () -> Unit = {},
 )


Test patch:
diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt
new file mode 100644
index 00000000000..3eb644d8008
--- /dev/null
+++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.utils
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SnackbarDispatcherTests {
+
+    @Test
+    fun `given an empty queue the flow emits a null item`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+    @Test
+    fun `given an empty queue calling clear does nothing`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            assertThat(awaitItem()).isNull()
+            snackbarDispatcher.clear()
+            expectNoEvents()
+        }
+    }
+
+    @Test
+    fun `given a non-empty queue the flow emits an item`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            snackbarDispatcher.post(SnackbarMessage(0))
+            val result = expectMostRecentItem()
+            assertThat(result).isNotNull()
+        }
+    }
+
+    @Test
+    fun `given a call to clear, the current message is cleared`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            snackbarDispatcher.post(SnackbarMessage(0))
+            val item = expectMostRecentItem()
+            assertThat(item).isNotNull()
+            snackbarDispatcher.clear()
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+    @Test
+    fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            val messageA = SnackbarMessage(0)
+            val messageB = SnackbarMessage(1)
+
+            // Send message A - it is the most recent item
+            snackbarDispatcher.post(messageA)
+            assertThat(expectMostRecentItem()).isEqualTo(messageA)
+
+            // Send message B - message A is still the most recent item
+            snackbarDispatcher.post(messageB)
+            expectNoEvents()
+
+            // Clear the last message - message B is now the most recent item
+            snackbarDispatcher.clear()
+            assertThat(expectMostRecentItem()).isEqualTo(messageB)
+
+            // Clear again - the queue is empty
+            snackbarDispatcher.clear()
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+}