Verification UI / UX iteration

<!-- 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 -->
Iterate on a few screens of the verification flows

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #3824

## 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|
|-|-|
|||
 -->
See the recorded screenshots

## Tests

<!-- Explain how you tested your development -->

- Perform a login followed by a session verification using another device
- From an other device, perform a session verification flow.

## 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 23
- [ ] 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
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

Incoming verification iteration

Incoming verification iteration
- [ ] Verification cancelled: show the error screen.
- [x] Add new intermediate screen:

- [ ] Check this screen copy:

diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
index cc2c9fbd2d0..e8f098f848e 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
@@ -80,14 +80,14 @@ fun IncomingVerificationView(
 @Composable
 private fun IncomingVerificationHeader(step: Step) {
     val iconStyle = when (step) {
-        Step.Canceled,
+        Step.Canceled -> BigIcon.Style.AlertSolid
         is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
         is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
         Step.Completed -> BigIcon.Style.SuccessSolid
         Step.Failure -> BigIcon.Style.AlertSolid
     }
     val titleTextId = when (step) {
-        Step.Canceled -> CommonStrings.common_verification_cancelled
+        Step.Canceled -> R.string.screen_session_verification_request_failure_title
         is Step.Initial -> R.string.screen_session_verification_request_title
         is Step.Verifying -> when (step.data) {
             is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
@@ -97,7 +97,7 @@ private fun IncomingVerificationHeader(step: Step) {
         Step.Failure -> R.string.screen_session_verification_request_failure_title
     }
     val subtitleTextId = when (step) {
-        Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
+        Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
         is Step.Initial -> R.string.screen_session_verification_request_subtitle
         is Step.Verifying -> when (step.data) {
             is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
index 360bf648750..97778b322b1 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
@@ -104,6 +104,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
         fun handleEvents(event: VerifySelfSessionViewEvents) {
             Timber.d("Verification user action: ${event::class.simpleName}")
             when (event) {
+                VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
                 VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
                 VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
                 VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
@@ -134,6 +135,9 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
                     isLastDevice = encryptionService.isLastDevice.value
                 )
             }
+            VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
+                VerifySelfSessionState.Step.UseAnotherDevice
+            }
             StateMachineState.RequestingVerification,
             StateMachineState.StartingSasVerification,
             StateMachineState.SasVerificationStarted,
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
index e763305caa2..32664b347f0 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
@@ -26,6 +26,7 @@ data class VerifySelfSessionState(

         // FIXME canEnterRecoveryKey value is never read.
         data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
+        data object UseAnotherDevice : Step
         data object Canceled : Step
         data object AwaitingOtherDeviceResponse : Step
         data object Ready : Step
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
index f423b14aae3..a67cc4d3318 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
@@ -38,12 +38,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
     init {
         spec {
             inState<State.Initial> {
+                on { _: Event.UseAnotherDevice, state ->
+                    state.override { State.UseAnotherDevice.andLogStateChange() }
+                }
+            }
+            inState<State.UseAnotherDevice> {
                 on { _: Event.RequestVerification, state ->
                     state.override { State.RequestingVerification.andLogStateChange() }
                 }
-                on { _: Event.StartSasVerification, state ->
-                    state.override { State.StartingSasVerification.andLogStateChange() }
-                }
             }
             inState<State.RequestingVerification> {
                 onEnterEffect {
@@ -64,9 +66,6 @@ class VerifySelfSessionStateMachine @Inject constructor(
                 }
             }
             inState<State.Canceled> {
-                on { _: Event.RequestVerification, state ->
-                    state.override { State.RequestingVerification.andLogStateChange() }
-                }
                 on { _: Event.Reset, state ->
                     state.override { State.Initial.andLogStateChange() }
                 }
@@ -119,6 +118,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
                 on { _: Event.Cancel, state: MachineState<State> ->
                     when (state.snapshot) {
                         State.Initial, State.Completed, State.Canceled -> state.noChange()
+                        State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
                         // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
                         // `Canceling` state to `Canceled` automatically anymore
                         else -> {
@@ -144,6 +144,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
         /** The initial state, before verification started. */
         data object Initial : State

+        /** Let the user know that they need to get ready on their other session. */
+        data object UseAnotherDevice : State
+
         /** Waiting for verification acceptance. */
         data object RequestingVerification : State

@@ -175,6 +178,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
     }

     sealed interface Event {
+        /** User wants to use another session. */
+        data object UseAnotherDevice : Event
+
         /** Request verification. */
         data object RequestVerification : Event

diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
index 8cb60a822f7..b9de96aae48 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
@@ -56,6 +56,9 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
             aVerifySelfSessionState(
                 step = Step.Skipped
             ),
+            aVerifySelfSessionState(
+                step = Step.UseAnotherDevice
+            ),
             // Add other state here
         )
 }
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
index 9bb59daa6f0..afebffea471 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
@@ -66,7 +66,9 @@ fun VerifySelfSessionView(
     fun cancelOrResetFlow() {
         when (step) {
             is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
-            is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
+            is Step.AwaitingOtherDeviceResponse,
+            Step.UseAnotherDevice,
+            Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
             is Step.Verifying -> {
                 if (!step.state.isLoading()) {
                     state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
@@ -159,7 +161,9 @@ fun VerifySelfSessionView(
 private fun VerifySelfSessionHeader(step: Step) {
     val iconStyle = when (step) {
         Step.Loading -> error("Should not happen")
-        is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
+        is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
+        Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
+        Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
         Step.Canceled -> BigIcon.Style.AlertSolid
         Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
         Step.Completed -> BigIcon.Style.SuccessSolid
@@ -167,8 +171,10 @@ private fun VerifySelfSessionHeader(step: Step) {
     }
     val titleTextId = when (step) {
         Step.Loading -> error("Should not happen")
-        is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
-        Step.Canceled -> CommonStrings.common_verification_cancelled
+        is Step.Initial -> R.string.screen_identity_confirmation_title
+        Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
+        Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
+        Step.Canceled -> CommonStrings.common_verification_failed
         Step.Ready -> R.string.screen_session_verification_compare_emojis_title
         Step.Completed -> R.string.screen_identity_confirmed_title
         is Step.Verifying -> when (step.data) {
@@ -179,8 +185,10 @@ private fun VerifySelfSessionHeader(step: Step) {
     }
     val subtitleTextId = when (step) {
         Step.Loading -> error("Should not happen")
-        is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
-        Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
+        is Step.Initial -> R.string.screen_identity_confirmation_subtitle
+        Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
+        Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
+        Step.Canceled -> R.string.screen_session_verification_failed_subtitle
         Step.Ready -> R.string.screen_session_verification_ready_subtitle
         Step.Completed -> R.string.screen_identity_confirmed_subtitle
         is Step.Verifying -> when (step.data) {
@@ -252,7 +260,7 @@ private fun VerifySelfSessionBottomMenu(
                     Button(
                         modifier = Modifier.fillMaxWidth(),
                         text = stringResource(R.string.screen_identity_use_another_device),
-                        onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
+                        onClick = { eventSink(VerifySelfSessionViewEvents.UseAnotherDevice) },
                     )
                 }
                 Button(
@@ -267,18 +275,26 @@ private fun VerifySelfSessionBottomMenu(
                 )
             }
         }
-        is Step.Canceled -> {
+        is Step.UseAnotherDevice -> {
             VerificationBottomMenu {
                 Button(
                     modifier = Modifier.fillMaxWidth(),
-                    text = stringResource(R.string.screen_session_verification_positive_button_canceled),
+                    text = stringResource(CommonStrings.action_start_verification),
                     onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
                 )
-                TextButton(
+                // Placeholder so the 1st button keeps its vertical position
+                Spacer(modifier = Modifier.height(40.dp))
+            }
+        }
+        is Step.Canceled -> {
+            VerificationBottomMenu {
+                Button(
                     modifier = Modifier.fillMaxWidth(),
-                    text = stringResource(CommonStrings.action_cancel),
+                    text = stringResource(CommonStrings.action_done),
                     onClick = onCancelClick,
                 )
+                // Placeholder so the 1st button keeps its vertical position
+                Spacer(modifier = Modifier.height(40.dp))
             }
         }
         is Step.Ready -> {
@@ -302,6 +318,7 @@ private fun VerifySelfSessionBottomMenu(
                     text = stringResource(R.string.screen_identity_waiting_on_other_device),
                     onClick = {},
                     showProgress = true,
+                    enabled = false,
                 )
                 // Placeholder so the 1st button keeps its vertical position
                 Spacer(modifier = Modifier.height(40.dp))
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
index 869bdc7051b..b4af38f7801 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
@@ -8,6 +8,7 @@
 package io.element.android.features.verifysession.impl.outgoing

 sealed interface VerifySelfSessionViewEvents {
+    data object UseAnotherDevice : VerifySelfSessionViewEvents
     data object RequestVerification : VerifySelfSessionViewEvents
     data object StartSasVerification : VerifySelfSessionViewEvents
     data object ConfirmVerification : VerifySelfSessionViewEvents
diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml
index f67a2024b92..db18590cbe2 100644
--- a/features/verifysession/impl/src/main/res/values/localazy.xml
+++ b/features/verifysession/impl/src/main/res/values/localazy.xml
@@ -35,6 +35,10 @@
   <string name="screen_session_verification_request_title">"Verification requested"</string>
   <string name="screen_session_verification_they_dont_match">"They don’t match"</string>
   <string name="screen_session_verification_they_match">"They match"</string>
+  <string name="screen_session_verification_use_another_device_subtitle">"Make sure you have the app open in the other device before starting verification from here."</string>
+  <string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string>
+  <string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string>
+  <string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string>
   <string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string>
   <string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string>
   <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>

diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
index bfdf27ee5d2..d8fe2f671c5 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
@@ -7,10 +7,7 @@

 package io.element.android.features.verifysession.impl.outgoing

-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
 import app.cash.turbine.ReceiveTurbine
-import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
 import io.element.android.features.logout.api.LogoutUseCase
 import io.element.android.features.logout.test.FakeLogoutUseCase
@@ -33,6 +30,7 @@ import io.element.android.tests.testutils.WarmUpRule
 import io.element.android.tests.testutils.lambda.lambdaError
 import io.element.android.tests.testutils.lambda.lambdaRecorder
 import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runTest
 import org.junit.Rule
@@ -48,9 +46,7 @@ class VerifySelfSessionPresenterTest {
         val presenter = createVerifySelfSessionPresenter(
             service = unverifiedSessionService(),
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             awaitItem().run {
                 assertThat(step).isEqualTo(Step.Initial(false))
                 assertThat(displaySkipButton).isTrue()
@@ -65,9 +61,7 @@ class VerifySelfSessionPresenterTest {
             service = unverifiedSessionService(),
             buildMeta = buildMeta,
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             assertThat(awaitItem().displaySkipButton).isFalse()
         }
     }
@@ -83,9 +77,7 @@ class VerifySelfSessionPresenterTest {
                 emitRecoveryState(RecoveryState.INCOMPLETE)
             }
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
             resetLambda.assertions().isCalledOnce().with(value(true))
         }
@@ -100,9 +92,7 @@ class VerifySelfSessionPresenterTest {
                 emitRecoveryState(RecoveryState.INCOMPLETE)
             }
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
         }
     }
@@ -114,43 +104,17 @@ class VerifySelfSessionPresenterTest {
             startVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             requestVerificationAndAwaitVerifyingState(service)
         }
     }

-    @Test
-    fun `present - Handles startSasVerification`() = runTest {
-        val service = unverifiedSessionService(
-            startVerificationLambda = { },
-        )
-        val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
-            val initialState = awaitItem()
-            assertThat(initialState.step).isEqualTo(Step.Initial(false))
-            initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
-            // Await for other device response:
-            assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
-            service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
-            // ChallengeReceived:
-            service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
-            val verifyingState = awaitItem()
-            assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
-        }
-    }
-
     @Test
     fun `present - Cancellation on initial state does nothing`() = runTest {
         val presenter = createVerifySelfSessionPresenter(
             service = unverifiedSessionService(),
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val initialState = awaitItem()
             assertThat(initialState.step).isEqualTo(Step.Initial(false))
             val eventSink = initialState.eventSink
@@ -167,9 +131,7 @@ class VerifySelfSessionPresenterTest {
             approveVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(service)
             state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
             // Cancelling
@@ -186,9 +148,8 @@ class VerifySelfSessionPresenterTest {
             requestVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
+            awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
             awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
             service.emitVerificationFlowState(VerificationFlowState.DidFail)
             assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
@@ -204,9 +165,7 @@ class VerifySelfSessionPresenterTest {
             cancelVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(service)
             state.eventSink(VerifySelfSessionViewEvents.Cancel)
             assertThat(awaitItem().step).isEqualTo(Step.Canceled)
@@ -220,35 +179,13 @@ class VerifySelfSessionPresenterTest {
             startVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             requestVerificationAndAwaitVerifyingState(service)
             service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
             ensureAllEventsConsumed()
         }
     }

-    @Test
-    fun `present - Restart after cancellation returns to requesting verification`() = runTest {
-        val service = unverifiedSessionService(
-            requestVerificationLambda = { },
-            startVerificationLambda = { },
-        )
-        val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
-            val state = requestVerificationAndAwaitVerifyingState(service)
-            service.emitVerificationFlowState(VerificationFlowState.DidCancel)
-            assertThat(awaitItem().step).isEqualTo(Step.Canceled)
-            state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
-            // Went back to requesting verification
-            assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
-            cancelAndIgnoreRemainingEvents()
-        }
-    }
-
     @Test
     fun `present - Go back after cancellation returns to initial state`() = runTest {
         val service = unverifiedSessionService(
@@ -256,9 +193,7 @@ class VerifySelfSessionPresenterTest {
             startVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(service)
             service.emitVerificationFlowState(VerificationFlowState.DidCancel)
             assertThat(awaitItem().step).isEqualTo(Step.Canceled)
@@ -280,9 +215,7 @@ class VerifySelfSessionPresenterTest {
             approveVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(
                 service,
                 SessionVerificationData.Emojis(emojis)
@@ -307,9 +240,7 @@ class VerifySelfSessionPresenterTest {
             declineVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(service)
             state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
             assertThat(awaitItem().step).isEqualTo(
@@ -330,9 +261,7 @@ class VerifySelfSessionPresenterTest {
             startVerificationLambda = { },
         )
         val presenter = createVerifySelfSessionPresenter(service)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             val state = requestVerificationAndAwaitVerifyingState(service)
             state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
             assertThat(awaitItem().step).isEqualTo(Step.Skipped)
@@ -352,9 +281,7 @@ class VerifySelfSessionPresenterTest {
             service = service,
             showDeviceVerifiedScreen = true,
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             assertThat(awaitItem().step).isEqualTo(Step.Completed)
         }
     }
@@ -372,9 +299,7 @@ class VerifySelfSessionPresenterTest {
             service = service,
             showDeviceVerifiedScreen = false,
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             skipItems(1)
             assertThat(awaitItem().step).isEqualTo(Step.Skipped)
         }
@@ -394,9 +319,7 @@ class VerifySelfSessionPresenterTest {
             service,
             logoutUseCase = FakeLogoutUseCase(signOutLambda)
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        presenter.test {
             skipItems(1)
             val initialItem = awaitItem()
             initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
@@ -414,6 +337,9 @@ class VerifySelfSessionPresenterTest {
     ): VerifySelfSessionState {
         var state = awaitItem()
         assertThat(state.step).isEqualTo(Step.Initial(false))
+        state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
+        state = awaitItem()
+        assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
         state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
         // Await for other device response:
         fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en.png
index 0aa47d9aa96..1c316959ed7 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:bee1454db757b0897ae2113d3a11ed9adef0eb6bc52f3775edac56ed8533a88f
-size 24076
+oid sha256:d3340a2d29e6c1d86f5ec5664179cae9501fcc95381311174d7d6b45b15af326
+size 24123
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en.png
index 32fa8a3383f..1c46f8eb533 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:59a23ec5e3086349d3382f006cf1bcb4121183c83ea8c749aa95e3160561aef1
-size 23524
+oid sha256:c6c7e8cdf40bdf018931635565ac649fed518140a047a71cf70cf63e9edf54d3
+size 23932
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_13_en.png
new file mode 100644
index 00000000000..567462d90f9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_13_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:18dadaebe7a32aacde31afa0352a343913955b099ca4a07851e3ffe75e88b4d6
+size 31012
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_1_en.png
index 130cc1d84a0..29667b296d6 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_1_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:2cc647747c41f4c2cf96a3425e8b279b39fd4e09e1441e5b5afcd260f79afaa1
-size 22714
+oid sha256:71b9f32b26b391ff3bf231ca9f364a157f535c1e6ff52ee3e3ead3630bb1b239
+size 30007
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_4_en.png
index 4e84bcb0c25..1faa04af50c 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Day_4_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:a7fe45575eb8423161d355d9b99ecff4acc0f708e5b106d9d38aa2657942d736
-size 28219
+oid sha256:000374157cb5fbf6670b4af041fc385538df78bf4aecaf83ea24d39dfec84f23
+size 24278
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_13_en.png
new file mode 100644
index 00000000000..bebfa94f6d2
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_13_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4dd274f8c2ade6213a13a47400ec3a571844eafcc82b292b1c813bd9aa098236
+size 30241
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_1_en.png
index 7e3a1136101..af8d7eadbee 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_1_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:786a63d0d824657270b44428a1a251d0d521e6a97a4239516ae5f2eff06fdc3b
-size 22125
+oid sha256:994b39ba25e011cce97504a1f3d4ed0c420b7bce87daafae77ba481cc629cdae
+size 29051
diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_4_en.png
index 76173e1b2d5..d9b8a3a0ded 100644
--- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.outgoing_VerifySelfSessionView_Night_4_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:c29d3f5bbff739d23800345feb42e73fe9374c5cb5db27690278fb22c5546fe3
-size 27754
+oid sha256:3a3f08002e805fe5f7a4c96aa4b73c2fcd6e8b79e6a9e82bcc7bd3df50d8c22d
+size 24015


Iteration on caption

<!-- 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 -->
This PR is following #3803 .
- Improve the last message preview in the room list for media. Either use the caption, or use the filename, as per EX on iOS.
- Ensure that filename of voice message are not rendered to the user.
- Render the caption in the notification, below the image.
- Other small bug fixes.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Polish the application

## 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|
|-|-|
|||
 -->

## Tests

<!-- Explain how you tested your development -->

- Alice send media with caption to Bob
- Bob can see the caption in the notification, and in the room list

## 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 23
- [ ] 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
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

Notifications of images with MSC2530 captions show no text
Notification:
![Screenshot_20240325-161912.png](https://github.com/element-hq/element-x-android/assets/775104/2700f3c4-d034-49c3-bc8a-a691d97b168e)

Message:
![Screenshot_20240325-162028.png](https://github.com/element-hq/element-x-android/assets/775104/ffaf241d-da1b-4966-9ac7-a510d279025e)

This should also display the image caption below the picture in the notification

I haven't updated SchildiChat to ElementX v0.4.7 yet, but if anybody wants to check if our old implementation of this is applicable: https://github.com/SchildiChat/schildichat-android-next/commit/9046a0a74b3b59a70d972fdb89f6578966d97485

Maybe I'll PR this once I get Schildi updated to the upstream caption implementation. If someone else wants to PR my changes I'm fine with signing that off as well.

Reminder to self: need to check if upstream has/needs https://github.com/SchildiChat/schildichat-android-next/commit/54b420631844fa03470aadcd454d9b31fdf49f5b

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
index 675f9adfc6d..849084ea1cc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
@@ -70,14 +70,14 @@ fun TimelineItemVideoView(
     onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
     modifier: Modifier = Modifier,
 ) {
-    val description = stringResource(CommonStrings.common_image)
+    val description = stringResource(CommonStrings.common_video)
     Column(
         modifier = modifier.semantics { contentDescription = description }
     ) {
         val containerModifier = if (content.showCaption) {
             Modifier
-                    .padding(top = 6.dp)
-                    .clip(RoundedCornerShape(6.dp))
+                .padding(top = 6.dp)
+                .clip(RoundedCornerShape(6.dp))
         } else {
             Modifier
         }
@@ -93,8 +93,8 @@ fun TimelineItemVideoView(
                 var isLoaded by remember { mutableStateOf(false) }
                 AsyncImage(
                     modifier = Modifier
-                            .fillMaxWidth()
-                            .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+                        .fillMaxWidth()
+                        .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
                     model = MediaRequestData(
                         source = content.thumbnailSource,
                         kind = MediaRequestData.Kind.File(
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
index ab7a19a9c78..11873ee7c13 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
@@ -95,7 +95,9 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
                 messageType.bestDescription.prefixWith(CommonStrings.common_audio)
             }
             is VoiceMessageType -> {
-                messageType.bestDescription.prefixWith(CommonStrings.common_voice_message)
+                // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
+                messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
+                    ?: sp.getString(CommonStrings.common_voice_message)
             }
             is OtherMessageType -> {
                 messageType.body
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index 6b43fc3607f..40319154452 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -110,25 +110,27 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
                 messageType.toPlainText(permalinkParser)
             }
             is VideoMessageType -> {
-                sp.getString(CommonStrings.common_video)
+                messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_video))
             }
             is ImageMessageType -> {
-                sp.getString(CommonStrings.common_image)
+                messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_image))
             }
             is StickerMessageType -> {
-                sp.getString(CommonStrings.common_sticker)
+                messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker))
             }
             is LocationMessageType -> {
                 sp.getString(CommonStrings.common_shared_location)
             }
             is FileMessageType -> {
-                sp.getString(CommonStrings.common_file)
+                messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_file))
             }
             is AudioMessageType -> {
-                sp.getString(CommonStrings.common_audio)
+                messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_audio))
             }
             is VoiceMessageType -> {
-                sp.getString(CommonStrings.common_voice_message)
+                // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
+                messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
+                    ?: sp.getString(CommonStrings.common_voice_message)
             }
             is OtherMessageType -> {
                 messageType.body
@@ -140,7 +142,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
         return message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
     }

-    private fun String.prefixIfNeeded(
+    private fun CharSequence.prefixIfNeeded(
         senderDisambiguatedDisplayName: String,
         isDmRoom: Boolean,
         isOutgoing: Boolean,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
index 2fb6186bd4e..97e80f4dbf1 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
@@ -107,6 +107,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
                     senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
                     body = messageBody,
                     imageUriString = content.fetchImageIfPresent(client)?.toString(),
+                    imageMimeType = content.getImageMimetype(),
                     roomName = roomDisplayName,
                     roomIsDm = isDm,
                     roomAvatarPath = roomAvatarUrl,
@@ -316,6 +317,17 @@ class DefaultNotifiableEventResolver @Inject constructor(
             }
             .getOrNull()
     }
+
+    private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
+        if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) {
+            return null
+        }
+        return when (val messageType = messageType) {
+            is ImageMessageType -> messageType.info?.mimetype
+            is VideoMessageType -> null // Use the thumbnail here?
+            else -> null
+        }
+    }
 }

 @Suppress("LongParameterList")
@@ -333,6 +345,7 @@ internal fun buildNotifiableMessageEvent(
     // We cannot use Uri? type here, as that could trigger a
     // NotSerializableException when persisting this to storage
     imageUriString: String? = null,
+    imageMimeType: String? = null,
     threadId: ThreadId? = null,
     roomName: String? = null,
     roomIsDm: Boolean = false,
@@ -358,6 +371,7 @@ internal fun buildNotifiableMessageEvent(
     senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
     body = body,
     imageUriString = imageUriString,
+    imageMimeType = imageMimeType,
     threadId = threadId,
     roomName = roomName,
     roomIsDm = roomIsDm,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
index 97c53e945b7..a3d553ba91b 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
@@ -150,6 +150,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
                 ?: stringProvider.getString(R.string.notification_sender_me),
             body = message,
             imageUriString = null,
+            imageMimeType = null,
             threadId = threadId,
             roomName = room.displayName,
             roomIsDm = room.isDm,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
index dc57b0fe7bc..559322eaaef 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
@@ -419,11 +419,22 @@ class DefaultNotificationCreator @Inject constructor(
                         senderPerson
                     ).also { message ->
                         event.imageUri?.let {
-                            message.setData("image/", it)
+                            message.setData(event.imageMimeType ?: "image/", it)
                         }
                         message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
                     }
                     addMessage(message)
+
+                    // Add additional message for captions
+                    if (event.imageUri != null && event.body != null) {
+                        addMessage(
+                            MessagingStyle.Message(
+                                event.body,
+                                event.timestamp,
+                                senderPerson,
+                            )
+                        )
+                    }
                 }
             }
         }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
index 4a4d2612ae0..d51c7e09dee 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
@@ -31,7 +31,8 @@ data class NotifiableMessageEvent(
     val body: String?,
     // We cannot use Uri? type here, as that could trigger a
     // NotSerializableException when persisting this to storage
-    val imageUriString: String?,
+    private val imageUriString: String?,
+    val imageMimeType: String?,
     val threadId: ThreadId?,
     val roomName: String?,
     val roomIsDm: Boolean = false,

diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
index af347135fbf..80a7691a1d0 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
@@ -159,11 +159,11 @@ class DefaultPinnedMessagesBannerFormatterTest {
             val expectedResult = when (type) {
                 is VideoMessageType,
                 is AudioMessageType,
-                is VoiceMessageType,
                 is ImageMessageType,
                 is StickerMessageType,
                 is FileMessageType,
                 is LocationMessageType -> AnnotatedString::class.java
+                is VoiceMessageType,
                 is EmoteMessageType,
                 is TextMessageType,
                 is NoticeMessageType,
@@ -176,7 +176,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
             val expectedResult = when (type) {
                 is VideoMessageType -> "Video: Shared body"
                 is AudioMessageType -> "Audio: Shared body"
-                is VoiceMessageType -> "Voice message: Shared body"
+                is VoiceMessageType -> "Voice message"
                 is ImageMessageType -> "Image: Shared body"
                 is StickerMessageType -> "Sticker: Shared body"
                 is FileMessageType -> "File: Shared body"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index efc748d10fd..3c5038069ab 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -208,32 +208,51 @@ class DefaultRoomLastMessageFormatterTest {

         // Verify results of DM mode
         for ((type, result) in resultsInDm) {
+            val string = result.toString()
             val expectedResult = when (type) {
-                is VideoMessageType -> "Video"
-                is AudioMessageType -> "Audio"
+                is VideoMessageType -> "Video: Shared body"
+                is AudioMessageType -> "Audio: Shared body"
                 is VoiceMessageType -> "Voice message"
-                is ImageMessageType -> "Image"
-                is StickerMessageType -> "Sticker"
-                is FileMessageType -> "File"
+                is ImageMessageType -> "Image: Shared body"
+                is StickerMessageType -> "Sticker: Shared body"
+                is FileMessageType -> "File: Shared body"
                 is LocationMessageType -> "Shared location"
                 is EmoteMessageType -> "* $senderName ${type.body}"
                 is TextMessageType,
                 is NoticeMessageType,
                 is OtherMessageType -> body
             }
-            assertWithMessage("$type was not properly handled for DM").that(result).isEqualTo(expectedResult)
+            val shouldCreateAnnotatedString = when (type) {
+                is VideoMessageType -> true
+                is AudioMessageType -> true
+                is VoiceMessageType -> false
+                is ImageMessageType -> true
+                is StickerMessageType -> true
+                is FileMessageType -> true
+                is LocationMessageType -> false
+                is EmoteMessageType -> false
+                is TextMessageType -> false
+                is NoticeMessageType -> false
+                is OtherMessageType -> false
+            }
+            if (shouldCreateAnnotatedString) {
+                assertWithMessage("$type doesn't produce an AnnotatedString")
+                    .that(result)
+                    .isInstanceOf(AnnotatedString::class.java)
+            }
+            assertWithMessage("$type was not properly handled for DM").that(string).isEqualTo(expectedResult)
         }

         // Verify results of Room mode
         for ((type, result) in resultsInRoom) {
             val string = result.toString()
             val expectedResult = when (type) {
-                is VideoMessageType -> "$expectedPrefix: Video"
-                is AudioMessageType -> "$expectedPrefix: Audio"
+                is VideoMessageType -> "$expectedPrefix: Video: Shared body"
+                is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
                 is VoiceMessageType -> "$expectedPrefix: Voice message"
-                is ImageMessageType -> "$expectedPrefix: Image"
-                is StickerMessageType -> "$expectedPrefix: Sticker"
-                is FileMessageType -> "$expectedPrefix: File"
+                is ImageMessageType -> "$expectedPrefix: Image: Shared body"
+                is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
+                is FileMessageType -> "$expectedPrefix: File: Shared body"
                 is LocationMessageType -> "$expectedPrefix: Shared location"
                 is TextMessageType,
                 is NoticeMessageType,
@@ -249,7 +268,8 @@ class DefaultRoomLastMessageFormatterTest {
                 is FileMessageType -> true
                 is LocationMessageType -> false
                 is EmoteMessageType -> false
-                is TextMessageType, is NoticeMessageType -> true
+                is TextMessageType -> true
+                is NoticeMessageType -> true
                 is OtherMessageType -> true
             }
             if (shouldCreateAnnotatedString) {
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
index de29fcf1f01..153ebe1ffcb 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
@@ -590,6 +590,7 @@ class DefaultNotifiableEventResolverTest {
                 senderDisambiguatedDisplayName = A_USER_NAME_2,
                 body = "Call in progress (unsupported)",
                 imageUriString = null,
+                imageMimeType = null,
                 threadId = null,
                 roomName = A_ROOM_NAME,
                 roomAvatarPath = null,
@@ -669,6 +670,7 @@ class DefaultNotifiableEventResolverTest {
                 canBeReplaced = false,
                 isRedacted = false,
                 imageUriString = null,
+                imageMimeType = null,
                 type = EventType.CALL_NOTIFY,
             )
         )
@@ -704,6 +706,7 @@ class DefaultNotifiableEventResolverTest {
                 canBeReplaced = false,
                 isRedacted = false,
                 imageUriString = null,
+                imageMimeType = null,
                 type = EventType.CALL_NOTIFY,
             )
         )
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt
index faba6a6c651..438718582ff 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt
@@ -100,6 +100,7 @@ fun aNotifiableMessageEvent(
     canBeReplaced = false,
     isRedacted = isRedacted,
     imageUriString = null,
+    imageMimeType = null,
     roomAvatarPath = null,
     senderAvatarPath = null,
     soundName = null,


Rotate firebase token in case of error

<!-- 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 -->
Add a quick fix for the troubleshoot notification test in case of PusherRejected error, which means that the token is unknown.

In this case, the quick fix will rotate the Firebase token.

Note: maybe later we will see if we can do the same thing for UnifiedPush provider.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #3752

## 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|
|-|-|
|||
 -->

You can see in the video below that when clicking on QuickFix, the token is rotated.

https://github.com/user-attachments/assets/0152344f-809e-482b-9cfa-1e2a6673d54d

## Tests

<!-- Explain how you tested your development -->

- @wrenix can you check if this is fixing your issue please?

Internally, this can be tested by patching the code and let `DefaultPushService.testPush()` throw a `PushGatewayFailure.PusherRejected()` (this is what I have done for the video above).

## 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 23
- [ ] 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
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

Push-Notification Option for recreate token if test failed
Here a dedicated issue of this slice different Problem:
https://github.com/element-hq/element-x-android/issues/3570#issuecomment-2423760827

### Steps to reproduce

1. Login to EXA
2. See in microg if EXA was correct registerd to FCM
3. test push works in EXA
4. Delete registration in microg
5. Run test in EXA again

### Outcome

#### What did you expect?

Token validation success, but loop test failed.

#### What happened instead?
Here are two Option to fix it:
 - Option to reset the token to trigger new FCM registration
 - Reset FCM-Token after change Norification Provider (e.g. to ntfy)

### Your phone model

_No response_

### Operating system version

_No response_

### Application version and app store

_No response_

### Homeserver

_No response_

### Will you send logs?

No

### Are you willing to provide a PR?

No

Here a Screenshot of current broken state

![1000008557](https://github.com/user-attachments/assets/eb849f57-1daa-446e-9214-0fce482b24e9)

diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt
index 0f3cde8033b..90b4cb04658 100644
--- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt
+++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt
@@ -44,4 +44,10 @@ interface PushProvider {
     suspend fun unregister(matrixClient: MatrixClient): Result<Unit>

     suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig?
+
+    fun canRotateToken(): Boolean
+
+    suspend fun rotateToken(): Result<Unit> {
+        error("rotateToken() not implemented, you need to override this method in your implementation")
+    }
 }
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt
index 6bbdb1efe83..b1e31a23035 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt
@@ -25,6 +25,7 @@ class FirebasePushProvider @Inject constructor(
     private val firebaseStore: FirebaseStore,
     private val pusherSubscriber: PusherSubscriber,
     private val isPlayServiceAvailable: IsPlayServiceAvailable,
+    private val firebaseTokenRotator: FirebaseTokenRotator,
 ) : PushProvider {
     override val index = FirebaseConfig.INDEX
     override val name = FirebaseConfig.NAME
@@ -71,6 +72,12 @@ class FirebasePushProvider @Inject constructor(
         }
     }

+    override fun canRotateToken(): Boolean = true
+
+    override suspend fun rotateToken(): Result<Unit> {
+        return firebaseTokenRotator.rotate()
+    }
+
     companion object {
         private val firebaseDistributor = Distributor("Firebase", "Firebase")
     }
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt
index 2058f716d4e..4ad36194544 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt
@@ -11,6 +11,10 @@ import android.content.SharedPreferences
 import androidx.core.content.edit
 import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.libraries.di.AppScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
 import javax.inject.Inject

 /**
@@ -18,6 +22,7 @@ import javax.inject.Inject
  */
 interface FirebaseStore {
     fun getFcmToken(): String?
+    fun fcmTokenFlow(): Flow<String?>
     fun storeFcmToken(token: String?)
 }

@@ -29,6 +34,22 @@ class SharedPreferencesFirebaseStore @Inject constructor(
         return sharedPreferences.getString(PREFS_KEY_FCM_TOKEN, null)
     }

+    override fun fcmTokenFlow(): Flow<String?> {
+        val flow = MutableStateFlow(getFcmToken())
+        val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, k ->
+            if (k == PREFS_KEY_FCM_TOKEN) {
+                try {
+                    flow.value = getFcmToken()
+                } catch (e: Exception) {
+                    flow.value = null
+                }
+            }
+        }
+        return flow
+            .onStart { sharedPreferences.registerOnSharedPreferenceChangeListener(listener) }
+            .onCompletion { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
+    }
+
     override fun storeFcmToken(token: String?) {
         sharedPreferences.edit {
             putString(PREFS_KEY_FCM_TOKEN, token)
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenDeleter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenDeleter.kt
new file mode 100644
index 00000000000..597a39493f8
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenDeleter.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.pushproviders.firebase
+
+import com.google.firebase.messaging.FirebaseMessaging
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+interface FirebaseTokenDeleter {
+    /**
+     * Deletes the current Firebase token.
+     */
+    suspend fun delete()
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultFirebaseTokenDeleter @Inject constructor(
+    private val isPlayServiceAvailable: IsPlayServiceAvailable,
+) : FirebaseTokenDeleter {
+    override suspend fun delete() {
+        // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
+        isPlayServiceAvailable.checkAvailableOrThrow()
+        suspendCoroutine { continuation ->
+            try {
+                FirebaseMessaging.getInstance().deleteToken()
+                    .addOnSuccessListener {
+                        continuation.resume(Unit)
+                    }
+                    .addOnFailureListener { e ->
+                        Timber.e(e, "## deleteFirebaseToken() : failed")
+                        continuation.resumeWithException(e)
+                    }
+            } catch (e: Throwable) {
+                Timber.e(e, "## deleteFirebaseToken() : failed")
+                continuation.resumeWithException(e)
+            }
+        }
+    }
+}
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt
new file mode 100644
index 00000000000..a15d5a250c1
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.pushproviders.firebase
+
+import com.google.firebase.messaging.FirebaseMessaging
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+interface FirebaseTokenGetter {
+    /**
+     * Read the current Firebase token from FirebaseMessaging.
+     * If the token does not exist, it will be generated.
+     */
+    suspend fun get(): String
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultFirebaseTokenGetter @Inject constructor(
+    private val isPlayServiceAvailable: IsPlayServiceAvailable,
+) : FirebaseTokenGetter {
+    override suspend fun get(): String {
+        // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
+        isPlayServiceAvailable.checkAvailableOrThrow()
+        return suspendCoroutine { continuation ->
+            try {
+                FirebaseMessaging.getInstance().token
+                    .addOnSuccessListener { token ->
+                        continuation.resume(token)
+                    }
+                    .addOnFailureListener { e ->
+                        Timber.e(e, "## retrievedFirebaseToken() : failed")
+                        continuation.resumeWithException(e)
+                    }
+            } catch (e: Throwable) {
+                Timber.e(e, "## retrievedFirebaseToken() : failed")
+                continuation.resumeWithException(e)
+            }
+        }
+    }
+}
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt
new file mode 100644
index 00000000000..fc55d11a77a
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.pushproviders.firebase
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+interface FirebaseTokenRotator {
+    suspend fun rotate(): Result<Unit>
+}
+
+/**
+ * This class delete the Firebase token and generate a new one.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultFirebaseTokenRotator @Inject constructor(
+    private val firebaseTokenDeleter: FirebaseTokenDeleter,
+    private val firebaseTokenGetter: FirebaseTokenGetter,
+) : FirebaseTokenRotator {
+    override suspend fun rotate(): Result<Unit> {
+        return runCatching {
+            firebaseTokenDeleter.delete()
+            firebaseTokenGetter.get()
+        }
+    }
+}
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt
index 6ef6931ea14..6991da5f251 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt
@@ -7,14 +7,9 @@

 package io.element.android.libraries.pushproviders.firebase

-import com.google.firebase.messaging.FirebaseMessaging
 import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.libraries.di.AppScope
-import timber.log.Timber
 import javax.inject.Inject
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import kotlin.coroutines.suspendCoroutine

 interface FirebaseTroubleshooter {
     suspend fun troubleshoot(): Result<Unit>
@@ -26,37 +21,12 @@ interface FirebaseTroubleshooter {
 @ContributesBinding(AppScope::class)
 class DefaultFirebaseTroubleshooter @Inject constructor(
     private val newTokenHandler: FirebaseNewTokenHandler,
-    private val isPlayServiceAvailable: IsPlayServiceAvailable,
+    private val firebaseTokenGetter: FirebaseTokenGetter,
 ) : FirebaseTroubleshooter {
     override suspend fun troubleshoot(): Result<Unit> {
         return runCatching {
-            val token = retrievedFirebaseToken()
+            val token = firebaseTokenGetter.get()
             newTokenHandler.handle(token)
         }
     }
-
-    private suspend fun retrievedFirebaseToken(): String {
-        return suspendCoroutine { continuation ->
-            // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
-            if (isPlayServiceAvailable.isAvailable()) {
-                try {
-                    FirebaseMessaging.getInstance().token
-                        .addOnSuccessListener { token ->
-                            continuation.resume(token)
-                        }
-                        .addOnFailureListener { e ->
-                            Timber.e(e, "## retrievedFirebaseToken() : failed")
-                            continuation.resumeWithException(e)
-                        }
-                } catch (e: Throwable) {
-                    Timber.e(e, "## retrievedFirebaseToken() : failed")
-                    continuation.resumeWithException(e)
-                }
-            } else {
-                val e = Exception("No valid Google Play Services found. Cannot use FCM.")
-                Timber.e(e)
-                continuation.resumeWithException(e)
-            }
-        }
-    }
 }
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt
index 02cf66cb744..a0177ef7576 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt
@@ -20,6 +20,12 @@ interface IsPlayServiceAvailable {
     fun isAvailable(): Boolean
 }

+fun IsPlayServiceAvailable.checkAvailableOrThrow() {
+    if (!isAvailable()) {
+        throw Exception("No valid Google Play Services found. Cannot use FCM.").also(Timber::e)
+    }
+}
+
 @ContributesBinding(AppScope::class)
 class DefaultIsPlayServiceAvailable @Inject constructor(
     @ApplicationContext private val context: Context,
diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt
index 9a2d5d5c10f..1ce929a75b6 100644
--- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt
+++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt
@@ -53,4 +53,6 @@ class UnifiedPushProvider @Inject constructor(
     override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {
         return unifiedPushCurrentUserPushConfigProvider.provide()
     }
+
+    override fun canRotateToken(): Boolean = false
 }

diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt
index e91f4351f78..57c255a4064 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt
@@ -52,9 +52,10 @@ class PushLoopbackTest @Inject constructor(
         val testPushResult = try {
             pushService.testPush()
         } catch (pusherRejected: PushGatewayFailure.PusherRejected) {
+            val hasQuickFix = pushService.getCurrentPushProvider()?.canRotateToken() == true
             delegate.updateState(
                 description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1),
-                status = NotificationTroubleshootTestState.Status.Failure(false)
+                status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix)
             )
             job.cancel()
             return
@@ -96,5 +97,11 @@ class PushLoopbackTest @Inject constructor(
         )
     }

+    override suspend fun quickFix(coroutineScope: CoroutineScope) {
+        delegate.start()
+        pushService.getCurrentPushProvider()?.rotateToken()
+        run(coroutineScope)
+    }
+
     override suspend fun reset() = delegate.reset()
 }
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt
index b12e0cf80b7..57ba07a5f64 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt
@@ -13,9 +13,11 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
 import io.element.android.libraries.matrix.test.A_FAILURE_REASON
 import io.element.android.libraries.push.api.gateway.PushGatewayFailure
 import io.element.android.libraries.push.test.FakePushService
+import io.element.android.libraries.pushproviders.test.FakePushProvider
 import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
 import io.element.android.services.toolbox.test.strings.FakeStringProvider
 import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
+import io.element.android.tests.testutils.lambda.lambdaRecorder
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
@@ -67,6 +69,41 @@ class PushLoopbackTestTest {
         }
     }

+    @Test
+    fun `test PushLoopbackTest PusherRejected error with quick fix`() = runTest {
+        val diagnosticPushHandler = DiagnosticPushHandler()
+        val rotateTokenLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
+        val sut = PushLoopbackTest(
+            pushService = FakePushService(
+                testPushBlock = {
+                    throw PushGatewayFailure.PusherRejected()
+                },
+                currentPushProvider = {
+                    FakePushProvider(
+                        canRotateTokenResult = { true },
+                        rotateTokenLambda = rotateTokenLambda,
+                    )
+                }
+            ),
+            diagnosticPushHandler = diagnosticPushHandler,
+            clock = FakeSystemClock(),
+            stringProvider = FakeStringProvider(),
+        )
+        launch {
+            sut.run(this)
+        }
+        sut.state.test {
+            assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true))
+            assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+            val lastItem = awaitItem()
+            assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true))
+            sut.quickFix(this)
+            assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+            assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true))
+            rotateTokenLambda.assertions().isCalledOnce()
+        }
+    }
+
     @Test
     fun `test PushLoopbackTest setup error`() = runTest {
         val diagnosticPushHandler = DiagnosticPushHandler()
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt
index a0d6d22d101..7d042396697 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt
@@ -19,7 +19,10 @@ import io.element.android.libraries.troubleshoot.api.test.NotificationTroublesho
 import io.element.android.libraries.troubleshoot.api.test.TestFilterData
 import io.element.android.services.toolbox.api.strings.StringProvider
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import javax.inject.Inject

 @ContributesMultibinding(AppScope::class)
@@ -41,23 +44,28 @@ class FirebaseTokenTest @Inject constructor(
         return data.currentPushProviderName == FirebaseConfig.NAME
     }

+    private var currentJob: Job? = null
     override suspend fun run(coroutineScope: CoroutineScope) {
+        currentJob?.cancel()
         delegate.start()
-        val token = firebaseStore.getFcmToken()
-        if (token != null) {
-            delegate.updateState(
-                description = stringProvider.getString(
-                    R.string.troubleshoot_notifications_test_firebase_token_success,
-                    "${token.take(8)}*****"
-                ),
-                status = NotificationTroubleshootTestState.Status.Success
-            )
-        } else {
-            delegate.updateState(
-                description = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_token_failure),
-                status = NotificationTroubleshootTestState.Status.Failure(true)
-            )
-        }
+        currentJob = firebaseStore.fcmTokenFlow()
+            .onEach { token ->
+                if (token != null) {
+                    delegate.updateState(
+                        description = stringProvider.getString(
+                            R.string.troubleshoot_notifications_test_firebase_token_success,
+                            "*****${token.takeLast(8)}"
+                        ),
+                        status = NotificationTroubleshootTestState.Status.Success
+                    )
+                } else {
+                    delegate.updateState(
+                        description = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_token_failure),
+                        status = NotificationTroubleshootTestState.Status.Failure(true)
+                    )
+                }
+            }
+            .launchIn(coroutineScope)
     }

     override suspend fun reset() = delegate.reset()
diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt
new file mode 100644
index 00000000000..c96c67c3fa4
--- /dev/null
+++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.pushproviders.firebase
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeFirebaseTokenRotator(
+    private val rotateWithResult: () -> Result<Unit> = { lambdaError() }
+) : FirebaseTokenRotator {
+    override suspend fun rotate(): Result<Unit> {
+        return rotateWithResult()
+    }
+}
diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt
index f94f4ebac89..6a3a9474e40 100644
--- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt
+++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt
@@ -166,15 +166,27 @@ class FirebasePushProviderTest {
         assertThat(result).isEqualTo(CurrentUserPushConfig(FirebaseConfig.PUSHER_HTTP_URL, "aToken"))
     }

+    @Test
+    fun `rotateToken invokes the FirebaseTokenRotator`() = runTest {
+        val lambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
+        val firebasePushProvider = createFirebasePushProvider(
+            firebaseTokenRotator = FakeFirebaseTokenRotator(lambda),
+        )
+        firebasePushProvider.rotateToken()
+        lambda.assertions().isCalledOnce()
+    }
+
     private fun createFirebasePushProvider(
         firebaseStore: FirebaseStore = InMemoryFirebaseStore(),
         pusherSubscriber: PusherSubscriber = FakePusherSubscriber(),
         isPlayServiceAvailable: IsPlayServiceAvailable = FakeIsPlayServiceAvailable(false),
+        firebaseTokenRotator: FirebaseTokenRotator = FakeFirebaseTokenRotator(),
     ): FirebasePushProvider {
         return FirebasePushProvider(
             firebaseStore = firebaseStore,
             pusherSubscriber = pusherSubscriber,
             isPlayServiceAvailable = isPlayServiceAvailable,
+            firebaseTokenRotator = firebaseTokenRotator,
         )
     }
 }
diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt
index 3a3e582f7f2..6728fd04903 100644
--- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt
+++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt
@@ -7,11 +7,16 @@

 package io.element.android.libraries.pushproviders.firebase

+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
 class InMemoryFirebaseStore(
     private var token: String? = null
 ) : FirebaseStore {
     override fun getFcmToken(): String? = token

+    override fun fcmTokenFlow(): Flow<String?> = flowOf(token)
+
     override fun storeFcmToken(token: String?) {
         this.token = token
     }
diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt
index adf50300e77..e67c59c8867 100644
--- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt
+++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt
@@ -35,7 +35,7 @@ class FirebaseTokenTestTest {
             assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
             val lastItem = awaitItem()
             assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success)
-            assertThat(lastItem.description).contains(FAKE_TOKEN.take(8))
+            assertThat(lastItem.description).contains(FAKE_TOKEN.takeLast(8))
             assertThat(lastItem.description).doesNotContain(FAKE_TOKEN)
         }
     }
diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt
index 9fe222e1cba..6f5bdda5b77 100644
--- a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt
+++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt
@@ -21,6 +21,8 @@ class FakePushProvider(
     private val currentUserPushConfig: CurrentUserPushConfig? = null,
     private val registerWithResult: (MatrixClient, Distributor) -> Result<Unit> = { _, _ -> lambdaError() },
     private val unregisterWithResult: (MatrixClient) -> Result<Unit> = { lambdaError() },
+    private val canRotateTokenResult: () -> Boolean = { lambdaError() },
+    private val rotateTokenLambda: () -> Result<Unit> = { lambdaError() },
 ) : PushProvider {
     override fun getDistributors(): List<Distributor> = distributors

@@ -39,4 +41,12 @@ class FakePushProvider(
     override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {
         return currentUserPushConfig
     }
+
+    override fun canRotateToken(): Boolean {
+        return canRotateTokenResult()
+    }
+
+    override suspend fun rotateToken(): Result<Unit> {
+        return rotateTokenLambda()
+    }
 }