From 66d6d365d7dda5b1c98f08ad97274a0839410069 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 14 Sep 2026 19:05:18 +0200 Subject: [PATCH] Route calls through an entry point the native stack can implement Adds the seam a native MatrixRTC call stack plugs into, with nothing behind it yet, so that the stack itself lands as a self-contained change. `FeatureFlags.NativeCall` is off by default and unfinished. `features/callnative/api` declares `NativeCallEntryPoint`, and `features/callnative/impl` provides a default that logs and does nothing: turning the flag on today gets no call and a line in the log. That is the honest outcome, where falling back to the WebView would make the flag look broken rather than unimplemented. `DefaultElementCallEntryPoint` forks on the flag. The read is asynchronous because the flag is storage-backed, and caching it in an eagerly started StateFlow would race a call placed just after app start onto the default value. Co-Authored-By: Claude Opus 5 (1M context) --- features/call/impl/build.gradle.kts | 1 + .../call/impl/DefaultElementCallEntryPoint.kt | 23 ++++++++++++- .../call/DefaultElementCallEntryPointTest.kt | 34 +++++++++++++++++++ .../call/utils/FakeNativeCallEntryPoint.kt | 17 ++++++++++ features/callnative/api/build.gradle.kts | 21 ++++++++++++ .../callnative/api/NativeCallEntryPoint.kt | 24 +++++++++++++ features/callnative/impl/build.gradle.kts | 29 ++++++++++++++++ .../impl/DefaultNativeCallEntryPoint.kt | 29 ++++++++++++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 ++++ 9 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt create mode 100644 features/callnative/api/build.gradle.kts create mode 100644 features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt create mode 100644 features/callnative/impl/build.gradle.kts create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index d01e31451e..e872678b63 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { implementation(libs.coil.compose) implementation(libs.serialization.json) implementation(libs.element.call.embedded) + implementation(projects.features.callnative.api) api(projects.features.call.api) testCommonDependencies(libs, true) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt index 532db3305d..074ea87551 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt @@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.IntentProvider +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope 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.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber @ContributesBinding(AppScope::class) class DefaultElementCallEntryPoint( @ApplicationContext private val context: Context, private val activeCallManager: ActiveCallManager, + private val nativeCallEntryPoint: NativeCallEntryPoint, + private val featureFlagService: FeatureFlagService, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : ElementCallEntryPoint { companion object { const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" @@ -31,7 +41,18 @@ class DefaultElementCallEntryPoint( } override fun startCall(callData: CallData) { - context.startActivity(IntentProvider.createIntent(context, callData)) + // The flag is backed by storage, so it has to be read asynchronously. Caching it in an + // eagerly started StateFlow would race: a call placed just after app start could be routed + // on the default value before the real one arrived. + appCoroutineScope.launch { + val useNativeCall = featureFlagService.isFeatureEnabled(FeatureFlags.NativeCall) + Timber.i("startCall: roomId=${callData.roomId}, nativeCall=$useNativeCall") + if (useNativeCall) { + nativeCallEntryPoint.startCall(callData) + } else { + context.startActivity(IntentProvider.createIntent(context, callData)) + } + } } override suspend fun handleIncomingCall( diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt index 9b2ee3d923..82c28209f7 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt @@ -16,15 +16,21 @@ import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.utils.FakeActiveCallManager +import io.element.android.features.call.utils.FakeNativeCallEntryPoint +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.AN_EVENT_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_USER_ID_2 import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.RuntimeEnvironment @@ -32,10 +38,13 @@ import org.robolectric.Shadows.shadowOf import kotlin.time.Duration.Companion.seconds class DefaultElementCallEntryPointTest : RobolectricTest() { + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { val entryPoint = createEntryPoint() entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) + // The flag is read asynchronously before routing. + advanceUntilIdle() val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java) val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity @@ -43,6 +52,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startCall - routes to the native call stack when the feature flag is on`() = runTest { + val startNativeCallLambda = lambdaRecorder {} + val entryPoint = createEntryPoint( + nativeCallEntryPoint = FakeNativeCallEntryPoint(startNativeCallLambda), + isNativeCallEnabled = true, + ) + val callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = true) + + entryPoint.startCall(callData) + advanceUntilIdle() + + startNativeCallLambda.assertions().isCalledOnce().with(value(callData)) + // The WebView Activity must not also be launched. + assertThat(shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity).isNull() + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { @@ -70,8 +97,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { private fun TestScope.createEntryPoint( activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), + nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(), + isNativeCallEnabled: Boolean = false, ) = DefaultElementCallEntryPoint( context = InstrumentationRegistry.getInstrumentation().targetContext, activeCallManager = activeCallManager, + nativeCallEntryPoint = nativeCallEntryPoint, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled), + ), + appCoroutineScope = this, ) } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt new file mode 100644 index 0000000000..742ba55bdc --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt @@ -0,0 +1,17 @@ +/* + * 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.call.utils + +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint + +class FakeNativeCallEntryPoint( + private val startCallLambda: (CallData) -> Unit = {}, +) : NativeCallEntryPoint { + override fun startCall(callData: CallData) = startCallLambda(callData) +} diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts new file mode 100644 index 0000000000..73c478ccb6 --- /dev/null +++ b/features/callnative/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.callnative.api" +} + +dependencies { + // api, not implementation: CallData is part of NativeCallEntryPoint's signature. + api(projects.features.call.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt new file mode 100644 index 0000000000..a964c1502a --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * 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.api + +import io.element.android.features.call.api.CallData + +/** + * Starts a call on the native MatrixRTC stack instead of the Element Call WebView. + * + * Experimental: gated behind `FeatureFlags.NativeCall` and reached only through + * [io.element.android.features.call.api.ElementCallEntryPoint], so nothing else in the app has to know + * it exists. + * + * There is nothing behind this seam yet - the default implementation does nothing and says so. The + * native stack that implements it is a separate library, added in a follow-up. + */ +interface NativeCallEntryPoint { + fun startCall(callData: CallData) +} diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts new file mode 100644 index 0000000000..26c9eabae3 --- /dev/null +++ b/features/callnative/impl/build.gradle.kts @@ -0,0 +1,29 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.callnative.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.callnative.api) + + implementation(projects.features.call.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + + testCommonDependencies(libs) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt new file mode 100644 index 0000000000..ecfdafd76f --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint +import timber.log.Timber + +/** + * What answers [NativeCallEntryPoint] until the native MatrixRTC stack is wired in: nothing. + * + * The seam exists so that `DefaultElementCallEntryPoint` can fork on `FeatureFlags.NativeCall` today, + * and so that adding the stack later is a change to this module alone. A user who turns the flag on + * before then gets no call and a line in the log, which is the honest outcome - falling back to the + * WebView would make the flag look broken rather than unimplemented. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallEntryPoint : NativeCallEntryPoint { + override fun startCall(callData: CallData) { + Timber.w("Native call requested for ${callData.roomId} but no native call stack is present in this build") + } +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 98bb55fecf..1b7aad30b3 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -162,4 +162,11 @@ enum class FeatureFlags( isFinished = false, isInLabs = false, ), + NativeCall( + key = "feature.native_call", + title = "Native calls (experimental)", + description = "Place calls with the native MatrixRTC stack instead of the Element Call WebView.", + defaultValue = { false }, + isFinished = false, + ), }