diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts index 26c9eabae3..8cfee12e0b 100644 --- a/features/callnative/impl/build.gradle.kts +++ b/features/callnative/impl/build.gradle.kts @@ -17,13 +17,36 @@ android { 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 { 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.libraries.architecture) implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.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) + 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) } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt new file mode 100644 index 0000000000..0cb50d2038 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt @@ -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() + + // 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 + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt new file mode 100644 index 0000000000..83ec22b392 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt @@ -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 = 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, + ) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt new file mode 100644 index 0000000000..aed2f97725 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt @@ -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 = 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 = + roomMembers().orEmpty().associate { member -> + ElementCallUserId(member.userId.value) to ElementCallRoomMember( + userId = ElementCallUserId(member.userId.value), + displayName = member.displayName, + avatarUrl = member.avatarUrl, + ) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt new file mode 100644 index 0000000000..6ef057e512 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt @@ -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) + } +}