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) <noreply@anthropic.com>
This commit is contained in:
Valere
2026-09-14 19:05:18 +02:00
committed by Valere Fedronic
parent 0059c72a32
commit 66d6d365d7
9 changed files with 184 additions and 1 deletions
+1
View File
@@ -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)
@@ -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(
@@ -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<CallData, Unit> {}
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,
)
}
@@ -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)
}
+21
View File
@@ -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)
}
@@ -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)
}
+29
View File
@@ -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)
}
@@ -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")
}
}
@@ -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,
),
}