From 0059c72a32e6d2df380228174af6db3748cd2441 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 14 Sep 2026 19:04:13 +0200 Subject: [PATCH 01/16] Expose the write sides of the current-call and ringing state `CurrentCallService` and `ActiveCallManager` are read by the whole app but written only by whoever is actually running a call. Writing has so far been reachable only from inside `features/call/impl`, because that is where both implementations live. Adds `CurrentCallTracker` and `RingingCallTracker` to `features/call/api`, so that a call implementation in another module can report the call it is running and answer a ring, without depending on `features/call/impl`. No behaviour change: both interfaces are implemented by the existing classes, and their methods are aliases of methods that already existed. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/call/api/CurrentCallTracker.kt | 23 +++++++++++++++ .../features/call/api/RingingCallTracker.kt | 28 +++++++++++++++++++ .../call/impl/utils/ActiveCallManager.kt | 16 +++++++++-- .../impl/utils/DefaultCurrentCallService.kt | 11 +++++--- .../call/test/FakeCurrentCallTracker.kt | 24 ++++++++++++++++ .../call/test/FakeRingingCallTracker.kt | 24 ++++++++++++++++ 6 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt create mode 100644 features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt create mode 100644 features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt create mode 100644 features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt new file mode 100644 index 0000000000..42190316c3 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt @@ -0,0 +1,23 @@ +/* + * 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.api + +/** + * The write side of [CurrentCallService], for whoever is actually running a call. + * + * Separate from [CurrentCallService] because the two have opposite audiences: everything in the app + * that wants to know whether we are in a call reads the service, while only a call implementation + * writes. The WebView path reaches the implementation directly through `ActiveCallManager`, both + * being in `features/call/impl`; the native path is in another module and would otherwise have to + * depend on that impl to report a call it is running itself. + */ +interface CurrentCallTracker { + fun onCallStarted(call: CurrentCall) + + fun onCallEnded() +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt new file mode 100644 index 0000000000..aaec286072 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt @@ -0,0 +1,28 @@ +/* + * 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.api + +/** + * Tells the ringing machinery what became of the call it is ringing for. + * + * There is one ring per incoming call, and it is owned by `ActiveCallManager` in + * `features/call/impl` - but *whichever* call implementation answers has to stop it, cancel the + * missed-call timeout and release the wake lock. The WebView path reaches the manager directly, + * being in the same module; the native path is in another one, and this is the narrow slice of it + * that anyone answering a call needs. + */ +interface RingingCallTracker { + /** + * The user is now in the call: stop ringing, take down the incoming-call UI, and cancel the + * timeout that would otherwise post a missed call. + */ + suspend fun onCallJoined(callData: CallData) + + /** The call is over. Clears the active call, and stops a ring that is still in progress. */ + suspend fun onCallEnded(callData: CallData) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 685fc932fe..c0f01fe0ac 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.appconfig.ElementCallConfig import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.RingingCallTracker import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -90,7 +92,8 @@ interface ActiveCallManager { } @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) class DefaultActiveCallManager( @ApplicationContext context: Context, @AppCoroutineScope @@ -103,7 +106,16 @@ class DefaultActiveCallManager( private val appForegroundStateService: AppForegroundStateService, private val imageLoaderHolder: ImageLoaderHolder, private val systemClock: SystemClock, -) : ActiveCallManager { +) : ActiveCallManager, RingingCallTracker { + /** + * The two [RingingCallTracker] entry points are the existing methods under the names a caller + * outside this module can reach. Deliberately not new behaviour: answering has always had to do + * exactly this, and the native path simply could not say so from another module. + */ + override suspend fun onCallJoined(callData: CallData) = joinedCall(callData) + + override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData) + private val tag = "ActiveCallManager" private var timedOutCallJob: Job? = null diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt index 1c8c0b342b..bf29ccd404 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt @@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCallService +import io.element.android.features.call.api.CurrentCallTracker import kotlinx.coroutines.flow.MutableStateFlow @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultCurrentCallService : CurrentCallService { +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) +class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker { override val currentCall = MutableStateFlow(CurrentCall.None) - fun onCallStarted(call: CurrentCall) { + override fun onCallStarted(call: CurrentCall) { currentCall.value = call } - fun onCallEnded() { + override fun onCallEnded() { currentCall.value = CurrentCall.None } } diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt new file mode 100644 index 0000000000..b6e31f8882 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.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.call.test + +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallTracker + +class FakeCurrentCallTracker : CurrentCallTracker { + /** Every transition in order, so a test can assert the call was reported before it was cleared. */ + val calls = mutableListOf() + + override fun onCallStarted(call: CurrentCall) { + calls += call + } + + override fun onCallEnded() { + calls += CurrentCall.None + } +} diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt new file mode 100644 index 0000000000..915641a0a4 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.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.call.test + +import io.element.android.features.call.api.CallData +import io.element.android.features.call.api.RingingCallTracker + +class FakeRingingCallTracker : RingingCallTracker { + val joined = mutableListOf() + val ended = mutableListOf() + + override suspend fun onCallJoined(callData: CallData) { + joined += callData + } + + override suspend fun onCallEnded(callData: CallData) { + ended += callData + } +} From 66d6d365d7dda5b1c98f08ad97274a0839410069 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 14 Sep 2026 19:05:18 +0200 Subject: [PATCH 02/16] 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, + ), } From 0d36f70c964c54878d464f8fad4d28feda4fd884 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:03 +0200 Subject: [PATCH 03/16] Resolve element-call-android from its GitHub releases The component and the matrix-rust-rtc core publish as GitHub release assets rather than to Maven, so two filtered ivy repositories resolve them; both become plain coordinates the day they reach Maven Central. Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 15 +++++++++++++- settings.gradle.kts | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1788edd14e..905db875ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,8 @@ detekt_compose_rules = "0.4.28" ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" kover = "0.9.9" +# The native MatrixRTC call component, https://github.com/element-hq/element-call-android +element_call = "0.1.0-rc.2" [libraries] # Project @@ -182,7 +184,11 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.28" +# `strictly` because element-call-matrix is a published AAR compiled against one SDK version and declares +# it as a plain `require`: without this, a library built against a newer SDK would silently drag this app +# onto it. With it, this pin always wins, and a library that needs something newer fails at configuration +# time naming both versions instead of at runtime with NoSuchMethodError. +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.09.28" } } rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others @@ -236,6 +242,13 @@ metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" # Element Call element_call_embedded = "io.element.android:element-call-embedded:0.26.0" +# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. +element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } +element_call_api = { module = "io.element.android:element-call-api" } +element_call_impl = { module = "io.element.android:element-call" } +element_call_ui = { module = "io.element.android:element-call-ui" } +element_call_matrix = { module = "io.element.android:element-call-matrix" } +element_call_test = { module = "io.element.android:element-call-test" } # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } diff --git a/settings.gradle.kts b/settings.gradle.kts index e46d80f4f2..db3aa099a4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -57,6 +57,48 @@ dependencyResolutionManagement { flatDir { dirs("libraries/matrix/libs") } + // Temporary: the native call component and the matrix-rust-rtc core publish as GitHub release + // assets, which carry full Maven metadata. Both blocks go once they reach Maven Central. + ivy { + url = uri("https://github.com/element-hq/element-call-android/releases/download") + patternLayout { artifact("v[revision]/[artifact]-[revision](-[classifier])(.[ext])") } + metadataSources { gradleMetadata() } + content { + // Per module: `element-call.*` would also catch element-call-embedded, the WebView call. + includeModule("io.element.android", "element-call-bom") + includeModule("io.element.android", "element-call-api") + includeModule("io.element.android", "element-call") + includeModule("io.element.android", "element-call-ui") + includeModule("io.element.android", "element-call-matrix") + includeModule("io.element.android", "element-call-test") + } + } + ivy { + url = uri("https://github.com/element-hq/matrix-rust-rtc/releases/download") + patternLayout { artifact("v[revision]/[artifact]-[revision](-[classifier])(.[ext])") } + // The core ships an AAR and nothing else; element-call's module metadata names it with an + // explicit aar artifact selector, so it needs no POM. + metadataSources { artifact() } + content { includeModule("io.element.android", "matrix-rtc-android") } + } + // Also temporary: testing an unreleased element-call-android build. Publish it there with + // `publishToMavenLocal -PVERSION_NAME=0.1.0-local`, then build here with + // `-PelementCallLocalVersion=0.1.0-local`. Absent without the property, so that everything + // resolves for a contributor who has never built the library. + providers.gradleProperty("elementCallLocalVersion").orNull?.let { elementCallVersion -> + logger.lifecycle("Note: resolving io.element.android:element-call-* $elementCallVersion from mavenLocal") + mavenLocal { + content { + // Per module: compound-android and element-call-embedded stay on Maven Central. + includeModule("io.element.android", "element-call-bom") + includeModule("io.element.android", "element-call-api") + includeModule("io.element.android", "element-call") + includeModule("io.element.android", "element-call-ui") + includeModule("io.element.android", "element-call-matrix") + includeModule("io.element.android", "element-call-test") + } + } + } } } From 43a13646717b408d7bf2edd6545b2ded5396a933 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:04 +0200 Subject: [PATCH 04/16] Give the native call component its view of Matrix The factory lives in libraries/matrix/impl because that is the only module compiled against the Rust SDK, and is app-scoped because a call is started for whichever account it belongs to rather than the one on screen. Co-Authored-By: Claude Opus 5 (1M context) --- features/callnative/api/build.gradle.kts | 12 +++++- .../api/ElementCallTransportFactory.kt | 26 +++++++++++++ libraries/matrix/impl/build.gradle.kts | 35 ++++++++++++++++- .../di/DefaultElementCallTransportFactory.kt | 38 +++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts index 73c478ccb6..81e5976627 100644 --- a/features/callnative/api/build.gradle.kts +++ b/features/callnative/api/build.gradle.kts @@ -13,8 +13,18 @@ android { namespace = "io.element.android.features.callnative.api" } +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android. Goes with the repositories in settings.gradle.kts. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + dependencies { - // api, not implementation: CallData is part of NativeCallEntryPoint's signature. + // api, not implementation: ElementCallMatrixTransport is part of ElementCallTransportFactory's + // signature, and CallData of NativeCallEntryPoint's. + // api, so that consumers of this module inherit the versions the BOM pins - the artifact + // below is declared without one. + api(platform("io.element.android:element-call-bom:$elementCallVersion")) + api(libs.element.call.api) 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/ElementCallTransportFactory.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt new file mode 100644 index 0000000000..bda31738f0 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt @@ -0,0 +1,26 @@ +/* + * 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.call.api.matrix.ElementCallMatrixTransport +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Makes the native call's view of Matrix for a session. + * + * The component's turnkey transport is built over a raw Rust SDK client, and only + * `libraries/matrix/impl` is compiled against the SDK. This is the seam that keeps it that way: the + * implementation lives there, everything else takes a [MatrixClient] and asks. + * + * A factory rather than a binding for the transport itself, because the call is started from app scope + * - a call can be placed for an account that is not the one on screen - and the session it belongs to + * is known only as an id until a client is fetched for it. + */ +fun interface ElementCallTransportFactory { + fun create(client: MatrixClient): ElementCallMatrixTransport +} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 13920980a2..6ef7891e10 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -20,16 +20,47 @@ 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()) + +val usesLocalRustSdk = file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists() + +if (usesLocalRustSdk) { + // element-call-matrix declares the Rust SDK as an ordinary Maven dependency, so with the local + // AAR in place a debug classpath would carry two copies of org.matrix.rustcomponents.sdk. + // Point the library's dependency at the same local AAR the app is using. + configurations.matching { it.name.startsWith("debug") }.configureEach { + resolutionStrategy.dependencySubstitution { + substitute(module("org.matrix.rustcomponents:sdk-android")) + .using(project(":libraries:rustsdk")) + .because("the local Rust SDK AAR replaces the published one on debug builds") + } + } +} + dependencies { releaseImplementation(libs.matrix.sdk) - if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { - println("\nNote: Using local binary of the Rust SDK.\n") + if (usesLocalRustSdk) { + println( + "\nNote: Using local binary of the Rust SDK." + + "\n element-call-matrix is substituted onto it as well, so the native call runs" + + "\n against this SDK rather than the one it was published against.\n" + ) debugImplementation(projects.libraries.rustsdk) } else { debugImplementation(libs.matrix.sdk) } implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") }) + // The native call's Matrix transport. Only this module may hand it a raw SDK client. + implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) + implementation(libs.element.call.api) + implementation(projects.features.callnative.api) + implementation(libs.element.call.matrix) + implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.libraries.androidutils) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt new file mode 100644 index 0000000000..14b59b14e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt @@ -0,0 +1,38 @@ +/* + * 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.libraries.matrix.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.matrix.ElementCallMatrixTransport +import io.element.android.call.matrix.ElementCallSdkTransport +import io.element.android.features.callnative.api.ElementCallTransportFactory +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.impl.RustMatrixClient + +/** + * Gives the native call component its view of Matrix. + * + * Here because this is the only module in the app compiled against the Rust SDK, and the component's + * turnkey transport needs a raw SDK client. No feature module ever sees the SDK, the rule that no + * feature depends on `libraries/matrix/impl` still holds, and the SDK version contract is checked where + * the app's own SDK pin lives. + * + * The transport is built on the client's own coroutine scope, which is what ties it to the session: the + * component has no shutdown of its own, so cancelling that scope at logout is the only thing that stops + * the RTC core and its to-device subscription. + */ +@ContributesBinding(AppScope::class) +class DefaultElementCallTransportFactory : ElementCallTransportFactory { + override fun create(client: MatrixClient): ElementCallMatrixTransport { + return ElementCallSdkTransport( + client = (client as RustMatrixClient).innerClient, + sessionScope = client.sessionCoroutineScope, + ) + } +} From 40cb43ae875678ee80712375260a6812d000dc4c Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:05 +0200 Subject: [PATCH 05/16] 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) --- features/callnative/impl/build.gradle.kts | 23 +++ .../callnative/impl/ElementCallControllers.kt | 164 ++++++++++++++++++ .../impl/ElementXCallLifecycleListener.kt | 62 +++++++ .../impl/ElementXRoomContextProvider.kt | 74 ++++++++ .../impl/ElementXCallLifecycleListenerTest.kt | 84 +++++++++ 5 files changed, 407 insertions(+) create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt create mode 100644 features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt 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) + } +} From 75605f19328532fd5c2ded37031f3118280d6fd5 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:06 +0200 Subject: [PATCH 06/16] Make a call with no UI actually run, and cover it with tests Found on a device: with no UI to answer the microphone permission or unset the maximized snapshot, the call parks forever and blanks the screen through the proximity wake lock. Co-Authored-By: Claude Opus 5 (1M context) --- .../callnative/api/NativeCallEntryPoint.kt | 2 +- .../impl/DefaultNativeCallEntryPoint.kt | 54 ++++++++-- .../impl/DefaultNativeCallEntryPointTest.kt | 101 ++++++++++++++++++ .../impl/FakeElementCallControllers.kt | 29 +++++ 4 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt create mode 100644 features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt 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 index a964c1502a..486a0b14c1 100644 --- 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 @@ -19,6 +19,6 @@ import io.element.android.features.call.api.CallData * 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 interface NativeCallEntryPoint { fun startCall(callData: CallData) } 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 index ecfdafd76f..4eb5b69418 100644 --- 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 @@ -7,23 +7,63 @@ package io.element.android.features.callnative.impl +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.rtc.id.RoomId import io.element.android.features.call.api.CallData 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import timber.log.Timber /** - * What answers [NativeCallEntryPoint] until the native MatrixRTC stack is wired in: nothing. + * Starts a call on the native MatrixRTC stack. * - * 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. + * App-scoped, while the stack it drives is per session: the session comes from the call rather than + * from whatever is on screen, so answering a notification for a second account reaches that account's + * stack. */ @ContributesBinding(AppScope::class) -class DefaultNativeCallEntryPoint : NativeCallEntryPoint { +class DefaultNativeCallEntryPoint( + @ApplicationContext private val context: Context, + private val controllers: ElementCallControllers, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : NativeCallEntryPoint { override fun startCall(callData: CallData) { - Timber.w("Native call requested for ${callData.roomId} but no native call stack is present in this build") + // The call does nothing past "requested" until the microphone permission is answered, and + // only an Activity can ask for one. Until the call has a UI of its own, the only answer + // available is the permission the app already holds - so check it before starting rather + // than after. Starting a call nobody can answer leaves it running and refusing every later + // attempt for the rest of the session, with nothing on screen to hang it up. + if (!hasMicrophonePermission()) { + Timber.w("NativeCall: RECORD_AUDIO is not granted and there is no call UI to ask for it, not starting the call") + return + } + appCoroutineScope.launch { + val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch + controller.startCall( + ElementCallData( + roomId = RoomId(callData.roomId.value), + isAudioCall = callData.isAudioCall, + ) + ) + controller.setMicrophonePermissionGranted(true) + // A call with no UI is never the screen the user is looking at, and the snapshot assumes + // it is. Saying so matters for more than tidiness: the component holds a proximity wake + // lock while a maximized audio call is on screen, so leaving the default in place blanks + // the display whenever a hand goes near the top of the phone - including when reaching + // for the notification shade, which is the only way to hang this call up. + controller.setMaximized(false) + } } + + private fun hasMicrophonePermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt new file mode 100644 index 0000000000..5afbda0e5b --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -0,0 +1,101 @@ +/* + * 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.Manifest +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.call.test.FakeElementCallController +import io.element.android.features.call.api.CallData +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNativeCallEntryPointTest : RobolectricTest() { + @Test + fun `starting a call answers the microphone permission the app already holds`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) + // Without this the call parks at RequestingPermission forever, and because a call is then + // "running" every later attempt is refused with nothing on screen to hang it up. + assertThat(controller.microphonePermissionAnswers).containsExactly(true) + } + + @Test + fun `a call with no UI is not maximized`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // The snapshot assumes a call is on screen. While there is no call UI it never is, and the + // component holds a proximity wake lock for a maximized audio call - which blanks the display + // whenever a hand goes near the top of the phone, including reaching for the shade. + assertThat(controller.maximizedCalls).containsExactly(false) + } + + @Test + fun `no call is started when the microphone permission is missing`() = runTest { + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO) + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // Nothing to ask with, so nothing is started: a call left unanswered would wedge the + // controller for the rest of the session. + assertThat(controller.startedCalls).isEmpty() + } + + @Test + fun `nothing happens for a session that cannot be restored`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller, sessionCanBeRestored = false) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).isEmpty() + } + + private fun grantMicrophonePermission() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO) + } + + private fun TestScope.createEntryPoint( + controller: FakeElementCallController, + sessionCanBeRestored: Boolean = true, + ) = DefaultNativeCallEntryPoint( + context = InstrumentationRegistry.getInstrumentation().targetContext as Application, + controllers = FakeElementCallControllers(controller.takeIf { sessionCanBeRestored }), + appCoroutineScope = this, + ) + + private companion object { + val A_CALL_DATA = CallData(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, isAudioCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt new file mode 100644 index 0000000000..64cf425af6 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.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 io.element.android.call.api.ElementCallController +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be + * restored - logged out, or an id from a stale notification. + */ +class FakeElementCallControllers( + private val controller: ElementCallController? = null, +) : ElementCallControllers { + val requestedSessions = mutableListOf() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? { + requestedSessions += sessionId + return controller + } + + override fun withRunningCall(): ElementCallController? = + controller?.takeIf { it.state.value != null } +} From e35b522a9a9c7b48236082cd272c7c6f7ce6fa2f Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:06 +0200 Subject: [PATCH 07/16] Answer a ringing call through the call entry point The notification's Answer action named ElementCallActivity outright, so answering always opened the WebView whatever FeatureFlags.NativeCall was set to. Co-Authored-By: Claude Opus 5 (1M context) --- .../RingingCallNotificationCreator.kt | 34 ++++++++++----- .../call/impl/ui/IncomingCallActivity.kt | 42 +++++++++++++++++++ .../RingingCallNotificationCreatorTest.kt | 31 ++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index 647f5ebcfc..78b08e4468 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat import androidx.core.app.Person import dev.zacsweers.metro.Inject import io.element.android.appconfig.ElementCallConfig -import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver import io.element.android.features.call.impl.ui.IncomingCallActivity -import io.element.android.features.call.impl.utils.IntentProvider import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.CommonDrawables @@ -55,6 +53,15 @@ class RingingCallNotificationCreator( * Request code for the full screen intent. */ const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 + + /** + * Request code for the answer action. + * + * Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target + * `IncomingCallActivity`: they carry different extras, and a shared request code would let + * `FLAG_CANCEL_CURRENT` on one replace the other. + */ + const val ANSWER_REQUEST_CODE = 3 } suspend fun createNotification( @@ -89,14 +96,6 @@ class RingingCallNotificationCreator( .setImportant(true) .build() - val answerIntent = IntentProvider.getPendingIntent( - context, - CallData( - sessionId = sessionId, - roomId = roomId, - isAudioCall = audioOnly, - ), - ) val notificationData = CallNotificationData( sessionId = sessionId, roomId = roomId, @@ -112,6 +111,21 @@ class RingingCallNotificationCreator( audioOnly = audioOnly, ) + // Through IncomingCallActivity rather than straight to ElementCallActivity, so that + // answering goes through ElementCallEntryPoint and lands in whichever call stack the + // NativeCall flag selects. Naming the WebView activity here meant the notification's Answer + // button always opened the WebView, even with native calls turned on. + val answerIntent = PendingIntentCompat.getActivity( + context, + ANSWER_REQUEST_CODE, + Intent(context, IncomingCallActivity::class.java).apply { + putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData) + putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false, + )!! + val declineIntent = PendingIntentCompat.getBroadcast( context, DECLINE_REQUEST_CODE, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 1d6989fb3c..a4bb9eef31 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -8,6 +8,7 @@ package io.element.android.features.call.impl.ui +import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() { * Extra key for the notification data. */ const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" + + /** + * Answer straight away rather than showing the incoming-call screen. + * + * Set by the notification's Answer action, which has already asked the user - showing them + * the same question again would be the bug. The screen is for the full-screen intent, where + * the user has not answered anything yet. + */ + const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY" } @Inject @@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() { ) val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } + if (answerImmediatelyIfAsked(intent)) return if (notificationData != null) { setContent { val colors by remember { @@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() { .launchIn(lifecycleScope) } + /** + * The Answer action can arrive while this activity is already showing the ringing screen, because + * the full screen intent put it there and it is `singleTask`. That delivers a new intent instead + * of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and + * [setIntent] has to run first, or everything reading `intent` afterwards would still see the one + * the activity was created with. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + answerImmediatelyIfAsked(intent) + } + + /** + * Answers and finishes when the intent is the notification's Answer action, and reports whether + * it did. + * + * Answering goes through this activity rather than straight to a call screen so that it goes + * through `ElementCallEntryPoint`, which is what decides between the WebView and the native + * stack. The notification action used to name `ElementCallActivity` outright, so answering from + * the notification always opened the WebView however the flag was set. + */ + private fun answerImmediatelyIfAsked(intent: Intent?): Boolean { + if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false + val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) + ?: return false + onAnswer(notificationData) + finish() + return true + } + private fun onAnswer(notificationData: CallNotificationData) { elementCallEntryPoint.startCall( CallData( diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt index 3f5c1c4e50..3c072c5958 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt @@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader import com.google.common.truth.Truth.assertThat import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -26,6 +27,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.test.runTest import org.junit.Test +import org.robolectric.Shadows.shadowOf class RingingCallNotificationCreatorTest : RobolectricTest() { @Test @@ -89,6 +91,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() { assertThat(acceptAction?.title?.toString()).isEqualTo("Answer") } + @Test + fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + // Not ElementCallActivity: naming the WebView activity here made the Answer button open the + // WebView call whatever FeatureFlags.NativeCall was set to. + val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent + assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue() + } + + @Test + fun `createNotification - tapping the notification body answers too`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + val contentIntent = shadowOf(notification?.contentIntent).savedIntent + assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + } + private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, From 0d5a79f3f23e7d974af51a3dc127bf7929371a6c Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 24 Sep 2026 14:57:09 +0200 Subject: [PATCH 08/16] Add the camera foreground service permission to the manifest snapshot --- tools/manifest/gplay/release/aaptDump.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 97e389bd7c..258e8a032e 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -18,6 +18,7 @@ uses-permission: name='android.permission.USE_FULL_SCREEN_INTENT' uses-permission: name='android.permission.ACCESS_NETWORK_STATE' uses-permission: name='android.permission.VIBRATE' uses-permission: name='android.permission.ACCESS_LOCAL_NETWORK' +uses-permission: name='android.permission.FOREGROUND_SERVICE_CAMERA' uses-permission: name='android.permission.USE_BIOMETRIC' uses-permission: name='android.permission.USE_FINGERPRINT' uses-permission: name='com.google.android.c2dm.permission.RECEIVE' From 2ca33296b51a82e2155858edc8b95eb7b4a2f30f Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 29 Sep 2026 15:07:08 +0200 Subject: [PATCH 09/16] Update element-call-android to 0.1.0-rc.6 Co-Authored-By: Claude Opus 5.5 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 905db875ae..ac20a11a83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" kover = "0.9.9" # The native MatrixRTC call component, https://github.com/element-hq/element-call-android -element_call = "0.1.0-rc.2" +element_call = "0.1.0-rc.6" [libraries] # Project From e786e2d22ff4d4db92d3d7a88f014a90954721d2 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:31:02 +0200 Subject: [PATCH 10/16] Draw the native call in the app A wrapper around the logged-in content rather than an overlay, because a minimized call takes a strip of height above it while a maximized one covers it. Co-Authored-By: Claude Opus 5 (1M context) --- features/callnative/api/build.gradle.kts | 3 +- .../features/callnative/api/NativeCallHost.kt | 26 +++++++ .../features/callnative/api/NativeCallPip.kt | 26 +++++++ features/callnative/impl/build.gradle.kts | 4 +- .../impl/DefaultNativeCallEntryPoint.kt | 25 +++---- .../callnative/impl/DefaultNativeCallHost.kt | 71 +++++++++++++++++++ .../callnative/impl/DefaultNativeCallPip.kt | 31 ++++++++ .../callnative/impl/ElementCallControllers.kt | 28 ++++++-- .../callnative/impl/ElementXCallStyle.kt | 57 +++++++++++++++ .../impl/DefaultNativeCallEntryPointTest.kt | 24 ++++--- .../impl/FakeElementCallControllers.kt | 4 ++ 11 files changed, 263 insertions(+), 36 deletions(-) create mode 100644 features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt create mode 100644 features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt create mode 100644 features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts index 81e5976627..f5f3c25f4b 100644 --- a/features/callnative/api/build.gradle.kts +++ b/features/callnative/api/build.gradle.kts @@ -6,7 +6,8 @@ */ plugins { - id("io.element.android-library") + // NativeCallHost renders the call, so this module carries a composable. + id("io.element.android-compose-library") } android { diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt new file mode 100644 index 0000000000..c90ffe1e29 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt @@ -0,0 +1,26 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Where a native call is drawn, wrapped around the logged-in content. + * + * A wrapper rather than an overlay, because the two states of a call need different things from the + * layout: minimized, the call takes a strip of height above the content; maximized, it covers it. The + * [Modifier] handed back to [content] has consumed whatever window insets the call has already taken, + * so the screens underneath do not pad for the status bar a second time. + * + * With no call running this renders [content] and nothing else. + */ +interface NativeCallHost { + @Composable + fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt new file mode 100644 index 0000000000..0cd8ea9110 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt @@ -0,0 +1,26 @@ +/* + * 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 android.app.Activity + +/** + * The one part of picture-in-picture that an Activity has to do itself. + * + * Everything else is installed where the call is drawn, which is inside the logged-in content and so + * knows which session's call it is. This does not: `onUserLeaveHint` is an Activity override, there is + * no session in scope at that point, and it applies to whichever call happens to be running. + * + * Only Android 11 and below reach this. From Android 12 the system is told up front that the Activity + * would like to shrink, which covers every way out of the app including the home gesture - the one + * people actually use, and the one this hook never sees. + */ +interface NativeCallPip { + /** Call from the host Activity's `onUserLeaveHint()` override. */ + fun onUserLeaveHint(activity: Activity) +} diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts index 8cfee12e0b..a135f46ad3 100644 --- a/features/callnative/impl/build.gradle.kts +++ b/features/callnative/impl/build.gradle.kts @@ -8,7 +8,7 @@ import extension.testCommonDependencies * Please see LICENSE files in the repository root for full details. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { @@ -29,8 +29,10 @@ dependencies { implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(libs.element.call.api) implementation(libs.element.call.impl) + implementation(libs.element.call.ui) implementation(projects.features.call.api) + implementation(projects.libraries.designsystem) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.featureflag.api) 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 index 4eb5b69418..2210663376 100644 --- 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 @@ -21,7 +21,6 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import timber.log.Timber /** * Starts a call on the native MatrixRTC stack. @@ -37,15 +36,6 @@ class DefaultNativeCallEntryPoint( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : NativeCallEntryPoint { override fun startCall(callData: CallData) { - // The call does nothing past "requested" until the microphone permission is answered, and - // only an Activity can ask for one. Until the call has a UI of its own, the only answer - // available is the permission the app already holds - so check it before starting rather - // than after. Starting a call nobody can answer leaves it running and refusing every later - // attempt for the rest of the session, with nothing on screen to hang it up. - if (!hasMicrophonePermission()) { - Timber.w("NativeCall: RECORD_AUDIO is not granted and there is no call UI to ask for it, not starting the call") - return - } appCoroutineScope.launch { val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch controller.startCall( @@ -54,13 +44,14 @@ class DefaultNativeCallEntryPoint( isAudioCall = callData.isAudioCall, ) ) - controller.setMicrophonePermissionGranted(true) - // A call with no UI is never the screen the user is looking at, and the snapshot assumes - // it is. Saying so matters for more than tidiness: the component holds a proximity wake - // lock while a maximized audio call is on screen, so leaving the default in place blanks - // the display whenever a hand goes near the top of the phone - including when reaching - // for the notification shade, which is the only way to hang this call up. - controller.setMaximized(false) + // The call screen asks for the microphone itself, and is where the answer normally comes + // from. This covers the case it cannot reach: a call answered from a notification while + // the app is in the background has no call screen composed yet, and a call waiting on a + // permission nobody is there to grant would sit unanswered. Reporting a permission we + // already hold is idempotent, so the screen asking again later costs nothing. + if (hasMicrophonePermission()) { + controller.setMicrophonePermissionGranted(true) + } } } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt new file mode 100644 index 0000000000..80c232e799 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -0,0 +1,71 @@ +/* + * 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 androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.call.ui.ElementCallOverlay +import io.element.android.features.callnative.api.NativeCallHost +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import timber.log.Timber + +/** + * Draws the session's native call around the logged-in content. + * + * Session-scoped, unlike the entry point: this renders inside the logged-in navigation, so the + * session is already in scope and there is no need to go looking for one. The stack it draws is not, + * so this asks the app-scoped registry for its own session's controller. + * + * That controller only exists once a call has been placed or answered. Until then this is a + * pass-through and nothing about the call is on screen. + */ +@ContributesBinding(SessionScope::class) +class DefaultNativeCallHost( + private val client: MatrixClient, + private val controllers: ElementCallControllers, +) : NativeCallHost { + @Composable + override fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) { + val controller by remember(client.sessionId) { controllers.controller(client.sessionId) } + .collectAsState(initial = null) + val current = controller + if (current == null) { + content(modifier) + return + } + + // Installed here rather than in the Activity because this is the only place that has both the + // Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it + // cannot delegate: see NativeCallPip. + val activity = LocalContext.current as? ComponentActivity + DisposableEffect(activity, current) { + if (activity != null) { + runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } + .onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") } + } + onDispose { } + } + + ElementCallOverlay( + controller = current, + modifier = modifier, + style = rememberElementXCallStyle(), + content = content, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt new file mode 100644 index 0000000000..b94785a931 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt @@ -0,0 +1,31 @@ +/* + * 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.app.Activity +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.features.callnative.api.NativeCallPip + +/** + * Forwards the Activity's leave hint to whichever session has a call. + * + * App-scoped because `onUserLeaveHint` is, and it asks for the running call rather than a particular + * session's: only one call runs at a time, and the Activity has no session in scope to name. Sessions + * whose stack was never built are skipped, so asking cannot start one. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallPip( + private val controllers: ElementCallControllers, +) : NativeCallPip { + override fun onUserLeaveHint(activity: Activity) { + val controller = controllers.withRunningCall() ?: return + ElementCallPictureInPicture.onUserLeaveHint(activity, controller) + } +} 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 index 0cb50d2038..5792da9a93 100644 --- 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 @@ -31,12 +31,16 @@ 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.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update 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. @@ -51,6 +55,14 @@ interface ElementCallControllers { */ suspend fun getOrBuild(sessionId: SessionId): ElementCallController? + /** + * The session's controller once its stack exists, and null until then. Never builds one. + * + * A flow rather than a getter because the stack appears part-way through the session's life, when + * a call is first placed or answered, and whatever draws the call has to notice when it does. + */ + fun controller(sessionId: SessionId): Flow + /** * The controller of whichever session currently has a call, or null when none does. * @@ -84,33 +96,37 @@ class DefaultElementCallControllers( private val appPreferencesStore: AppPreferencesStore, private val featureFlagService: FeatureFlagService, ) : ElementCallControllers { - private val stacks = ConcurrentHashMap() + // A flow rather than a plain map because what draws the call has to see the stack appear. + private val stacks = MutableStateFlow>(emptyMap()) // 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 } + stacks.value[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 + stacks.update { it + (sessionId to 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) + stacks.update { if (it[sessionId] === stack) it - sessionId else it } stack.close() } }.controller } + override fun controller(sessionId: SessionId): Flow = + stacks.map { it[sessionId]?.controller }.distinctUntilChanged() + override fun withRunningCall(): ElementCallController? = - stacks.values.firstNotNullOfOrNull { stack -> + stacks.value.values.firstNotNullOfOrNull { stack -> stack.controller.takeIf { it.state.value != null } } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt new file mode 100644 index 0000000000..751679b298 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt @@ -0,0 +1,57 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.element.android.call.ui.theme.ElementCallColors +import io.element.android.call.ui.theme.ElementCallIcons +import io.element.android.call.ui.theme.ElementCallStyle +import io.element.android.call.ui.theme.ElementCallTypography +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType + +/** + * How Element X dresses the native call. + * + * Deliberately almost nothing. The component's own defaults are already Compound - its colours are + * Compound's dark palette and its glyphs are Compound vectors - so re-deriving them from + * `ElementTheme` would buy no visual change and a lot of mapping to keep in step. The call also stays + * dark in a light-themed app on purpose: it is a video surface, as it is in Element Call on the web. + * + * What the component genuinely cannot do is turn an `mxc://` URL into a picture, because that needs a + * Matrix media loader. So the avatar is the one thing overridden here, and it is what makes faces + * appear on the tiles rather than initials. + */ +@Composable +fun rememberElementXCallStyle(): ElementCallStyle { + val icons = ElementCallIcons.default() + return remember(icons) { + ElementCallStyle( + colors = ElementCallColors.Dark, + typography = ElementCallTypography.Default, + icons = icons, + avatar = { data, size -> + Avatar( + avatarData = AvatarData( + id = data.id, + name = data.name, + url = data.url, + // Unused: forcedAvatarSize below is what decides the size, and the component + // asks in dp because it lays the tiles out itself. + size = AvatarSize.ActiveCallItem, + ), + avatarType = AvatarType.User, + forcedAvatarSize = size, + ) + }, + ) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt index 5afbda0e5b..cd209069e7 100644 --- a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -36,13 +36,14 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() { runCurrent() assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) - // Without this the call parks at RequestingPermission forever, and because a call is then - // "running" every later attempt is refused with nothing on screen to hang it up. + // The call screen normally answers this, but it is not composed when a call is answered from + // a notification with the app in the background - and a call waiting on a permission nobody + // is there to grant sits unanswered and refuses every later attempt. assertThat(controller.microphonePermissionAnswers).containsExactly(true) } @Test - fun `a call with no UI is not maximized`() = runTest { + fun `a call the user placed opens on the call screen, not in the minimized bar`() = runTest { grantMicrophonePermission() val controller = FakeElementCallController() val entryPoint = createEntryPoint(controller) @@ -50,14 +51,14 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() { entryPoint.startCall(A_CALL_DATA) runCurrent() - // The snapshot assumes a call is on screen. While there is no call UI it never is, and the - // component holds a proximity wake lock for a maximized audio call - which blanks the display - // whenever a hand goes near the top of the phone, including reaching for the shade. - assertThat(controller.maximizedCalls).containsExactly(false) + // A snapshot starts maximized, which is what the user asked for by pressing call. Saying + // otherwise here is what a UI-less build had to do, and it would now dock a freshly placed + // call into the bar. + assertThat(controller.maximizedCalls).isEmpty() } @Test - fun `no call is started when the microphone permission is missing`() = runTest { + fun `the call still starts without the microphone permission, so the call screen can ask`() = runTest { shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO) val controller = FakeElementCallController() val entryPoint = createEntryPoint(controller) @@ -65,9 +66,10 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() { entryPoint.startCall(A_CALL_DATA) runCurrent() - // Nothing to ask with, so nothing is started: a call left unanswered would wedge the - // controller for the rest of the session. - assertThat(controller.startedCalls).isEmpty() + assertThat(controller.startedCalls).hasSize(1) + // Nothing to report, and nothing to report it from: only an Activity can ask, and the call + // screen does exactly that once it appears. + assertThat(controller.microphonePermissionAnswers).isEmpty() } @Test diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt index 64cf425af6..fb87f494d5 100644 --- a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt @@ -9,6 +9,8 @@ package io.element.android.features.callnative.impl import io.element.android.call.api.ElementCallController import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf /** * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be @@ -24,6 +26,8 @@ class FakeElementCallControllers( return controller } + override fun controller(sessionId: SessionId): Flow = flowOf(controller) + override fun withRunningCall(): ElementCallController? = controller?.takeIf { it.state.value != null } } From f052f755b645a91ecd300044f451b6083e128309 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:31:03 +0200 Subject: [PATCH 11/16] Follow a native call into picture-in-picture from the app supportsPictureInPicture and smallestScreenSize have to be declared here, because a library cannot change a host Activity's manifest entry. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/AndroidManifest.xml | 3 ++- .../io/element/android/x/MainActivity.kt | 13 ++++++++++ .../io/element/android/x/di/AppBindings.kt | 8 +++++++ appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 24 ++++++++++++------- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5f08e0795..9193c6de71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,9 +53,10 @@ --> diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c5551f11aa..cc058d0f8f 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -70,6 +70,19 @@ class MainActivity : NodeActivity() { } } + /** + * The pre-Android 12 way into picture-in-picture for a native call. + * + * Everything else about it is installed where the call is drawn, which knows whose call it is. + * This cannot be: an Activity override has no session in scope. From Android 12 the system is told + * up front that this Activity would like to shrink, and the call handles it without ever coming + * through here. + */ + override fun onUserLeaveHint() { + super.onUserLeaveHint() + appBindings.nativeCallPip().onUserLeaveHint(this) + } + @Composable private fun MainContent(appBindings: AppBindings) { val migrationState = appBindings.migrationEntryPoint().present() diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 638ba0e38c..abdb6b1394 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Multibinds import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.callnative.api.NativeCallPip import io.element.android.features.enterprise.api.AppStartupHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.lockscreen.api.LockScreenEntryPoint @@ -52,6 +53,13 @@ interface AppBindings { fun buildMeta(): BuildMeta + /** + * For picture-in-picture. Only an Activity is told it is being left, and only the call knows + * whether that is worth shrinking for, so `MainActivity` needs this much of the native call and + * no more. + */ + fun nativeCallPip(): NativeCallPip + fun sentrySdkDsn(): SentrySdkDsn? @Multibinds(allowEmpty = true) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 12b95d9a09..f65692feb4 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(libs.coil) implementation(projects.features.announcement.api) + implementation(projects.features.callnative.api) implementation(projects.features.ftue.api) implementation(projects.features.linknewdevice.api) implementation(projects.features.share.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a0bcf3b489..2f0988281e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.callnative.api.NativeCallHost import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService @@ -144,6 +145,7 @@ class LoggedInFlowNode( private val shareEntryPoint: ShareEntryPoint, private val matrixClient: MatrixClient, private val sendingQueue: SendQueues, + private val nativeCallHost: NativeCallHost, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val sessionEnterpriseService: SessionEnterpriseService, @@ -701,15 +703,19 @@ class LoggedInFlowNode( ) { CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { val isOnline by syncService.isOnline.collectAsState() - ConnectivityIndicatorContainer( - isOnline = isOnline, - modifier = modifier, - ) { contentModifier -> - Box(modifier = contentModifier) { - val ftueState by ftueService.state.collectAsState() - BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) - if (ftueState is FtueState.Complete) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + // Outside the connectivity indicator so the two strips stack rather than fight: an + // offline banner during a call belongs under the call bar, not over it. + nativeCallHost.Render(modifier = modifier) { callContentModifier -> + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = callContentModifier, + ) { contentModifier -> + Box(modifier = contentModifier) { + val ftueState by ftueService.state.collectAsState() + BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) + if (ftueState is FtueState.Complete) { + PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + } } } } From a2be854cf9c56a95918fdad564e8da27376e34c7 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 23 Sep 2026 18:06:47 +0200 Subject: [PATCH 12/16] fix: linter LocalContext should not be cast to Activity --- .../android/features/callnative/impl/DefaultNativeCallHost.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt index 80c232e799..bc37649f58 100644 --- a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -8,13 +8,13 @@ package io.element.android.features.callnative.impl import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import dev.zacsweers.metro.ContributesBinding import io.element.android.call.impl.ElementCallPictureInPicture import io.element.android.call.ui.ElementCallOverlay @@ -52,7 +52,7 @@ class DefaultNativeCallHost( // Installed here rather than in the Activity because this is the only place that has both the // Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it // cannot delegate: see NativeCallPip. - val activity = LocalContext.current as? ComponentActivity + val activity = LocalActivity.current as? ComponentActivity DisposableEffect(activity, current) { if (activity != null) { runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } From 01bc7dd070b3e119473afecedf65b182b845f89e Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 29 Sep 2026 15:08:55 +0200 Subject: [PATCH 13/16] Keep the app content composed when a native call starts The overlay now draws with no controller too, so starting a call no longer resets the content's state, such as the composer draft. Co-Authored-By: Claude Opus 5.5 --- .../features/callnative/impl/DefaultNativeCallHost.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt index bc37649f58..332f28f7d8 100644 --- a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -31,8 +31,8 @@ import timber.log.Timber * session is already in scope and there is no need to go looking for one. The stack it draws is not, * so this asks the app-scoped registry for its own session's controller. * - * That controller only exists once a call has been placed or answered. Until then this is a - * pass-through and nothing about the call is on screen. + * That controller only exists once a call has been placed or answered. Until then the overlay draws + * the content alone. */ @ContributesBinding(SessionScope::class) class DefaultNativeCallHost( @@ -44,23 +44,20 @@ class DefaultNativeCallHost( val controller by remember(client.sessionId) { controllers.controller(client.sessionId) } .collectAsState(initial = null) val current = controller - if (current == null) { - content(modifier) - return - } // Installed here rather than in the Activity because this is the only place that has both the // Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it // cannot delegate: see NativeCallPip. val activity = LocalActivity.current as? ComponentActivity DisposableEffect(activity, current) { - if (activity != null) { + if (activity != null && current != null) { runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } .onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") } } onDispose { } } + // Composed even with no controller yet, so the content never moves in the tree and keeps its state. ElementCallOverlay( controller = current, modifier = modifier, From 3985d4b712dc78f1576c2f85266eb8f285104ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 29 Sep 2026 19:06:33 +0200 Subject: [PATCH 14/16] Add missing parts of the `26.09.4` release to `develop`: - Update CHANGES.md. - Update aaptDump.txt. --- CHANGES.md | 9 +++++++++ tools/manifest/gplay/release/aaptDump.txt | 20 ++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index cbf9bc0652..74621c3eea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,12 @@ +Changes in Element X v26.09.4 +============================= + +## Hotifx version: + +Fixes an issue with the room list not displaying all activity from unread messages. + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.3...v26.09.4 + Changes in Element X v26.09.3 ============================= diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 258e8a032e..9c2dca205a 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,5 +1,5 @@ -package: name='io.element.android.x' versionCode='202609030' versionName='26.09.3' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' -sdkVersion:'24' +package: name='io.element.android.x' versionCode='202609040' versionName='26.09.4' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +sdkVersion:'33' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' uses-permission: name='android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS' @@ -66,14 +66,14 @@ application-label-vi:'Element X' application-label-zh:'Element X' application-label-zh-CN:'Element X' application-label-zh-TW:'Element X' -application-icon-120:'res/BW.xml' -application-icon-160:'res/BW.xml' -application-icon-240:'res/BW.xml' -application-icon-320:'res/BW.xml' -application-icon-480:'res/BW.xml' -application-icon-640:'res/BW.xml' -application-icon-65534:'res/BW.xml' -application: label='Element X' icon='res/BW.xml' +application-icon-120:'res/E4.xml' +application-icon-160:'res/E4.xml' +application-icon-240:'res/E4.xml' +application-icon-320:'res/E4.xml' +application-icon-480:'res/E4.xml' +application-icon-640:'res/E4.xml' +application-icon-65534:'res/E4.xml' +application: label='Element X' icon='res/E4.xml' launchable-activity: name='io.element.android.x.MainActivity' label='' icon='' uses-library-not-required:'androidx.window.extensions' uses-library-not-required:'androidx.window.sidecar' From 41f7cb7b742fc27c59ba845bedd5bafeb569bb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 29 Sep 2026 22:02:49 +0200 Subject: [PATCH 15/16] Fix aaptDump.txt and changelog The previous commit changed the aaptDump.txt file too much --- CHANGES.md | 2 +- tools/manifest/gplay/release/aaptDump.txt | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 74621c3eea..e303af54d7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,7 @@ Changes in Element X v26.09.4 ============================= -## Hotifx version: +## Hotfix version: Fixes an issue with the room list not displaying all activity from unread messages. diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 9c2dca205a..ae39f3c459 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,5 +1,5 @@ package: name='io.element.android.x' versionCode='202609040' versionName='26.09.4' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' -sdkVersion:'33' +sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' uses-permission: name='android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS' @@ -66,14 +66,14 @@ application-label-vi:'Element X' application-label-zh:'Element X' application-label-zh-CN:'Element X' application-label-zh-TW:'Element X' -application-icon-120:'res/E4.xml' -application-icon-160:'res/E4.xml' -application-icon-240:'res/E4.xml' -application-icon-320:'res/E4.xml' -application-icon-480:'res/E4.xml' -application-icon-640:'res/E4.xml' -application-icon-65534:'res/E4.xml' -application: label='Element X' icon='res/E4.xml' +application-icon-120:'res/BW.xml' +application-icon-160:'res/BW.xml' +application-icon-240:'res/BW.xml' +application-icon-320:'res/BW.xml' +application-icon-480:'res/BW.xml' +application-icon-640:'res/BW.xml' +application-icon-65534:'res/BW.xml' +application: label='Element X' icon='res/BW.xml' launchable-activity: name='io.element.android.x.MainActivity' label='' icon='' uses-library-not-required:'androidx.window.extensions' uses-library-not-required:'androidx.window.sidecar' From 767377193a4d305aac03c6394bae12984f28106e Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 29 Sep 2026 20:31:12 +0000 Subject: [PATCH 16/16] Update screenshots --- .../snapshots/images/features.home.impl_HomeViewA11y_en.png | 4 ++-- ...eme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png | 4 ++-- ...e.components_HorizontalFloatingToolbarNoFab_Night_0_en.png | 4 ++-- ...em.theme.components_HorizontalFloatingToolbar_Day_0_en.png | 4 ++-- ....theme.components_HorizontalFloatingToolbar_Night_0_en.png | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index 560b337d7d..a1e2d12cd9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a77b3fd89bdf26f15b44d070ee60af56a885e5c8d087780630e1dc5303de077c -size 143941 +oid sha256:8ca00f66f5c785e8b453c248f70b37c97e84d7524ff35f7c5a1f88c4091c10a4 +size 143901 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png index 80c560d774..3670bb54b1 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8081231a32a58a3434838b3ced72519748fee33f42ecbfa17088bbc45fa3707a -size 12646 +oid sha256:b6a45078c6cc5843a1d8d2a1c17e06c3f8330e4a7503c2feeab345948cd26e85 +size 13146 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png index 3eb6f63292..4a8b5782d9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b08efec9f2c25d1f5a83bc5aea8b64704918696dd5090c064a5b286dfdf10d6c -size 10622 +oid sha256:37961ef0263be6a03255873655ce01deab68cf50886f9147078237d47181d19a +size 10829 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png index d1be5852a2..a7cdbb8de8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5dca764f7130788f5e0ab4fec70c82556fd69ac2367951846aaf398a959f4029 -size 38662 +oid sha256:77a76061175e94de2344cbc33196806aaf4614434925adf302f76080934f11f7 +size 15671 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png index bf8ab560bd..0819189b62 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d9a428c2554b3868dfe47b1b319054026131f73ef18db121c99fb5ee8ed43a6 -size 28905 +oid sha256:e0643b4ac98b00734151004ea5d823f7163d405ce1ad15fa3889631d61d4a52d +size 12295