From 3d60dced02963f2813fa4008f2036e4911cef55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 23 Jun 2025 16:24:19 +0200 Subject: [PATCH 1/2] Display error dialog if Element Call can't be joined When on room call type, we listen for the 'join' action for 10s, and if we don't receive one we display an error dialog which will close the call screen. --- .../features/call/impl/data/WidgetMessage.kt | 3 +++ .../call/impl/ui/CallScreenPresenter.kt | 25 ++++++++++++------- .../call/ui/CallScreenPresenterTest.kt | 9 +++---- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt index 0e9ddbe3649..c18fef410ee 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt @@ -30,6 +30,9 @@ data class WidgetMessage( @Serializable enum class Action { + @SerialName("io.element.join") + Join, + @SerialName("im.vector.hangup") HangUp, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index e16428de3bf..f96959e03d1 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -44,13 +44,11 @@ import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import timber.log.Timber import java.util.UUID import kotlin.time.Duration.Companion.seconds @@ -91,6 +89,8 @@ class CallScreenPresenter @AssistedInject constructor( var webViewError by remember { mutableStateOf(null) } val languageTag = languageTagProvider.provideLanguageTag() val theme = if (ElementTheme.isLightTheme) "light" else "dark" + var loadCallTimeoutJob by remember { mutableStateOf(null) } + DisposableEffect(Unit) { coroutineScope.launch { // Sets the call as joined @@ -121,6 +121,15 @@ class CallScreenPresenter @AssistedInject constructor( callWidgetDriver.value?.let { driver -> LaunchedEffect(Unit) { + loadCallTimeoutJob = launch { + // Wait for the call to be loaded, if it takes too long, we display an error + delay(10.seconds) + + Timber.w("The call took too long to be joined. Displaying an error before exiting.") + + // This will display a simple 'Sorry, an error occurred' dialog + webViewError = "" + } driver.incomingMessages .onEach { // Relay message to the WebView @@ -145,12 +154,10 @@ class CallScreenPresenter @AssistedInject constructor( if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) { if (parsedMessage.action == WidgetMessage.Action.Close) { close(callWidgetDriver.value, navigator) - } else if (parsedMessage.action == WidgetMessage.Action.SendEvent) { - // This event is received when a member joins the call, the first one will be the current one - val type = parsedMessage.data?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull - if (type == "org.matrix.msc3401.call.member") { - isJoinedCall = true - } + } else if (parsedMessage.action == WidgetMessage.Action.Join) { + // This action is received when the call is joined, we can stop the timeout job + isJoinedCall = true + loadCallTimeoutJob?.cancel() } } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 23253a2033b..148355cf571 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -225,7 +225,7 @@ import kotlin.time.Duration.Companion.seconds } @Test - fun `present - a received room member message makes the call to be active`() = runTest { + fun `present - a received 'joined' action makes the call to be active`() = runTest { val navigator = FakeCallScreenNavigator() val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( @@ -248,13 +248,10 @@ import kotlin.time.Duration.Companion.seconds messageInterceptor.givenInterceptedMessage( """ { - "action":"send_event", + "action":"io.element.join", "api":"fromWidget", "widgetId":"1", - "requestId":"1", - "data":{ - "type":"org.matrix.msc3401.call.member" - } + "requestId":"1" } """.trimIndent() ) From 2b504f295f678741d9608b4da92b3eb875737abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 24 Jun 2025 12:29:37 +0200 Subject: [PATCH 2/2] Simplify the check a bit, add extra test --- .../call/impl/ui/CallScreenPresenter.kt | 25 +++++++------- .../call/ui/CallScreenPresenterTest.kt | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index f96959e03d1..04999c82ab6 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -44,7 +44,6 @@ import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -89,7 +88,6 @@ class CallScreenPresenter @AssistedInject constructor( var webViewError by remember { mutableStateOf(null) } val languageTag = languageTagProvider.provideLanguageTag() val theme = if (ElementTheme.isLightTheme) "light" else "dark" - var loadCallTimeoutJob by remember { mutableStateOf(null) } DisposableEffect(Unit) { coroutineScope.launch { @@ -121,15 +119,6 @@ class CallScreenPresenter @AssistedInject constructor( callWidgetDriver.value?.let { driver -> LaunchedEffect(Unit) { - loadCallTimeoutJob = launch { - // Wait for the call to be loaded, if it takes too long, we display an error - delay(10.seconds) - - Timber.w("The call took too long to be joined. Displaying an error before exiting.") - - // This will display a simple 'Sorry, an error occurred' dialog - webViewError = "" - } driver.incomingMessages .onEach { // Relay message to the WebView @@ -155,14 +144,24 @@ class CallScreenPresenter @AssistedInject constructor( if (parsedMessage.action == WidgetMessage.Action.Close) { close(callWidgetDriver.value, navigator) } else if (parsedMessage.action == WidgetMessage.Action.Join) { - // This action is received when the call is joined, we can stop the timeout job isJoinedCall = true - loadCallTimeoutJob?.cancel() } } } .launchIn(this) } + + LaunchedEffect(Unit) { + // Wait for the call to be joined, if it takes too long, we display an error + delay(10.seconds) + + if (!isJoinedCall) { + Timber.w("The call took too long to be joined. Displaying an error before exiting.") + + // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call + webViewError = "" + } + } } fun handleEvents(event: CallScreenEvents) { diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 148355cf571..7709066dd04 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -261,6 +261,40 @@ import kotlin.time.Duration.Companion.seconds } } + @Test + fun `present - if in room mode and no join action is received an error is displayed`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.isCallActive).isFalse() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + skipItems(2) + + // Wait for the timeout to trigger + advanceTimeBy(10.seconds) + + val finalState = awaitItem() + assertThat(finalState.isCallActive).isFalse() + // The error dialog that will force the user to leave the call is displayed + assertThat(finalState.webViewError).isNotNull() + assertThat(finalState.webViewError).isEmpty() + } + } + @Test fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest { val navigator = FakeCallScreenNavigator()