mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Build the native call stack on demand for each session
Keyed by session id in app scope rather than session-scoped, because answering a call notification after a cold start has no session graph to reach a session-scoped stack from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,13 +17,36 @@ android {
|
|||||||
|
|
||||||
setupDependencyInjection()
|
setupDependencyInjection()
|
||||||
|
|
||||||
|
// Temporary: `-PelementCallLocalVersion` points this at a locally published build of
|
||||||
|
// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal
|
||||||
|
// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it.
|
||||||
|
val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion")
|
||||||
|
.getOrElse(libs.versions.element.call.get())
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.callnative.api)
|
api(projects.features.callnative.api)
|
||||||
|
|
||||||
|
implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
|
||||||
|
implementation(libs.element.call.api)
|
||||||
|
implementation(libs.element.call.impl)
|
||||||
|
|
||||||
implementation(projects.features.call.api)
|
implementation(projects.features.call.api)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
|
// For NotificationIdProvider: the ongoing-call notification id is shared with the WebView call's
|
||||||
|
// foreground service, so that the two paths cannot leave two call notifications in the shade.
|
||||||
|
implementation(projects.libraries.push.api)
|
||||||
|
implementation(projects.services.appnavstate.api)
|
||||||
|
implementation(libs.androidx.corektx)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
|
testImplementation(libs.element.call.test)
|
||||||
|
testImplementation(projects.features.call.test)
|
||||||
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(projects.services.appnavstate.test)
|
||||||
}
|
}
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.callnative.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.call.api.ElementCallController
|
||||||
|
import io.element.android.call.api.ElementCallNotificationConfig
|
||||||
|
import io.element.android.call.api.ElementCallOptions
|
||||||
|
import io.element.android.call.api.rtc.MatrixRtcLogLevel
|
||||||
|
import io.element.android.call.api.rtc.MatrixRtcLoggingConfiguration
|
||||||
|
import io.element.android.call.impl.ElementCallStack
|
||||||
|
import io.element.android.features.call.api.CurrentCallTracker
|
||||||
|
import io.element.android.features.call.api.RingingCallTracker
|
||||||
|
import io.element.android.features.callnative.api.ElementCallTransportFactory
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.job
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native call controller of a session, built on demand.
|
||||||
|
*
|
||||||
|
* An interface so that what starts calls can be tested without the real stack, which loads the RTC
|
||||||
|
* native library.
|
||||||
|
*/
|
||||||
|
interface ElementCallControllers {
|
||||||
|
/**
|
||||||
|
* The session's controller, building its call stack on first use. Null when there is no such
|
||||||
|
* session - logged out, or a session id that cannot be restored.
|
||||||
|
*/
|
||||||
|
suspend fun getOrBuild(sessionId: SessionId): ElementCallController?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controller of whichever session currently has a call, or null when none does.
|
||||||
|
*
|
||||||
|
* For the Activity-level seams, which are app-scoped and have no session of their own to ask. Only
|
||||||
|
* sessions whose stack is already built are considered, so asking cannot start one.
|
||||||
|
*/
|
||||||
|
fun withRunningCall(): ElementCallController?
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one call stack per session, from a Matrix client, and keeps it for the session's life.
|
||||||
|
*
|
||||||
|
* App-scoped and keyed by session id rather than session-scoped, because everything that starts a call
|
||||||
|
* is app-scoped: the push path that resolves a ringing call, the notification it posts, and the Activity
|
||||||
|
* that answers it all run on a [MatrixClient] fetched by id, and none of them has a session graph. A
|
||||||
|
* session-scoped stack could only be reached after the user had navigated into the session, so answering
|
||||||
|
* a call notification after a cold start would find nothing to answer with.
|
||||||
|
*
|
||||||
|
* One stack serves every call in a session; each call opens and closes its own room underneath, which
|
||||||
|
* the component does itself.
|
||||||
|
*/
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultElementCallControllers(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
|
private val transportFactory: ElementCallTransportFactory,
|
||||||
|
private val currentCallTracker: CurrentCallTracker,
|
||||||
|
private val ringingCallTracker: RingingCallTracker,
|
||||||
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
) : ElementCallControllers {
|
||||||
|
private val stacks = ConcurrentHashMap<SessionId, ElementCallStack>()
|
||||||
|
|
||||||
|
// One at a time across every session: two calls placed at once must not race into two stacks for
|
||||||
|
// the same session, because only one of them would ever be closed.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock {
|
||||||
|
stacks[sessionId]?.let { return@withLock it.controller }
|
||||||
|
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull()
|
||||||
|
if (client == null) {
|
||||||
|
Timber.w("NativeCall: no client for $sessionId, cannot start a call")
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
build(client).also { stack ->
|
||||||
|
stacks[sessionId] = stack
|
||||||
|
// The client's scope is the stack's lifetime: the component has no shutdown of its own, so
|
||||||
|
// cancelling this at logout is what stops the RTC core. Closing here only releases the
|
||||||
|
// component's own reference, so a dead stack is not left behind it.
|
||||||
|
client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion {
|
||||||
|
stacks.remove(sessionId, stack)
|
||||||
|
stack.close()
|
||||||
|
}
|
||||||
|
}.controller
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun withRunningCall(): ElementCallController? =
|
||||||
|
stacks.values.firstNotNullOfOrNull { stack ->
|
||||||
|
stack.controller.takeIf { it.state.value != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun build(client: MatrixClient): ElementCallStack =
|
||||||
|
ElementCallStack.Builder(context, transportFactory.create(client))
|
||||||
|
.lifecycleListener(
|
||||||
|
ElementXCallLifecycleListener(
|
||||||
|
sessionId = client.sessionId,
|
||||||
|
currentCallTracker = currentCallTracker,
|
||||||
|
ringingCallTracker = ringingCallTracker,
|
||||||
|
sessionCoroutineScope = client.sessionCoroutineScope,
|
||||||
|
appForegroundStateService = appForegroundStateService,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.roomContext(ElementXRoomContextProvider(client))
|
||||||
|
.options(options())
|
||||||
|
.build(scope = client.sessionCoroutineScope)
|
||||||
|
|
||||||
|
private suspend fun options() = ElementCallOptions(
|
||||||
|
logging = loggingConfiguration(),
|
||||||
|
notification = ElementCallNotificationConfig(
|
||||||
|
// The same id the WebView call's foreground service uses, so that the two paths cannot
|
||||||
|
// leave two ongoing-call notifications in the shade.
|
||||||
|
notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read here rather than in `PlatformInitializer`, where the Rust SDK's tracing is configured: the
|
||||||
|
* core applies this when it first loads, which is when a call is first placed, and this is the first
|
||||||
|
* place where both the preference and the flag are readable.
|
||||||
|
*/
|
||||||
|
private suspend fun loggingConfiguration(): MatrixRtcLoggingConfiguration {
|
||||||
|
val logLevel = appPreferencesStore.getTracingLogLevelFlow().first()
|
||||||
|
return MatrixRtcLoggingConfiguration(
|
||||||
|
// Floored at debug: turning the SDK level down to quiet the timeline and event-cache
|
||||||
|
// traces is exactly what you do while working on calls, and it must not also silence the
|
||||||
|
// RTC core. The core's own filter holds down the parts that log per frame.
|
||||||
|
logLevel = maxOf(logLevel, LogLevel.DEBUG).toMatrixRtcLogLevel(),
|
||||||
|
writesToLogcat = featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LogLevel.toMatrixRtcLogLevel(): MatrixRtcLogLevel = when (this) {
|
||||||
|
LogLevel.ERROR -> MatrixRtcLogLevel.ERROR
|
||||||
|
LogLevel.WARN -> MatrixRtcLogLevel.WARN
|
||||||
|
LogLevel.INFO -> MatrixRtcLogLevel.INFO
|
||||||
|
LogLevel.DEBUG -> MatrixRtcLogLevel.DEBUG
|
||||||
|
LogLevel.TRACE -> MatrixRtcLogLevel.TRACE
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.callnative.impl
|
||||||
|
|
||||||
|
import io.element.android.call.api.ElementCallData
|
||||||
|
import io.element.android.call.api.ElementCallLifecycleListener
|
||||||
|
import io.element.android.features.call.api.CallData
|
||||||
|
import io.element.android.features.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.api.CurrentCallTracker
|
||||||
|
import io.element.android.features.call.api.RingingCallTracker
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a native call back to the parts of Element X that already track calls.
|
||||||
|
*
|
||||||
|
* The library knows it is in a call; Element X's room list, timeline and ringing machinery each
|
||||||
|
* already know how to react to one. This joins the two, so that a native call looks like any other
|
||||||
|
* call to the rest of the app rather than needing every reader to learn about it.
|
||||||
|
*/
|
||||||
|
class ElementXCallLifecycleListener(
|
||||||
|
private val sessionId: SessionId,
|
||||||
|
private val currentCallTracker: CurrentCallTracker,
|
||||||
|
private val ringingCallTracker: RingingCallTracker,
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
appForegroundStateService: AppForegroundStateService,
|
||||||
|
) : ElementCallLifecycleListener {
|
||||||
|
override val isAppInForeground: Flow<Boolean> = appForegroundStateService.isInForeground
|
||||||
|
|
||||||
|
override fun onCallStarted(callData: ElementCallData) {
|
||||||
|
currentCallTracker.onCallStarted(CurrentCall.RoomCall(callData.roomId()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallJoined(callData: ElementCallData) {
|
||||||
|
// The ringing side is suspending and these callbacks are not, so the work is handed to the
|
||||||
|
// session scope rather than blocking the controller's. It outlives the call on purpose: a
|
||||||
|
// ring still has to be taken down even if the call ends while this is in flight.
|
||||||
|
sessionCoroutineScope.launch { ringingCallTracker.onCallJoined(callData.toCallData()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallEnded(callData: ElementCallData?) {
|
||||||
|
currentCallTracker.onCallEnded()
|
||||||
|
callData?.let { sessionCoroutineScope.launch { ringingCallTracker.onCallEnded(it.toCallData()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ElementCallData.roomId() = RoomId(roomId.value)
|
||||||
|
|
||||||
|
private fun ElementCallData.toCallData() = CallData(
|
||||||
|
sessionId = sessionId,
|
||||||
|
roomId = roomId(),
|
||||||
|
isAudioCall = isAudioCall,
|
||||||
|
)
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.callnative.impl
|
||||||
|
|
||||||
|
import io.element.android.call.api.ElementCallRoomContext
|
||||||
|
import io.element.android.call.api.ElementCallRoomContextProvider
|
||||||
|
import io.element.android.call.api.ElementCallRoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import io.element.android.call.api.rtc.id.RoomId as ElementCallRoomId
|
||||||
|
import io.element.android.call.api.rtc.id.UserId as ElementCallUserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId as MatrixRoomId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the call what the room is called and who is in it.
|
||||||
|
*
|
||||||
|
* The RTC layer knows a participant only as a member id and a user id - it has no reason to know
|
||||||
|
* what anyone is called or what they look like. Element X does, so the join happens here rather than
|
||||||
|
* in the call UI, and it goes through Element X's room cache so that names and avatars are the same
|
||||||
|
* ones the rest of the app shows. The library ships a provider of its own that reads the SDK
|
||||||
|
* directly; it is not used, because it would miss exactly that cache.
|
||||||
|
*/
|
||||||
|
class ElementXRoomContextProvider(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
) : ElementCallRoomContextProvider {
|
||||||
|
override fun roomContext(roomId: ElementCallRoomId): Flow<ElementCallRoomContext> = flow {
|
||||||
|
val room = matrixClient.getRoom(MatrixRoomId(roomId.value))
|
||||||
|
if (room == null) {
|
||||||
|
// Emits nothing rather than guessing: the call then shows user ids, which is better than
|
||||||
|
// a name we made up.
|
||||||
|
Timber.w("NativeCall: no room for ${roomId.value}, the call will show user ids")
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
room.use {
|
||||||
|
coroutineScope {
|
||||||
|
// The member list is loaded lazily, and a call is exactly the moment it is needed:
|
||||||
|
// without this the tiles would show user ids until something else happened to ask.
|
||||||
|
// Launched rather than awaited so the room name still arrives immediately.
|
||||||
|
launch { room.updateMembers() }
|
||||||
|
emitAll(
|
||||||
|
combine(room.roomInfoFlow, room.membersStateFlow) { info, membersState ->
|
||||||
|
ElementCallRoomContext(
|
||||||
|
displayName = info.name,
|
||||||
|
isDm = info.isDm,
|
||||||
|
members = membersState.toElementCallMembers(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoomMembersState.toElementCallMembers(): Map<ElementCallUserId, ElementCallRoomMember> =
|
||||||
|
roomMembers().orEmpty().associate { member ->
|
||||||
|
ElementCallUserId(member.userId.value) to ElementCallRoomMember(
|
||||||
|
userId = ElementCallUserId(member.userId.value),
|
||||||
|
displayName = member.displayName,
|
||||||
|
avatarUrl = member.avatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.callnative.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.call.api.ElementCallData
|
||||||
|
import io.element.android.call.api.rtc.id.RoomId
|
||||||
|
import io.element.android.features.call.api.CurrentCall
|
||||||
|
import io.element.android.features.call.test.FakeCurrentCallTracker
|
||||||
|
import io.element.android.features.call.test.FakeRingingCallTracker
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ElementXCallLifecycleListenerTest {
|
||||||
|
@Test
|
||||||
|
fun `a started call is reported as the current call, and cleared when it ends`() = runTest {
|
||||||
|
val currentCallTracker = FakeCurrentCallTracker()
|
||||||
|
val listener = createListener(currentCallTracker = currentCallTracker)
|
||||||
|
|
||||||
|
listener.onCallStarted(A_CALL)
|
||||||
|
listener.onCallEnded(A_CALL)
|
||||||
|
|
||||||
|
assertThat(currentCallTracker.calls).containsExactly(
|
||||||
|
CurrentCall.RoomCall(A_ROOM_ID),
|
||||||
|
CurrentCall.None,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `joining a call stops the ring`() = runTest {
|
||||||
|
val ringingCallTracker = FakeRingingCallTracker()
|
||||||
|
val listener = createListener(ringingCallTracker = ringingCallTracker)
|
||||||
|
|
||||||
|
listener.onCallJoined(A_CALL)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(ringingCallTracker.joined.map { it.roomId }).containsExactly(A_ROOM_ID)
|
||||||
|
assertThat(ringingCallTracker.ended).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ending a call tells the ringing side, and survives a call that never had data`() = runTest {
|
||||||
|
val ringingCallTracker = FakeRingingCallTracker()
|
||||||
|
val currentCallTracker = FakeCurrentCallTracker()
|
||||||
|
val listener = createListener(
|
||||||
|
currentCallTracker = currentCallTracker,
|
||||||
|
ringingCallTracker = ringingCallTracker,
|
||||||
|
)
|
||||||
|
|
||||||
|
listener.onCallEnded(null)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// The current call is always cleared; only the ring needs to know which call it was.
|
||||||
|
assertThat(currentCallTracker.calls).containsExactly(CurrentCall.None)
|
||||||
|
assertThat(ringingCallTracker.ended).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createListener(
|
||||||
|
currentCallTracker: FakeCurrentCallTracker = FakeCurrentCallTracker(),
|
||||||
|
ringingCallTracker: FakeRingingCallTracker = FakeRingingCallTracker(),
|
||||||
|
) = ElementXCallLifecycleListener(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
currentCallTracker = currentCallTracker,
|
||||||
|
ringingCallTracker = ringingCallTracker,
|
||||||
|
sessionCoroutineScope = this,
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val A_CALL = ElementCallData(roomId = RoomId(A_ROOM_ID.value), isAudioCall = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user