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

Issue:
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


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

Pull request: 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


Test command: ./gradlew :features:roomlist:impl:testDebugUnitTest --tests "io.element.android.features.roomlist.impl.RoomListViewTest" :libraries:androidutils:testDebugUnitTest --tests "io.element.android.libraries.androidutils.throttler.FirstThrottlerTest"

Fix patch:
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)
-    }
 }


Test patch:
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()
+    }
+}