Browse Source

[Element Call] Keep MatrixClient alive while the call is working (#1695)

* Element Call: keep MatrixClient alive to get event updates
pull/1711/head
Jorge Martin Espinosa 11 months ago committed by GitHub
parent
commit
355ee95964
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
  2. 6
      features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenEvents.kt
  3. 47
      features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt
  4. 2
      features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt
  5. 6
      features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt
  6. 69
      features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
  7. 7
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt
  8. 2
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt

2
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) sessionIdsToMatrixClient.remove(sessionId)
} }
fun getOrNull(sessionId: SessionId): MatrixClient? { override fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId] return sessionIdsToMatrixClient[sessionId]
} }

6
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreeEvents.kt → 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 import io.element.android.features.call.utils.WidgetMessageInterceptor
sealed interface CallScreeEvents { sealed interface CallScreenEvents {
data object Hangup : CallScreeEvents data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreeEvents data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
} }

47
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt

@ -17,6 +17,7 @@
package io.element.android.features.call.ui package io.element.android.features.call.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue 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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -53,9 +57,11 @@ class CallScreenPresenter @AssistedInject constructor(
@Assisted private val callType: CallType, @Assisted private val callType: CallType,
@Assisted private val navigator: CallScreenNavigator, @Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider, private val callWidgetProvider: CallWidgetProvider,
private val userAgentProvider: UserAgentProvider, userAgentProvider: UserAgentProvider,
private val clock: SystemClock, private val clock: SystemClock,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val matrixClientsProvider: MatrixClientProvider,
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
@ -78,6 +84,8 @@ class CallScreenPresenter @AssistedInject constructor(
loadUrl(callType, urlState, callWidgetDriver) loadUrl(callType, urlState, callWidgetDriver)
} }
HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver -> callWidgetDriver.value?.let { driver ->
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
driver.incomingMessages driver.incomingMessages
@ -115,21 +123,22 @@ class CallScreenPresenter @AssistedInject constructor(
} }
} }
fun handleEvents(event: CallScreeEvents) { fun handleEvents(event: CallScreenEvents) {
when (event) { when (event) {
is CallScreeEvents.Hangup -> { is CallScreenEvents.Hangup -> {
val widgetId = callWidgetDriver.value?.id val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isJoinedCall) { if (widgetId != null && interceptor != null && isJoinedCall) {
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically. // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor) sendHangupMessage(widgetId, interceptor)
isJoinedCall = false
} else { } else {
coroutineScope.launch { coroutineScope.launch {
close(callWidgetDriver.value, navigator) close(callWidgetDriver.value, navigator)
} }
} }
} }
is CallScreeEvents.SetupMessageChannels -> { is CallScreenEvents.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor 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? { private fun parseMessage(message: String): WidgetMessage? {
return WidgetMessageSerializer.deserialize(message).getOrNull() return WidgetMessageSerializer.deserialize(message).getOrNull()
} }

2
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt

@ -22,5 +22,5 @@ data class CallScreenState(
val urlState: Async<String>, val urlState: Async<String>,
val userAgent: String, val userAgent: String,
val isInWidgetMode: Boolean, val isInWidgetMode: Boolean,
val eventSink: (CallScreeEvents) -> Unit, val eventSink: (CallScreenEvents) -> Unit,
) )

6
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt

@ -65,14 +65,14 @@ internal fun CallScreenView(
navigationIcon = { navigationIcon = {
BackButton( BackButton(
resourceId = CommonDrawables.ic_compound_close, resourceId = CommonDrawables.ic_compound_close,
onClick = { state.eventSink(CallScreeEvents.Hangup) } onClick = { state.eventSink(CallScreenEvents.Hangup) }
) )
} }
) )
} }
) { padding -> ) { padding ->
BackHandler { BackHandler {
state.eventSink(CallScreeEvents.Hangup) state.eventSink(CallScreenEvents.Hangup)
} }
CallWebView( CallWebView(
modifier = Modifier modifier = Modifier
@ -88,7 +88,7 @@ internal fun CallScreenView(
}, },
onWebViewCreated = { webView -> onWebViewCreated = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView) val interceptor = WebViewWidgetMessageInterceptor(webView)
state.eventSink(CallScreeEvents.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
} }
) )
} }

69
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.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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.matrix.test.widget.FakeWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi 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.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
@ -95,7 +102,7 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() 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 // And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message") widgetDriver.givenIncomingMessage("A message")
@ -125,9 +132,9 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() 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 // Let background coroutines run
runCurrent() runCurrent()
@ -155,7 +162,7 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() 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"}""") 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( private fun TestScope.createCallScreenPresenter(
callType: CallType, callType: CallType,
navigator: CallScreenNavigator = FakeCallScreenNavigator(), navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
): CallScreenPresenter { ): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider { val userAgentProvider = object : UserAgentProvider {
override fun provide(): String { override fun provide(): String {
@ -189,6 +248,8 @@ class CallScreenPresenterTest {
userAgentProvider, userAgentProvider,
clock, clock,
dispatchers, dispatchers,
matrixClientsProvider,
this,
) )
} }
} }

7
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. * Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider.
*/ */
suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient>
/**
* 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?
} }

2
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<MatrixClient> = { Result.success(FakeMatrixClient()) } private val getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) }
) : MatrixClientProvider { ) : MatrixClientProvider {
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId) override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId)
override fun getOrNull(sessionId: SessionId): MatrixClient? = getClient(sessionId).getOrNull()
} }

Loading…
Cancel
Save