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

Issue:
Downloading currently gives no feedback if it started or if it is finished
### Your use case

#### What would you like to do?

Currently EX is missing any indication that download has started/finished and it would suit to have some sort of information 

#### Why would you like to do it?

Element had it, element x should also have it. 

#### How would you like to achieve it?

Simple toast message, notification/ message in notification are that download has started or something similar


### Have you considered any alternatives?

_No response_

### Additional context

_No response_

### Are you willing to provide a PR?

No


Discussion hints:
nan

Pull request: Fix snack bar not displayed in MediaViewer
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Content

<!-- Describe shortly what has been changed -->
The SnackBar was not displayed in the MediaViewer because it's in a Node which is displayed in Overlay mode. In this case, the Screen below (Messages or Gallery) is consuming the SnackBar. Using a local `SnackbarDispatcher` in `MediaViewerPresenter` fixes the issue. This is commit d36b4139f8011a6c0711c65a1a9fcb198defa9b8

c3e148b97c98efa8658ee8b7acee8f53763be07d is closing the bottom sheet once an action is selected, to either leave space for the share native bottom sheet or the "saved" snackbar.

Other commits are cleanup.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #4128 

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

https://github.com/user-attachments/assets/b2b617e6-cbcc-4b6f-a2bf-9a2bc79a70f2

## Tests

<!-- Explain how you tested your development -->

- Open an image and save it to disk.
- You should see a confirmation snackbar.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## 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 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR


Test command: ./gradlew :libraries:mediaviewer:impl:testDebugUnitTest --tests "io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPresenterTest"

Fix patch:
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
index f3ca8b92346..2759e49f70a 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
@@ -45,7 +45,7 @@ class LoggedInEventProcessor @Inject constructor(
         observingJob = null
     }
 
-    private suspend fun displayMessage(message: Int) {
+    private fun displayMessage(message: Int) {
         snackbarDispatcher.post(SnackbarMessage(message))
     }
 }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index da50881d146..a01a3ed3c8f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -484,7 +484,7 @@ class MessagesPresenter @AssistedInject constructor(
         )
     }
 
-    private suspend fun handleCopyContents(event: TimelineItem.Event) {
+    private fun handleCopyContents(event: TimelineItem.Event) {
         val content = when (event.content) {
             is TimelineItemTextBasedContent -> event.content.body
             is TimelineItemStateContent -> event.content.body
@@ -496,7 +496,7 @@ class MessagesPresenter @AssistedInject constructor(
         }
     }
 
-    private suspend fun handleCopyCaption(event: TimelineItem.Event) {
+    private fun handleCopyCaption(event: TimelineItem.Event) {
         val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
         clipboardHelper.copyPlainText(content)
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt
index 1fcd1b792f6..03f44241581 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt
@@ -8,7 +8,6 @@
 package io.element.android.features.securebackup.impl.setup
 
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import com.bumble.appyx.core.modality.BuildContext
 import com.bumble.appyx.core.node.Node
@@ -22,8 +21,6 @@ import io.element.android.libraries.architecture.inputs
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
 import io.element.android.libraries.di.SessionScope
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
 
 @ContributesNode(SessionScope::class)
 class SecureBackupSetupNode @AssistedInject constructor(
@@ -42,12 +39,11 @@ class SecureBackupSetupNode @AssistedInject constructor(
 
     @Composable
     override fun View(modifier: Modifier) {
-        val coroutineScope = rememberCoroutineScope()
         val state = presenter.present()
         SecureBackupSetupView(
             state = state,
             onSuccess = {
-                coroutineScope.postSuccessSnackbar()
+                postSuccessSnackbar()
                 navigateUp()
             },
             onBackClick = ::navigateUp,
@@ -55,7 +51,7 @@ class SecureBackupSetupNode @AssistedInject constructor(
         )
     }
 
-    private fun CoroutineScope.postSuccessSnackbar() = launch {
+    private fun postSuccessSnackbar() {
         snackbarDispatcher.post(
             SnackbarMessage(
                 messageResId = if (inputs.isChangeRecoveryKeyUserStory) {
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
index 336d706d2ed..52fe5979e6d 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
@@ -36,7 +36,7 @@ class SnackbarDispatcher {
         }
     }
 
-    suspend fun post(message: SnackbarMessage) {
+    fun post(message: SnackbarMessage) {
         if (snackBarMessageQueue.isEmpty()) {
             snackBarMessageQueue.add(message)
             if (queueMutex.isLocked) queueMutex.unlock()
@@ -54,7 +54,7 @@ class SnackbarDispatcher {
 }
 
 /** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */
-val LocalSnackbarDispatcher = compositionLocalOf<SnackbarDispatcher> { SnackbarDispatcher() }
+val LocalSnackbarDispatcher = compositionLocalOf { SnackbarDispatcher() }
 
 @Composable
 fun SnackbarDispatcher.collectSnackbarMessageAsState(): State<SnackbarMessage?> {
@@ -72,10 +72,10 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
     } ?: return snackbarHostState
 
     val dispatcher = LocalSnackbarDispatcher.current
-    LaunchedEffect(snackbarMessageText) {
+    LaunchedEffect(snackbarMessage.id) {
         // 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) {
+        if (snackbarMessage.isDisplayed.getAndSet(true).not()) {
             try {
                 snackbarHostState.showSnackbar(
                     message = snackbarMessageText,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt
index 2597944c599..fb9be76f988 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.utils.snackbar
 import androidx.annotation.StringRes
 import androidx.compose.material3.SnackbarDuration
 import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.random.Random
 
 /**
  * A message to be displayed in a [Snackbar].
@@ -17,6 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean
  * @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 id The unique identifier of the message. The default value is a random long.
  * @param action The action to be performed when the action is clicked.
  */
 data class SnackbarMessage(
@@ -24,5 +26,6 @@ data class SnackbarMessage(
     val duration: SnackbarDuration = SnackbarDuration.Short,
     @StringRes val actionResId: Int? = null,
     val isDisplayed: AtomicBoolean = AtomicBoolean(false),
+    val id: Long = Random.nextLong(),
     val action: () -> Unit = {},
 )
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
index 476e86c5c58..e094e1b9fbd 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
@@ -86,11 +86,13 @@ class MediaGalleryPresenter @AssistedInject constructor(
                     mediaGalleryDataSource.deleteItem(event.eventId)
                 }
                 is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
+                    mediaBottomSheetState = MediaBottomSheetState.Hidden
                     groupedMediaItems.dataOrNull().find(event.eventId)?.let {
                         saveOnDisk(it)
                     }
                 }
                 is MediaGalleryEvents.Share -> coroutineScope.launch {
+                    mediaBottomSheetState = MediaBottomSheetState.Hidden
                     groupedMediaItems.dataOrNull().find(event.eventId)?.let {
                         share(it)
                     }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
index a801437e825..b039576e0ab 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
@@ -44,7 +44,6 @@ class MediaViewerPresenter @AssistedInject constructor(
     @Assisted private val dataSource: MediaViewerDataSource,
     private val room: MatrixRoom,
     private val localMediaActions: LocalMediaActions,
-    private val snackbarDispatcher: SnackbarDispatcher,
 ) : Presenter<MediaViewerState> {
     @AssistedFactory
     interface Factory {
@@ -55,6 +54,9 @@ class MediaViewerPresenter @AssistedInject constructor(
         ): MediaViewerPresenter
     }
 
+    // Use a local snackbarDispatcher because this presenter is used in an Overlay Node
+    private val snackbarDispatcher = SnackbarDispatcher()
+
     @Composable
     override fun present(): MediaViewerState {
         val coroutineScope = rememberCoroutineScope()


Test patch:
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
index ac0acfe7193..47e48ca9e15 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
@@ -13,7 +13,6 @@ import android.net.Uri
 import app.cash.turbine.ReceiveTurbine
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -568,7 +567,6 @@ class MediaViewerPresenterTest {
         eventId: EventId? = null,
         matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
         localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
-        snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
         mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
             startLambda = { },
         ),
@@ -598,7 +596,6 @@ class MediaViewerPresenterTest {
             ),
             room = room,
             localMediaActions = localMediaActions,
-            snackbarDispatcher = snackbarDispatcher,
         )
     }
 }