Disable mutliple click (parallel or serial) on a room

<!-- 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 minimum delay between two click handling on the room list.
Protect:
- opening the setting
- opening a room
- opening the create room screen

## Motivation and context

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

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

- Open the room list
- Tap on several rooms simultaneously
- Observe that only one room is opened by going back to the room list
- Quickly tap on a room
- Observe that only one room is opened by going back to the room list
- Tap on a room and on the user avatar icon
- Observe that the settings screen is opened or the room is opened but not both by going back to the room list
- Tap on a room and on the floating action button to create a room
- Observe that the settings to create a room is opened or the room is opened but not both by going back to the room list

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

It's possible to open a room view twice by tapping it super quickly
### Steps to reproduce

From the room list, tap the same room name twice super quickly.

### Outcome

#### What did you expect?

The room view to open only once.

#### What happened instead?

The room view has opened twice: if I press return/back, then I get back to the same room view, instead of the room list.

### Your phone model

Motorola Razr

### Operating system version

_No response_

### Application version and app store

ElementX beta from the app store

### Homeserver

_No response_

### Will you send logs?

No

### Are you willing to provide a PR?

No

Additionally, it's possible to open multiple rooms by tapping on several rooms at the same time.

diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 016cc4d116b..b00796c1fed 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -17,6 +17,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.material3.rememberTopAppBarState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringResource
@@ -30,6 +32,7 @@ import io.element.android.features.roomlist.impl.components.RoomListMenuAction
 import io.element.android.features.roomlist.impl.components.RoomListTopBar
 import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
 import io.element.android.features.roomlist.impl.search.RoomListSearchView
+import io.element.android.libraries.androidutils.throttler.FirstThrottler
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@@ -54,6 +57,9 @@ fun RoomListView(
     modifier: Modifier = Modifier,
     acceptDeclineInviteView: @Composable () -> Unit,
 ) {
+    val coroutineScope = rememberCoroutineScope()
+    val firstThrottler = remember { FirstThrottler(300, coroutineScope) }
+
     ConnectivityIndicatorContainer(
         modifier = modifier,
         isOnline = state.hasNetworkConnection,
@@ -83,9 +89,9 @@ fun RoomListView(
                 state = state,
                 onSetUpRecoveryClick = onSetUpRecoveryClick,
                 onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
-                onRoomClick = onRoomClick,
-                onOpenSettings = onSettingsClick,
-                onCreateRoomClick = onCreateRoomClick,
+                onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
+                onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
+                onCreateRoomClick = { if (firstThrottler.canHandle()) onCreateRoomClick() },
                 onMenuActionClick = onMenuActionClick,
                 modifier = Modifier.padding(top = topPadding),
             )
@@ -94,7 +100,7 @@ fun RoomListView(
                 state = state.searchState,
                 eventSink = state.eventSink,
                 hideInvitesAvatars = state.hideInvitesAvatars,
-                onRoomClick = onRoomClick,
+                onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
                 modifier = Modifier
                     .statusBarsPadding()
                     .padding(top = topPadding)
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt
index 30919d52282..8f6766cdd6c 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt
@@ -6,36 +6,29 @@
  */
 package io.element.android.libraries.androidutils.throttler

-import android.os.SystemClock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean

 /**
  * Simple ThrottleFirst
  * See https://raw.githubusercontent.com/wiki/ReactiveX/RxJava/images/rx-operators/throttleFirst.png
  */
-class FirstThrottler(private val minimumInterval: Long = 800) {
-    private var lastDate = 0L
+class FirstThrottler(
+    private val minimumInterval: Long = 800,
+    private val coroutineScope: CoroutineScope,
+) {
+    private val canHandle = AtomicBoolean(true)

-    sealed interface CanHandleResult {
-        data object Yes : CanHandleResult
-        data class No(val shouldWaitMillis: Long) : CanHandleResult
-
-        fun waitMillis(): Long {
-            return when (this) {
-                Yes -> 0
-                is No -> shouldWaitMillis
+    fun canHandle(): Boolean {
+        return canHandle.getAndSet(false).also { result ->
+            if (result) {
+                coroutineScope.launch {
+                    delay(minimumInterval)
+                    canHandle.set(true)
+                }
             }
         }
     }
-
-    fun canHandle(): CanHandleResult {
-        val now = SystemClock.elapsedRealtime()
-        val delaySinceLast = now - lastDate
-        if (delaySinceLast > minimumInterval) {
-            lastDate = now
-            return CanHandleResult.Yes
-        }
-
-        // Too early
-        return CanHandleResult.No(minimumInterval - delaySinceLast)
-    }
 }

diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index ee4cc0b5642..0242591f55b 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -5,6 +5,8 @@
  * Please see LICENSE files in the repository root for full details.
  */

+@file:OptIn(ExperimentalCoroutinesApi::class)
+
 package io.element.android.features.roomlist.impl

 import androidx.activity.ComponentActivity
@@ -27,6 +29,7 @@ import io.element.android.tests.testutils.EventsRecorder
 import io.element.android.tests.testutils.clickOn
 import io.element.android.tests.testutils.ensureCalledOnce
 import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestRule
@@ -168,6 +171,29 @@ class RoomListViewTest {
         eventsRecorder.assertEmpty()
     }

+    @Test
+    fun `clicking on a room twice invokes the expected callback only once`() {
+        val eventsRecorder = EventsRecorder<RoomListEvents>()
+        val state = aRoomListState(
+            eventSink = eventsRecorder,
+        )
+        val room0 = state.contentAsRooms().summaries.first {
+            it.displayType == RoomSummaryDisplayType.ROOM
+        }
+        ensureCalledOnceWithParam(room0.roomId) { callback ->
+            rule.setRoomListView(
+                state = state,
+                onRoomClick = callback,
+            )
+            // Remove automatic initial events
+            eventsRecorder.clear()
+            rule.onNodeWithText(room0.lastMessage!!.toString())
+                .performClick()
+                .performClick()
+        }
+        eventsRecorder.assertEmpty()
+    }
+
     @Test
     fun `long clicking on a room emits the expected Event`() {
         val eventsRecorder = EventsRecorder<RoomListEvents>()
diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt
new file mode 100644
index 00000000000..f33a3f237a8
--- /dev/null
+++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.libraries.androidutils.throttler
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class FirstThrottlerTest {
+    @Test
+    fun `throttle canHandle returns the expected result`() = runTest {
+        val throttler = FirstThrottler(
+            minimumInterval = 300,
+            coroutineScope = backgroundScope,
+        )
+        assertThat(throttler.canHandle()).isTrue()
+        assertThat(throttler.canHandle()).isFalse()
+        advanceTimeBy(200)
+        assertThat(throttler.canHandle()).isFalse()
+        advanceTimeBy(110)
+        assertThat(throttler.canHandle()).isTrue()
+    }
+}


Render caller avatar on Incoming call screen

<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Content

Ensure that the image loader is set, else the IncomingCallActivity will not be able to render the avatar.
<!-- Describe shortly what has been changed -->

Note that this is a temporary fix, we may want to revisit how we handle the Coil image loader singleton.

This PR also ensure that a background color is set else we can end up with light text on light background.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Fixes #4633
Fixes #4629

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

|Before|After|
|-|-|
|![IncomingCall-Before2](https://github.com/user-attachments/assets/09a0846b-5769-413a-ae15-c3fb310c8123)|![IncomingCall-After](https://github.com/user-attachments/assets/3d72958c-2165-4b9d-a552-a1fce2b94a71)|

## Tests

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

- Set the system color to light
- Set the app theme to dark
- Kill the app
- Lock the device (screen is off)
- Receive a call from a DM where other user has an avatar
- Observe that the screen now renders the caller avatar and is rendering light text on dark background. Previously it was only rendering initials avatar, with light text on light background.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

EC 1:1 incoming call: still displayed in light color under certain conditions.
### Steps to reproduce

1. If EXA is running, kill it first;
2. Start EXA, then remove it from the recent tasks list (make sure EXA disables battery optimization and allows running in the background);
3. Lock the phone (the screen needs to be turned off) and wait for an incoming call, then answer the call.

### Outcome

#### What did you expect?
n/a

#### What happened instead?
![Image](https://github.com/user-attachments/assets/1e5aed5f-4db8-42d9-8d0d-5c7b3aca9876)![Image](https://github.com/user-attachments/assets/93188154-f422-46f4-93ac-d1f029941611)

At this time, the incoming call interface and EC WebUI are displayed in light color, regardless of the color setting of EXA.
**This phenomenon only occurs once since the last time EXA was started**, and each subsequent incoming call can return to normal.

### Your phone model

OnePlus MT2111

### Operating system version

Oxygen OS 15 (Android 15)

### Application version and app store

Element X for Android 25.4.3

### Homeserver

Synapse 1.128.0

### Will you send logs?

No

### Are you willing to provide a PR?

No
Incoming Call screen does not render the caller avatar
### Steps to reproduce

1. Kill the application
2. Lock the screen
3. Receive a call from someone who has set an avatar

### Outcome

#### What did you expect?

The incoming call screen (IncomingCallActivity) render the caller avatar

#### What happened instead?

The incoming call screen render an fallback initial avatar

From the log:

> java.lang.IllegalStateException: Unable to create a fetcher that supports: AvatarData(id=@user:matrix.org, name=Benoit, url=mxc://matrix.org/jRqrnjimPBREDACTED, size=IncomingCall)

So it seems that the `DefaultLoggedInImageLoaderFactory` is not correctly set in this configuration.

### Your phone model

Emu on API 35

### Operating system version

Android 15

### Application version and app store

Latest develop

### Homeserver

Any

### Will you send logs?

Yes

### Are you willing to provide a PR?

Yes

Hello @YamatoRyou , I cannot reproduce this issue on my device, nor on my emulator, both running Android 15 OS.

Do you have more details to provide?

But I have discovered this issue, which is related to this screen: #4633
> Hello [@YamatoRyou](https://github.com/YamatoRyou) , I cannot reproduce this issue on my device, nor on my emulator, both running Android 15 OS.
>
> Do you have more details to provide?
>
> But I have discovered this issue, which is related to this screen: [#4633](https://github.com/element-hq/element-x-android/issues/4633)

The following steps can be added to increase the chances of reproducing the problem:
4. Set the system's color scheme to light;
5. Set EXA's color scheme to dark.
6. Repeat steps 1 to 3 mentioned above.

diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 59e19d3fe2f..8804a91f79f 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -13,6 +13,8 @@ import android.os.PowerManager
 import androidx.annotation.VisibleForTesting
 import androidx.core.app.NotificationManagerCompat
 import androidx.core.content.getSystemService
+import coil3.SingletonImageLoader
+import coil3.annotation.DelicateCoilApi
 import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.appconfig.ElementCallConfig
 import io.element.android.features.call.api.CallType
@@ -23,6 +25,8 @@ import io.element.android.libraries.di.AppScope
 import io.element.android.libraries.di.ApplicationContext
 import io.element.android.libraries.di.SingleIn
 import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
 import io.element.android.libraries.push.api.notifications.ForegroundServiceType
 import io.element.android.libraries.push.api.notifications.NotificationIdProvider
 import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -89,6 +93,7 @@ class DefaultActiveCallManager @Inject constructor(
     private val matrixClientProvider: MatrixClientProvider,
     private val defaultCurrentCallService: DefaultCurrentCallService,
     private val appForegroundStateService: AppForegroundStateService,
+    private val imageLoaderHolder: ImageLoaderHolder,
 ) : ActiveCallManager {
     private val tag = "DefaultActiveCallManager"
     private var timedOutCallJob: Job? = null
@@ -125,6 +130,7 @@ class DefaultActiveCallManager @Inject constructor(
             )

             timedOutCallJob = coroutineScope.launch {
+                setUpCoil(notificationData.sessionId)
                 showIncomingCallNotification(notificationData)

                 // Wait for the ringing call to time out
@@ -140,6 +146,13 @@ class DefaultActiveCallManager @Inject constructor(
         }
     }

+    @OptIn(DelicateCoilApi::class)
+    private suspend fun setUpCoil(sessionId: SessionId) {
+        val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
+        // Ensure that the image loader is set, else the IncomingCallActivity will not be able to render the caller avatar
+        SingletonImageLoader.setUnsafe(imageLoaderHolder.get(matrixClient))
+    }
+
     /**
      * Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
      */
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
index 8ea58deb40c..b4830e0904a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
@@ -8,6 +8,7 @@
 package io.element.android.libraries.designsystem.background

 import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,13 +33,17 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 @Suppress("ModifierMissing")
 @Composable
 fun OnboardingBackground() {
-    Box(modifier = Modifier.fillMaxSize()) {
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+            .background(ElementTheme.colors.bgCanvasDefault)
+    ) {
         val isLightTheme = ElementTheme.isLightTheme
         Canvas(
             modifier = Modifier
-            .fillMaxWidth()
-            .height(220.dp)
-            .align(Alignment.BottomCenter)
+                .fillMaxWidth()
+                .height(220.dp)
+                .align(Alignment.BottomCenter)
         ) {
             val gradientBrush = ShaderBrush(
                 LinearGradientShader(

diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index 5f7309344a4..2059e9836e4 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -325,5 +325,6 @@ class DefaultActiveCallManagerTest {
         matrixClientProvider = matrixClientProvider,
         defaultCurrentCallService = DefaultCurrentCallService(),
         appForegroundStateService = FakeAppForegroundStateService(),
+        imageLoaderHolder = FakeImageLoaderHolder(),
     )
 }


Update wording for push provider support test. (#4079)

## Content

Update wording of the notification test about push provider support.
The text is quite similar, so its OK if translations are updated later. Created a `success_2` though, since we do not need a plural anymore.

## Motivation and context

Closes #4079

## Screenshots / GIFs

|Before|After|
|-|-|
|||

## Tests

- Step 1
- Step 2
- Step ...

## Tested devices

- [ ] Physical
- [ ] Emulator
- OS version(s):

## Checklist

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

Firebase Error on LineageOS

Firebase Error on LineageOS
### Steps to reproduce

1. Install Element X on a LineageOS mobile phone
2. Start it and login to your homeserver
3. After logging in, an error appears that push notifications might not be available because of "other error"
4. Click on "Settings" and click on "Fix" (i translated them myself from german -> english, so maybe they are not 100% the same wordings)
5. Error will appear

### Outcome
![signal-2024-12-20-121052](https://github.com/user-attachments/assets/5550ff7e-249a-4788-9ec8-1edb38bd6c8d)

#### What did you expect?
No error at all

#### What happened instead?
An error and probably no push notifications at all

### Your phone model

OnePlus 11

### Operating system version

LineageOS 21

### Application version and app store

0.7.5

### Homeserver

Synapse 1.121.1 on debian trixie

### Will you send logs?

Yes

### Are you willing to provide a PR?

No

Yes, so what is the exact problem? If you have LineageOS without google apps installed, there will be no Google push available. If you do have the whole Google shebang installed, it should be working.
It says firebase is available and a line below it says firebase not available. My assumption is that the text is misleading, if thats the correct behaviour.
I am not familiar with the push magic behind all those apps, so i assume firebase is the google push server. I installed ntfy as alternative, because thats the non-google push service, right? when firebase is not available, i would expect that to be used. Without an error.
In the troubleshoot notification test suite, this test:

(in English)

is actually confirming that the application is able to use Firebase, if it is installed on the device. It is a check that our internal module interacting with Firebase is included in the build.

I also think that the wording may be a bit confusing.

We may want to change the wording `Detect push providers` to something else like `Detect push provider plugins` or something else.
Thanks, that makes a bit more sense then.
If my text is correct from https://github.com/element-hq/element-x-android/issues/4079#issuecomment-2558454340, it is still somehow confusing because i use LineageOS without any google services. Why is firebase available at all when its "google-free"? Is this a problem in LineageOS? The other thing is, firebase is available but seems like non-functioning? Why is UnifiedPush then not used? And why cant i change that in element x on android?
Only the code in Element X Android is available to manage Firebase Push for you. But Firebase is not available, conformed by this other test:

For instance, if a user installs Element X Android from F-Droid (https://f-droid.org/packages/io.element.android.x/), the application will not have the code to manage Firebase, even if Firebase (Google Play Service) is available on their device.
I see, so firebase is available when i install element x from google play store but when i use fdroid, that function is not available at all? so why list it then there? for me, as a bit more technical user, i am highly confused by that and it looks like something is wrong with my mobile phone. Also, i would not say "Firebase ist nicht verfügbar" which means its not there at all, i would use something like "Firebase inkompatibel mit Custom-ROM" or so.
I installed it from fdroid on a google-free phone, why is there firebase then and those checks? i am now really confused.
I will install it with aurora (google play store wrapper) and see how that behaves.

Edit: installed it now with aurora and get the same error. Just to be sure i will install it later again from fdroid
Would it bit clearer with this wording?

Absolutely! Just a bit much text but it makes now 100% more sense (imo).
Or maybe "The application was built with support for: Firebase, UnifiedPush" ? Reminds me a bit of -configure flags in the linux c world :)
`support` is the word I was looking for, thanks!

diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml
index 5c6c852f145..9860c8bef7f 100644
--- a/libraries/push/impl/src/main/res/values/localazy.xml
+++ b/libraries/push/impl/src/main/res/values/localazy.xml
@@ -58,13 +58,14 @@
   <string name="troubleshoot_notifications_test_current_push_provider_failure">"No push providers selected."</string>
   <string name="troubleshoot_notifications_test_current_push_provider_success">"Current push provider: %1$s."</string>
   <string name="troubleshoot_notifications_test_current_push_provider_title">"Current push provider"</string>
-  <string name="troubleshoot_notifications_test_detect_push_provider_description">"Ensure that the application has at least one push provider."</string>
-  <string name="troubleshoot_notifications_test_detect_push_provider_failure">"No push providers found."</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_description">"Ensure that the application supports at least one push provider."</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_failure">"No push provider support found."</string>
   <plurals name="troubleshoot_notifications_test_detect_push_provider_success">
     <item quantity="one">"Found %1$d push provider: %2$s"</item>
     <item quantity="other">"Found %1$d push providers: %2$s"</item>
   </plurals>
-  <string name="troubleshoot_notifications_test_detect_push_provider_title">"Detect push providers"</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_success_2">"The application was built with support for: %1$s"</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_title">"Push provider support"</string>
   <string name="troubleshoot_notifications_test_display_notification_description">"Check that the application can display notification."</string>
   <string name="troubleshoot_notifications_test_display_notification_failure">"The notification has not been clicked."</string>
   <string name="troubleshoot_notifications_test_display_notification_permission_failure">"Cannot display the notification."</string>

diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
index d75c4f4d590..5a30db6f59e 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
@@ -38,10 +38,8 @@ class PushProvidersTest @Inject constructor(
         val result = sortedPushProvider.isNotEmpty()
         if (result) {
             delegate.updateState(
-                description = stringProvider.getQuantityString(
-                    resId = R.plurals.troubleshoot_notifications_test_detect_push_provider_success,
-                    quantity = sortedPushProvider.size,
-                    sortedPushProvider.size,
+                description = stringProvider.getString(
+                    resId = R.string.troubleshoot_notifications_test_detect_push_provider_success_2,
                     sortedPushProvider.joinToString { it.name }
                 ),
                 status = NotificationTroubleshootTestState.Status.Success
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt
index cc4df51b556..8947b646a69 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt
@@ -53,7 +53,6 @@ class PushProvidersTestTest {
             assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
             val lastItem = awaitItem()
             assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success)
-            assertThat(lastItem.description).contains("2")
             assertThat(lastItem.description).contains("foo")
             assertThat(lastItem.description).contains("bar")
         }


Preload account urls

<!-- 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 -->
Preload account management URL into SDK memory cache.

## Motivation and context

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

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

- Signin using an OIDC managed account
- Open the settings
- All the items in the settings screen should load instantely.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

[Task] Preload account management URLs

[Task] Preload account management URLs
A memory cache has been implemented in the SDK. See https://github.com/matrix-org/matrix-rust-sdk/pull/4663

We need the application to pre-load the 2 accounts management URLs to populate the in-memory cache. That way, when the user open the settings, the flickering will not be observed for the two entries:

<img width="366" alt="Image" src="https://github.com/user-attachments/assets/9b636d5e-95fc-40a1-a438-96faef34071a" />

Note that if the app is started without network access, the url cannot be loaded and the settings entries will not be displayed.

There is no change expected on the setting screen, that should still requires the URLs from the SDK.

./gradlew :features:preferences:impl:testDebugUnitTest --tests "io.element.android.features.preferences.impl.root.PreferencesRootPresenterTest" :appnav:testDebugUnitTest --tests "io.element.android.appnav.loggedin.LoggedInPresenterTest"

diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 2539f9e1d54..fbecfeb60b8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.core.meta.BuildMeta
 import io.element.android.libraries.matrix.api.MatrixClient
 import io.element.android.libraries.matrix.api.encryption.EncryptionService
 import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
 import io.element.android.libraries.matrix.api.sync.SyncService
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
 import io.element.android.libraries.push.api.PushService
 import io.element.android.libraries.pushproviders.api.RegistrationFailure
 import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
@@ -60,6 +62,7 @@ class LoggedInPresenter @Inject constructor(
             pushService.ignoreRegistrationError(matrixClient.sessionId)
         }.collectAsState(initial = false)
         val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
+        LaunchedEffect(Unit) { preloadAccountManagementUrl() }
         LaunchedEffect(Unit) {
             sessionVerificationService.sessionVerifiedStatus
                 .onEach { sessionVerifiedStatus ->
@@ -202,4 +205,9 @@ class LoggedInPresenter @Inject constructor(
             analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
         }
     }
+
+    private fun CoroutineScope.preloadAccountManagementUrl() = launch {
+        matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
+        matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList)
+    }
 }

diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index bfd416eaf3a..aac40e000ce 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -5,12 +5,11 @@
  * Please see LICENSE files in the repository root for full details.
  */

+@file:OptIn(ExperimentalCoroutinesApi::class)
+
 package io.element.android.appnav.loggedin

-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 im.vector.app.features.analytics.plan.CryptoSessionStateChange
 import im.vector.app.features.analytics.plan.UserProperties
@@ -19,6 +18,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
 import io.element.android.libraries.matrix.api.core.SessionId
 import io.element.android.libraries.matrix.api.encryption.EncryptionService
 import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
 import io.element.android.libraries.matrix.api.sync.SyncState
@@ -45,8 +45,8 @@ import io.element.android.tests.testutils.lambda.any
 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.TestScope
 import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
 import org.junit.Rule
@@ -58,10 +58,7 @@ class LoggedInPresenterTest {

     @Test
     fun `present - initial state`() = runTest {
-        val presenter = createLoggedInPresenter()
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        createLoggedInPresenter().test {
             val initialState = awaitItem()
             assertThat(initialState.showSyncSpinner).isFalse()
             assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
@@ -69,13 +66,32 @@ class LoggedInPresenterTest {
         }
     }

+    @Test
+    fun `present - ensure that account urls are preloaded`() = runTest {
+        val accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
+        val matrixClient = FakeMatrixClient(
+            accountManagementUrlResult = accountManagementUrlResult,
+        )
+        createLoggedInPresenter(
+            matrixClient = matrixClient,
+        ).test {
+            awaitItem()
+            advanceUntilIdle()
+            accountManagementUrlResult.assertions().isCalledExactly(2)
+                .withSequence(
+                    listOf(value(AccountManagementAction.Profile)),
+                    listOf(value(AccountManagementAction.SessionsList)),
+                )
+        }
+    }
+
     @Test
     fun `present - show sync spinner`() = runTest {
         val roomListService = FakeRoomListService()
-        val presenter = createLoggedInPresenter(roomListService, SyncState.Running)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        createLoggedInPresenter(
+            syncState = SyncState.Running,
+            matrixClient = FakeMatrixClient(roomListService = roomListService),
+        ).test {
             val initialState = awaitItem()
             assertThat(initialState.showSyncSpinner).isFalse()
             roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
@@ -92,18 +108,18 @@ class LoggedInPresenterTest {
         val verificationService = FakeSessionVerificationService()
         val encryptionService = FakeEncryptionService()
         val buildMeta = aBuildMeta()
-        val presenter = LoggedInPresenter(
-            matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService),
+        LoggedInPresenter(
+            matrixClient = FakeMatrixClient(
+                roomListService = roomListService,
+                encryptionService = encryptionService,
+            ),
             syncService = FakeSyncService(initialSyncState = SyncState.Running),
             pushService = FakePushService(),
             sessionVerificationService = verificationService,
             analyticsService = analyticsService,
             encryptionService = encryptionService,
             buildMeta = buildMeta,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
             encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
             verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
@@ -129,13 +145,10 @@ class LoggedInPresenterTest {
         val verificationService = FakeSessionVerificationService(
             initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = verificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.errorOrNull())
                 .isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
@@ -155,13 +168,13 @@ class LoggedInPresenterTest {
         val pushService = createFakePushService(
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+            matrixClient = FakeMatrixClient(
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
             lambda.assertions()
@@ -188,13 +201,13 @@ class LoggedInPresenterTest {
         val pushService = createFakePushService(
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+            matrixClient = FakeMatrixClient(
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
             lambda.assertions()
@@ -233,13 +246,13 @@ class LoggedInPresenterTest {
             currentPushProvider = { pushProvider },
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+            matrixClient = FakeMatrixClient(
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
             lambda.assertions()
@@ -277,13 +290,13 @@ class LoggedInPresenterTest {
             currentPushProvider = { pushProvider },
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+            matrixClient = FakeMatrixClient(
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
             lambda.assertions()
@@ -317,13 +330,10 @@ class LoggedInPresenterTest {
             currentPushProvider = { pushProvider },
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.errorOrNull())
                 .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@@ -345,13 +355,10 @@ class LoggedInPresenterTest {
             registerWithLambda = lambda,
             setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.errorOrNull())
                 .isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
@@ -394,13 +401,10 @@ class LoggedInPresenterTest {
             registerWithLambda = lambda,
             selectPushProviderLambda = selectPushProviderLambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.errorOrNull())
                 .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@@ -445,13 +449,13 @@ class LoggedInPresenterTest {
             pushProvider1 = pushProvider1,
             registerWithLambda = lambda,
         )
-        val presenter = createLoggedInPresenter(
+        createLoggedInPresenter(
             pushService = pushService,
             sessionVerificationService = sessionVerificationService,
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+            matrixClient = FakeMatrixClient(
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val finalState = awaitFirstItem()
             assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
             lambda.assertions().isCalledOnce()
@@ -505,10 +509,9 @@ class LoggedInPresenterTest {
             currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
             availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
         )
-        val presenter = createLoggedInPresenter(matrixClient = matrixClient)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        createLoggedInPresenter(
+            matrixClient = matrixClient,
+        ).test {
             val initialState = awaitItem()
             assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()

@@ -526,13 +529,14 @@ class LoggedInPresenterTest {
             assertThat(ignoreSdkError).isTrue()
             null
         }
-        val matrixClient = FakeMatrixClient().apply {
+        val matrixClient = FakeMatrixClient(
+            accountManagementUrlResult = { Result.success(null) },
+        ).apply {
             this.logoutLambda = logoutLambda
         }
-        val presenter = createLoggedInPresenter(matrixClient = matrixClient)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        createLoggedInPresenter(
+            matrixClient = matrixClient,
+        ).test {
             val initialState = awaitItem()

             initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
@@ -548,14 +552,15 @@ class LoggedInPresenterTest {
         return awaitItem()
     }

-    private fun TestScope.createLoggedInPresenter(
-        roomListService: RoomListService = FakeRoomListService(),
+    private fun createLoggedInPresenter(
         syncState: SyncState = SyncState.Running,
         analyticsService: AnalyticsService = FakeAnalyticsService(),
         sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
         encryptionService: EncryptionService = FakeEncryptionService(),
         pushService: PushService = FakePushService(),
-        matrixClient: MatrixClient = FakeMatrixClient(roomListService = roomListService),
+        matrixClient: MatrixClient = FakeMatrixClient(
+            accountManagementUrlResult = { Result.success(null) },
+        ),
         buildMeta: BuildMeta = aBuildMeta(),
     ): LoggedInPresenter {
         return LoggedInPresenter(
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 6f85c436210..72ae8bdb10e 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -5,12 +5,11 @@
  * Please see LICENSE files in the repository root for full details.
  */

+@file:OptIn(ExperimentalCoroutinesApi::class)
+
 package io.element.android.features.preferences.impl.root

-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.direct.aDirectLogoutState
 import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@@ -18,6 +17,7 @@ import io.element.android.libraries.core.meta.BuildType
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
 import io.element.android.libraries.indicator.impl.DefaultIndicatorService
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.user.MatrixUser
 import io.element.android.libraries.matrix.test.AN_AVATAR_URL
 import io.element.android.libraries.matrix.test.A_USER_NAME
@@ -27,6 +27,10 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
 import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
 import io.element.android.services.analytics.test.FakeAnalyticsService
 import io.element.android.tests.testutils.WarmUpRule
+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
 import org.junit.Test
@@ -37,11 +41,16 @@ class PreferencesRootPresenterTest {

     @Test
     fun `present - initial state`() = runTest {
-        val matrixClient = FakeMatrixClient(canDeactivateAccountResult = { true })
-        val presenter = createPresenter(matrixClient = matrixClient)
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        val accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { action ->
+            Result.success("$action url")
+        }
+        val matrixClient = FakeMatrixClient(
+            canDeactivateAccountResult = { true },
+            accountManagementUrlResult = accountManagementUrlResult,
+        )
+        createPresenter(
+            matrixClient = matrixClient,
+        ).test {
             val initialState = awaitItem()
             assertThat(initialState.myUser).isEqualTo(
                 MatrixUser(
@@ -71,19 +80,26 @@ class PreferencesRootPresenterTest {
             assertThat(loadedState.canDeactivateAccount).isTrue()
             assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
             assertThat(loadedState.snackbarMessage).isNull()
+            skipItems(1)
+            val finalState = awaitItem()
+            accountManagementUrlResult.assertions().isCalledExactly(2)
+                .withSequence(
+                    listOf(value(AccountManagementAction.Profile)),
+                    listOf(value(AccountManagementAction.SessionsList)),
+                )
+            assertThat(finalState.accountManagementUrl).isEqualTo("Profile url")
+            assertThat(finalState.devicesManagementUrl).isEqualTo("SessionsList url")
         }
     }

     @Test
     fun `present - can deactivate account is false if the Matrix client say so`() = runTest {
-        val presenter = createPresenter(
+        createPresenter(
             matrixClient = FakeMatrixClient(
-                canDeactivateAccountResult = { false }
-            )
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+                canDeactivateAccountResult = { false },
+                accountManagementUrlResult = { Result.success(null) },
+            ),
+        ).test {
             val loadedState = awaitFirstItem()
             assertThat(loadedState.canDeactivateAccount).isFalse()
         }
@@ -91,12 +107,13 @@ class PreferencesRootPresenterTest {

     @Test
     fun `present - developer settings is hidden by default in release builds`() = runTest {
-        val presenter = createPresenter(
+        createPresenter(
+            matrixClient = FakeMatrixClient(
+                canDeactivateAccountResult = { true },
+                accountManagementUrlResult = { Result.success(null) },
+            ),
             showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE))
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val loadedState = awaitFirstItem()
             assertThat(loadedState.showDeveloperSettings).isFalse()
         }
@@ -104,12 +121,13 @@ class PreferencesRootPresenterTest {

     @Test
     fun `present - developer settings can be enabled in release builds`() = runTest {
-        val presenter = createPresenter(
+        createPresenter(
+            matrixClient = FakeMatrixClient(
+                canDeactivateAccountResult = { true },
+                accountManagementUrlResult = { Result.success(null) },
+            ),
             showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE))
-        )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
+        ).test {
             val loadedState = awaitFirstItem()
             repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) {
                 assertThat(loadedState.showDeveloperSettings).isFalse()
@@ -125,7 +143,7 @@ class PreferencesRootPresenterTest {
     }

     private fun createPresenter(
-        matrixClient: FakeMatrixClient = FakeMatrixClient(canDeactivateAccountResult = { true }),
+        matrixClient: FakeMatrixClient = FakeMatrixClient(),
         sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
         showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
     ) = PreferencesRootPresenter(
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 93c2d4c075a..030e8db7131 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -72,11 +72,11 @@ class FakeMatrixClient(
     private val syncService: FakeSyncService = FakeSyncService(),
     private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
     private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
-    private val accountManagementUrlString: Result<String?> = Result.success(null),
+    private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
     private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
         Result.success(
-        Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
-    )
+            Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
+        )
     },
     private val getRoomPreviewResult: (RoomIdOrAlias, List<String>) -> Result<RoomPreview> = { _, _ -> Result.failure(AN_EXCEPTION) },
     private val clearCacheLambda: () -> Unit = { lambdaError() },
@@ -192,8 +192,8 @@ class FakeMatrixClient(
         return Result.success(result)
     }

-    override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> {
-        return accountManagementUrlString
+    override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> = simulateLongTask {
+        accountManagementUrlResult(action)
     }

     override suspend fun uploadMedia(


Consider that the topic of a room has been removed when it's blank.

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

## Motivation and context

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

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

<img width="379" alt="image" src="https://github.com/user-attachments/assets/6869cf63-e035-429b-9c2b-26f12ca2a8c6" />

## Tests

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

- In a room, set the topic to ""
- observe the rendering on the timeline.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

[Task] Show a blank topic as removed in the state event.
Please see and discuss the details in the meta issue (this fixes Problem 2).

diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
index 493f5b1584e..3f0f4a1dc29 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
@@ -63,7 +63,7 @@ class StateContentFormatter @Inject constructor(
                 }
             }
             is OtherState.RoomTopic -> {
-                val hasRoomTopic = content.topic != null
+                val hasRoomTopic = content.topic?.isNotBlank() == true
                 when {
                     senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed_by_you, content.topic)
                     senderIsYou && !hasRoomTopic -> sp.getString(R.string.state_event_room_topic_removed_by_you)

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 7090b2467b5..11b809258e9 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
@@ -662,6 +662,7 @@ class DefaultRoomLastMessageFormatterTest {
         val roomTopic = "New topic"
         val changedContent = StateContent("", OtherState.RoomTopic(roomTopic))
         val removedContent = StateContent("", OtherState.RoomTopic(null))
+        val blankContent = StateContent("", OtherState.RoomTopic(""))

         val youChangedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
         val youChangedRoomTopic = formatter.format(youChangedRoomTopicEvent, false)
@@ -678,6 +679,14 @@ class DefaultRoomLastMessageFormatterTest {
         val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
         val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent, false)
         assertThat(someoneRemovedRoomTopic).isEqualTo("$otherName removed the room topic")
+
+        val youSetBlankRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = blankContent)
+        val youSetBlankRoomTopic = formatter.format(youSetBlankRoomTopicEvent, false)
+        assertThat(youSetBlankRoomTopic).isEqualTo("You removed the room topic")
+
+        val someoneSetBlankRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = blankContent)
+        val someoneSetBlankRoomTopic = formatter.format(someoneSetBlankRoomTopicEvent, false)
+        assertThat(someoneSetBlankRoomTopic).isEqualTo("$otherName removed the room topic")
     }

     @Test