diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt index 372fead0ee..f75725b1bb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt @@ -49,7 +49,7 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: sessionIdsToMatrixClient.remove(sessionId) } - fun getOrNull(sessionId: SessionId): MatrixClient? { + override fun getOrNull(sessionId: SessionId): MatrixClient? { return sessionIdsToMatrixClient[sessionId] } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreeEvents.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenEvents.kt similarity index 86% rename from features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreeEvents.kt rename to features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenEvents.kt index 8ed4454fea..d16baacf3e 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreeEvents.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenEvents.kt @@ -18,7 +18,7 @@ package io.element.android.features.call.ui import io.element.android.features.call.utils.WidgetMessageInterceptor -sealed interface CallScreeEvents { - data object Hangup : CallScreeEvents - data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreeEvents +sealed interface CallScreenEvents { + data object Hangup : CallScreenEvents + data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt index f9bb8bde2f..6883ebeb61 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt @@ -17,6 +17,7 @@ package io.element.android.features.call.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -37,10 +38,13 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -53,9 +57,11 @@ class CallScreenPresenter @AssistedInject constructor( @Assisted private val callType: CallType, @Assisted private val navigator: CallScreenNavigator, private val callWidgetProvider: CallWidgetProvider, - private val userAgentProvider: UserAgentProvider, + userAgentProvider: UserAgentProvider, private val clock: SystemClock, private val dispatchers: CoroutineDispatchers, + private val matrixClientsProvider: MatrixClientProvider, + private val appCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory @@ -78,6 +84,8 @@ class CallScreenPresenter @AssistedInject constructor( loadUrl(callType, urlState, callWidgetDriver) } + HandleMatrixClientSyncState() + callWidgetDriver.value?.let { driver -> LaunchedEffect(Unit) { driver.incomingMessages @@ -115,21 +123,22 @@ class CallScreenPresenter @AssistedInject constructor( } } - fun handleEvents(event: CallScreeEvents) { + fun handleEvents(event: CallScreenEvents) { when (event) { - is CallScreeEvents.Hangup -> { + is CallScreenEvents.Hangup -> { val widgetId = callWidgetDriver.value?.id val interceptor = messageInterceptor.value if (widgetId != null && interceptor != null && isJoinedCall) { // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically. sendHangupMessage(widgetId, interceptor) + isJoinedCall = false } else { coroutineScope.launch { close(callWidgetDriver.value, navigator) } } } - is CallScreeEvents.SetupMessageChannels -> { + is CallScreenEvents.SetupMessageChannels -> { messageInterceptor.value = event.widgetMessageInterceptor } } @@ -166,6 +175,36 @@ class CallScreenPresenter @AssistedInject constructor( } } + @Composable + private fun HandleMatrixClientSyncState() { + val coroutineScope = rememberCoroutineScope() + DisposableEffect(Unit) { + val client = (callType as? CallType.RoomCall)?.sessionId?.let { + matrixClientsProvider.getOrNull(it) + } ?: return@DisposableEffect onDispose { } + + coroutineScope.launch { + client.syncService().syncState + .onEach { state -> + if (state != SyncState.Running) { + client.syncService().startSync() + } + } + .collect() + } + onDispose { + // We can't use the local coroutine scope here because it will be disposed before this effect + appCoroutineScope.launch { + client.syncService().run { + if (syncState.value == SyncState.Running) { + stopSync() + } + } + } + } + } + } + private fun parseMessage(message: String): WidgetMessage? { return WidgetMessageSerializer.deserialize(message).getOrNull() } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt index d9716251fc..12cd7612ae 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt @@ -22,5 +22,5 @@ data class CallScreenState( val urlState: Async, val userAgent: String, val isInWidgetMode: Boolean, - val eventSink: (CallScreeEvents) -> Unit, + val eventSink: (CallScreenEvents) -> Unit, ) diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt index 33611515a7..acc01e6149 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt @@ -65,14 +65,14 @@ internal fun CallScreenView( navigationIcon = { BackButton( resourceId = CommonDrawables.ic_compound_close, - onClick = { state.eventSink(CallScreeEvents.Hangup) } + onClick = { state.eventSink(CallScreenEvents.Hangup) } ) } ) } ) { padding -> BackHandler { - state.eventSink(CallScreeEvents.Hangup) + state.eventSink(CallScreenEvents.Hangup) } CallWebView( modifier = Modifier @@ -88,7 +88,7 @@ internal fun CallScreenView( }, onWebViewCreated = { webView -> val interceptor = WebViewWidgetMessageInterceptor(webView) - state.eventSink(CallScreeEvents.SetupMessageChannels(interceptor)) + state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) } ) } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index c318b1dfaa..77f83de209 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -25,14 +25,21 @@ import io.element.android.features.call.utils.FakeCallWidgetProvider import io.element.android.features.call.utils.FakeWidgetMessageInterceptor import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runCurrent @@ -95,7 +102,7 @@ class CallScreenPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) // And incoming message from the Widget Driver is passed to the WebView widgetDriver.givenIncomingMessage("A message") @@ -125,9 +132,9 @@ class CallScreenPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) - initialState.eventSink(CallScreeEvents.Hangup) + initialState.eventSink(CallScreenEvents.Hangup) // Let background coroutines run runCurrent() @@ -155,7 +162,7 @@ class CallScreenPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor)) + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""") @@ -169,12 +176,64 @@ class CallScreenPresenterTest { } } + @Test + fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeWidgetDriver() + val matrixClient = FakeMatrixClient() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + consumeItemsUntilTimeout() + + assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Running) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - automatically stops the Matrix client sync on dispose`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeWidgetDriver() + val matrixClient = FakeMatrixClient() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + val hasRun = Mutex(true) + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.collect { + hasRun.unlock() + } + } + + hasRun.lock() + + job.cancelAndJoin() + + assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Terminated) + } + private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), ): CallScreenPresenter { val userAgentProvider = object : UserAgentProvider { override fun provide(): String { @@ -189,6 +248,8 @@ class CallScreenPresenterTest { userAgentProvider, clock, dispatchers, + matrixClientsProvider, + this, ) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt index 44d1a1d1a6..eaa0356209 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt @@ -25,4 +25,11 @@ interface MatrixClientProvider { * Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider. */ suspend fun getOrRestore(sessionId: SessionId): Result + + /** + * Can be used to retrieve an existing [MatrixClient] with the given [SessionId]. + * @param sessionId the [SessionId] of the [MatrixClient] to retrieve. + * @return the [MatrixClient] if it exists. + */ + fun getOrNull(sessionId: SessionId): MatrixClient? } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt index 80cdcff7ec..53ebf00f41 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt @@ -24,4 +24,6 @@ class FakeMatrixClientProvider( private val getClient: (SessionId) -> Result = { Result.success(FakeMatrixClient()) } ) : MatrixClientProvider { override suspend fun getOrRestore(sessionId: SessionId): Result = getClient(sessionId) + + override fun getOrNull(sessionId: SessionId): MatrixClient? = getClient(sessionId).getOrNull() }