Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-09-29 20:57:37 +02:00
205 changed files with 1795 additions and 541 deletions
+1 -1
View File
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
+9
View File
@@ -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 Changes in Element X v26.09.3
============================= =============================
@@ -1,2 +1,9 @@
# Don't obfuscate anything for non-enterprise builds # Don't obfuscate anything for non-enterprise builds
-dontobfuscate -dontobfuscate
# Strip PostHog's Firebase push token integration. We don't use it and it breaks F-Droid.
# Removing the contents of this method will mark the Firebase-related classes as unused,
# which will allow R8 to remove them from the final build.
-assumevalues class com.posthog.android.PostHogAndroidConfig {
boolean getCapturePushNotificationSubscriptions() return false;
}
+2 -1
View File
@@ -53,9 +53,10 @@
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.ElementX.Splash" android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
@@ -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 @Composable
private fun MainContent(appBindings: AppBindings) { private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present() val migrationState = appBindings.migrationEntryPoint().present()
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds import dev.zacsweers.metro.Multibinds
import io.element.android.features.api.MigrationEntryPoint 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.AppStartupHook
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
@@ -52,6 +53,13 @@ interface AppBindings {
fun buildMeta(): BuildMeta 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? fun sentrySdkDsn(): SentrySdkDsn?
@Multibinds(allowEmpty = true) @Multibinds(allowEmpty = true)
+1
View File
@@ -50,6 +50,7 @@ dependencies {
implementation(libs.coil) implementation(libs.coil)
implementation(projects.features.announcement.api) implementation(projects.features.announcement.api)
implementation(projects.features.callnative.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.linknewdevice.api) implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api) implementation(projects.features.share.api)
@@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark 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.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService
@@ -144,6 +145,7 @@ class LoggedInFlowNode(
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
@@ -701,9 +703,12 @@ class LoggedInFlowNode(
) { ) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
val isOnline by syncService.isOnline.collectAsState() val isOnline by syncService.isOnline.collectAsState()
// 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( ConnectivityIndicatorContainer(
isOnline = isOnline, isOnline = isOnline,
modifier = modifier, modifier = callContentModifier,
) { contentModifier -> ) { contentModifier ->
Box(modifier = contentModifier) { Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState() val ftueState by ftueService.state.collectAsState()
@@ -716,6 +721,7 @@ class LoggedInFlowNode(
} }
} }
} }
}
} }
@Parcelize @Parcelize
@@ -0,0 +1,2 @@
Main changes in this version: fix an issue with the room list not displaying all activity from unread messages.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -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()
}
@@ -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)
}
+1
View File
@@ -86,6 +86,7 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.element.call.embedded) implementation(libs.element.call.embedded)
implementation(projects.features.callnative.api)
api(projects.features.call.api) api(projects.features.call.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
@@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider 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.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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint( class DefaultElementCallEntryPoint(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val nativeCallEntryPoint: NativeCallEntryPoint,
private val featureFlagService: FeatureFlagService,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : ElementCallEntryPoint { ) : ElementCallEntryPoint {
companion object { companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
@@ -31,8 +41,19 @@ class DefaultElementCallEntryPoint(
} }
override fun startCall(callData: CallData) { override fun startCall(callData: 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)) context.startActivity(IntentProvider.createIntent(context, callData))
} }
}
}
override suspend fun handleIncomingCall( override suspend fun handleIncomingCall(
callData: CallData, callData: CallData,
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person import androidx.core.app.Person
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig 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.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
* Request code for the full screen intent. * Request code for the full screen intent.
*/ */
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 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( suspend fun createNotification(
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
.setImportant(true) .setImportant(true)
.build() .build()
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData( val notificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
audioOnly = audioOnly, 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( val declineIntent = PendingIntentCompat.getBroadcast(
context, context,
DECLINE_REQUEST_CODE, DECLINE_REQUEST_CODE,
@@ -8,6 +8,7 @@
package io.element.android.features.call.impl.ui package io.element.android.features.call.impl.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
* Extra key for the notification data. * Extra key for the notification data.
*/ */
const val EXTRA_NOTIFICATION_DATA = "EXTRA_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 @Inject
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
) )
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (answerImmediatelyIfAsked(intent)) return
if (notificationData != null) { if (notificationData != null) {
setContent { setContent {
val colors by remember { val colors by remember {
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
.launchIn(lifecycleScope) .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) { private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallData( CallData(
@@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall 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.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -90,7 +92,8 @@ interface ActiveCallManager {
} }
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class, binding = binding<ActiveCallManager>())
@ContributesBinding(AppScope::class, binding = binding<RingingCallTracker>())
class DefaultActiveCallManager( class DefaultActiveCallManager(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@AppCoroutineScope @AppCoroutineScope
@@ -103,7 +106,16 @@ class DefaultActiveCallManager(
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock, 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 val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn 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.CurrentCall
import io.element.android.features.call.api.CurrentCallService import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.api.CurrentCallTracker
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class, binding = binding<CurrentCallService>())
class DefaultCurrentCallService : CurrentCallService { @ContributesBinding(AppScope::class, binding = binding<CurrentCallTracker>())
class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None) override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) { override fun onCallStarted(call: CurrentCall) {
currentCall.value = call currentCall.value = call
} }
fun onCallEnded() { override fun onCallEnded() {
currentCall.value = CurrentCall.None currentCall.value = CurrentCall.None
} }
} }
@@ -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.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
@@ -32,10 +38,13 @@ import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class DefaultElementCallEntryPointTest : RobolectricTest() { class DefaultElementCallEntryPointTest : RobolectricTest() {
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) 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 expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@@ -43,6 +52,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startCall - routes to the native call stack when the feature flag is on`() = runTest {
val startNativeCallLambda = lambdaRecorder<CallData, Unit> {}
val entryPoint = createEntryPoint(
nativeCallEntryPoint = FakeNativeCallEntryPoint(startNativeCallLambda),
isNativeCallEnabled = true,
)
val callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = true)
entryPoint.startCall(callData)
advanceUntilIdle()
startNativeCallLambda.assertions().isCalledOnce().with(value(callData))
// The WebView Activity must not also be launched.
assertThat(shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
@@ -70,8 +97,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
private fun TestScope.createEntryPoint( private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(),
isNativeCallEnabled: Boolean = false,
) = DefaultElementCallEntryPoint( ) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager, activeCallManager = activeCallManager,
nativeCallEntryPoint = nativeCallEntryPoint,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled),
),
appCoroutineScope = this,
) )
} }
@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator 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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_ROOM_ID
@@ -26,6 +27,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.robolectric.Shadows.shadowOf
class RingingCallNotificationCreatorTest : RobolectricTest() { class RingingCallNotificationCreatorTest : RobolectricTest() {
@Test @Test
@@ -89,6 +91,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer") 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( private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -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)
}
@@ -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<CurrentCall>()
override fun onCallStarted(call: CurrentCall) {
calls += call
}
override fun onCallEnded() {
calls += CurrentCall.None
}
}
@@ -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<CallData>()
val ended = mutableListOf<CallData>()
override suspend fun onCallJoined(callData: CallData) {
joined += callData
}
override suspend fun onCallEnded(callData: CallData) {
ended += callData
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
* 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 {
// NativeCallHost renders the call, so this module carries a composable.
id("io.element.android-compose-library")
}
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: 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)
}
@@ -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
}
@@ -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.
*/
fun interface NativeCallEntryPoint {
fun startCall(callData: CallData)
}
@@ -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)
}
@@ -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)
}
+54
View File
@@ -0,0 +1,54 @@
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-compose-library")
}
android {
namespace = "io.element.android.features.callnative.impl"
}
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(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)
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)
}
@@ -0,0 +1,60 @@
/*
* 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.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
/**
* Starts a call on the native MatrixRTC stack.
*
* 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(
@ApplicationContext private val context: Context,
private val controllers: ElementCallControllers,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : NativeCallEntryPoint {
override fun startCall(callData: CallData) {
appCoroutineScope.launch {
val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch
controller.startCall(
ElementCallData(
roomId = RoomId(callData.roomId.value),
isAudioCall = callData.isAudioCall,
)
)
// 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)
}
}
}
private fun hasMicrophonePermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
}
@@ -0,0 +1,68 @@
/*
* 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.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 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 the overlay draws
* the content alone.
*/
@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
// 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 && 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,
style = rememberElementXCallStyle(),
content = content,
)
}
}
@@ -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)
}
}
@@ -0,0 +1,180 @@
/*
* 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.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
/**
* 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 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<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 {
// A flow rather than a plain map because what draws the call has to see the stack appear.
private val stacks = MutableStateFlow<Map<SessionId, ElementCallStack>>(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.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.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.update { if (it[sessionId] === stack) it - sessionId else it }
stack.close()
}
}.controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> =
stacks.map { it[sessionId]?.controller }.distinctUntilChanged()
override fun withRunningCall(): ElementCallController? =
stacks.value.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
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallData
import io.element.android.call.api.ElementCallLifecycleListener
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallTracker
import io.element.android.features.call.api.RingingCallTracker
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
/**
* Reports a native call back to the parts of Element X that already track calls.
*
* The library knows it is in a call; Element X's room list, timeline and ringing machinery each
* already know how to react to one. This joins the two, so that a native call looks like any other
* call to the rest of the app rather than needing every reader to learn about it.
*/
class ElementXCallLifecycleListener(
private val sessionId: SessionId,
private val currentCallTracker: CurrentCallTracker,
private val ringingCallTracker: RingingCallTracker,
private val sessionCoroutineScope: CoroutineScope,
appForegroundStateService: AppForegroundStateService,
) : ElementCallLifecycleListener {
override val isAppInForeground: Flow<Boolean> = appForegroundStateService.isInForeground
override fun onCallStarted(callData: ElementCallData) {
currentCallTracker.onCallStarted(CurrentCall.RoomCall(callData.roomId()))
}
override fun onCallJoined(callData: ElementCallData) {
// The ringing side is suspending and these callbacks are not, so the work is handed to the
// session scope rather than blocking the controller's. It outlives the call on purpose: a
// ring still has to be taken down even if the call ends while this is in flight.
sessionCoroutineScope.launch { ringingCallTracker.onCallJoined(callData.toCallData()) }
}
override fun onCallEnded(callData: ElementCallData?) {
currentCallTracker.onCallEnded()
callData?.let { sessionCoroutineScope.launch { ringingCallTracker.onCallEnded(it.toCallData()) } }
}
private fun ElementCallData.roomId() = RoomId(roomId.value)
private fun ElementCallData.toCallData() = CallData(
sessionId = sessionId,
roomId = roomId(),
isAudioCall = isAudioCall,
)
}
@@ -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,
)
},
)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallRoomContext
import io.element.android.call.api.ElementCallRoomContextProvider
import io.element.android.call.api.ElementCallRoomMember
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import timber.log.Timber
import io.element.android.call.api.rtc.id.RoomId as ElementCallRoomId
import io.element.android.call.api.rtc.id.UserId as ElementCallUserId
import io.element.android.libraries.matrix.api.core.RoomId as MatrixRoomId
/**
* Tells the call what the room is called and who is in it.
*
* The RTC layer knows a participant only as a member id and a user id - it has no reason to know
* what anyone is called or what they look like. Element X does, so the join happens here rather than
* in the call UI, and it goes through Element X's room cache so that names and avatars are the same
* ones the rest of the app shows. The library ships a provider of its own that reads the SDK
* directly; it is not used, because it would miss exactly that cache.
*/
class ElementXRoomContextProvider(
private val matrixClient: MatrixClient,
) : ElementCallRoomContextProvider {
override fun roomContext(roomId: ElementCallRoomId): Flow<ElementCallRoomContext> = flow {
val room = matrixClient.getRoom(MatrixRoomId(roomId.value))
if (room == null) {
// Emits nothing rather than guessing: the call then shows user ids, which is better than
// a name we made up.
Timber.w("NativeCall: no room for ${roomId.value}, the call will show user ids")
return@flow
}
room.use {
coroutineScope {
// The member list is loaded lazily, and a call is exactly the moment it is needed:
// without this the tiles would show user ids until something else happened to ask.
// Launched rather than awaited so the room name still arrives immediately.
launch { room.updateMembers() }
emitAll(
combine(room.roomInfoFlow, room.membersStateFlow) { info, membersState ->
ElementCallRoomContext(
displayName = info.name,
isDm = info.isDm,
members = membersState.toElementCallMembers(),
)
}
)
}
}
}
private fun RoomMembersState.toElementCallMembers(): Map<ElementCallUserId, ElementCallRoomMember> =
roomMembers().orEmpty().associate { member ->
ElementCallUserId(member.userId.value) to ElementCallRoomMember(
userId = ElementCallUserId(member.userId.value),
displayName = member.displayName,
avatarUrl = member.avatarUrl,
)
}
}
@@ -0,0 +1,103 @@
/*
* 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)
// 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 the user placed opens on the call screen, not in the minimized bar`() = runTest {
grantMicrophonePermission()
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
// 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 `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)
entryPoint.startCall(A_CALL_DATA)
runCurrent()
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
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)
}
}
@@ -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)
}
}
@@ -0,0 +1,33 @@
/*
* 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
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
* restored - logged out, or an id from a stale notification.
*/
class FakeElementCallControllers(
private val controller: ElementCallController? = null,
) : ElementCallControllers {
val requestedSessions = mutableListOf<SessionId>()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? {
requestedSessions += sessionId
return controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> = flowOf(controller)
override fun withRunningCall(): ElementCallController? =
controller?.takeIf { it.state.value != null }
}
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread // Call and unread
Row( Row(
modifier = Modifier modifier = Modifier
.heightIn(min = 16.dp) .height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -358,18 +358,21 @@ private fun MessagePreviewAndIndicatorRow(
isAudio = room.activeCallIntent == CallIntent.AUDIO isAudio = room.activeCallIntent == CallIntent.AUDIO
) )
} }
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
if (isMuted) {
NotificationOffIndicatorAtom() NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) { } else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom() MentionIndicatorAtom()
} }
if (room.hasNewContent) { if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = when { val count = if (showUnreadCount) {
showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
!showUnreadCount -> 0 room.numberOfUnreadMessages
else -> null } else {
room.numberOfUnreadNotifications
}
} else {
null
} }
UnreadIndicatorAtom( UnreadIndicatorAtom(
color = tint, color = tint,
@@ -454,6 +457,5 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
isInviteSeen = data.name == "Bob", isInviteSeen = data.name == "Bob",
onClick = {}, onClick = {},
eventSink = {}, eventSink = {},
showUnreadCount = data.numberOfUnreadNotifications > 0,
) )
} }
@@ -82,24 +82,6 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
hasRoomCall = hasCall, hasRoomCall = hasCall,
), ),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 100,
hasRoomCall = hasCall,
),
) )
}.flatten() }.flatten()
}.flatten(), }.flatten(),
+15 -2
View File
@@ -64,6 +64,8 @@ detekt_compose_rules = "0.4.28"
ktlint = "1.8.0" ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.9" kover = "0.9.9"
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android
element_call = "0.1.0-rc.6"
[libraries] [libraries]
# Project # 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 # 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 # 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. # 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" rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0"
# Others # Others
@@ -223,7 +229,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.60.7" posthog = "com.posthog:posthog-android:3.60.7"
sentry = "io.sentry:sentry-android:8.55.0" sentry = "io.sentry:sentry-android:8.57.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0"
@@ -236,6 +242,13 @@ metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro"
# Element Call # Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.26.0" 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 # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
@@ -8,43 +8,39 @@
package io.element.android.libraries.designsystem.atomic.atoms package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99 private const val MAX_COUNT = 99
private const val MAX_COUNT_STRING = "$MAX_COUNT+" private const val MAX_COUNT_STRING = "+$MAX_COUNT"
/** /**
* A counter atom that displays a number in a circle. * A counter atom that displays a number in a circle.
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev * Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
* *
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING]. * @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, just a dot will be displayed instead. * If the number is less than 1, the counter will not be displayed.
* @param modifier The modifier to apply to this layout. * @param modifier The modifier to apply to this layout.
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default. * @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
* @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary]. * @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
* @param contentPadding The padding to apply to the text inside the counter.
* @param textStyle The style to apply to the text inside the counter. * @param textStyle The style to apply to the text inside the counter.
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme. * @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
* Only used when [containerColor] is null. * Only used when [containerColor] is null.
*/ */
@@ -54,51 +50,41 @@ fun CounterAtom(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
containerColor: Color? = null, containerColor: Color? = null,
contentColor: Color? = null, contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
textStyle: TextStyle = CounterAtomDefaults.textStyle, textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false, isCritical: Boolean = false,
) { ) {
if (count < 1) return
val countAsText = when (count) { val countAsText = when (count) {
in Int.MIN_VALUE..0 -> null in 0..MAX_COUNT -> count.toString()
in 1..MAX_COUNT -> count.toString()
else -> MAX_COUNT_STRING else -> MAX_COUNT_STRING
} }
val minSingleDigitSize = if (countAsText?.length == 1) {
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size // Measure the maximum count string size
val textLayoutResult = textMeasurer.measure( val textLayoutResult = textMeasurer.measure(
text = countAsText, text = MAX_COUNT_STRING,
style = textStyle style = textStyle
) )
val textSize = textLayoutResult.size val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height) val squareSize = maxOf(textSize.width, textSize.height)
Box(
modifier = modifier
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else { } else {
0 ElementTheme.colors.iconAccentPrimary
} }
)
androidx.compose.material3.Badge( ) {
modifier = modifier.then(
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
),
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
content = countAsText?.let {
@Composable {
Text( Text(
modifier = Modifier.sizeIn( modifier = Modifier.align(Alignment.Center),
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
text = countAsText, text = countAsText,
style = textStyle, style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
) )
} }
}
)
} }
object CounterAtomDefaults { object CounterAtomDefaults {
@@ -109,7 +95,7 @@ object CounterAtomDefaults {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun CounterAtomPreview() = ElementPreview { internal fun CounterAtomPreview() = ElementPreview {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { Column(verticalArrangement = spacedBy(2.dp)) {
CounterAtom(count = 0) CounterAtom(count = 0)
CounterAtom(count = 4) CounterAtom(count = 4)
CounterAtom(count = 99) CounterAtom(count = 99)
@@ -9,16 +9,15 @@
package io.element.android.libraries.designsystem.atomic.atoms package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -38,36 +37,35 @@ fun UnreadIndicatorAtom(
isVisible: Boolean = true, isVisible: Boolean = true,
contentDescription: String? = null, contentDescription: String? = null,
border: BorderStroke? = null, border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) { ) {
when { when {
!isVisible -> Spacer(modifier = modifier.size(size)) !isVisible -> Spacer(modifier = modifier.size(size))
else -> count?.let { count != null && count >= 1 -> CounterAtom(
CounterAtom(
count = count.toInt(), count = count.toInt(),
modifier = modifier modifier = modifier
.semantics { .semantics {
contentDescription?.let { this.contentDescription = it } contentDescription?.let { this.contentDescription = it }
} }
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier), .then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
containerColor = color, containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault, contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium, textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
) )
else -> Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
} }
.size(size)
.clip(CircleShape)
.background(color)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
)
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun UnreadIndicatorAtomPreview() = ElementPreview { internal fun UnreadIndicatorAtomPreview() = ElementPreview {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { UnreadIndicatorAtom()
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
} }
@@ -8,18 +8,17 @@
package io.element.android.libraries.designsystem.theme.components package io.element.android.libraries.designsystem.theme.components
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingToolbarColors import androidx.compose.material3.FloatingToolbarColors
import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
@@ -29,6 +28,7 @@ import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
@@ -139,20 +139,8 @@ fun HorizontalFloatingToolbarItem(
contentColor = ElementTheme.colors.iconSecondary, contentColor = ElementTheme.colors.iconSecondary,
) )
} }
BadgedBox( Box {
badge = { FilledIconButton(
counter?.let {
CounterAtom(
count = it,
// Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
contentPadding = PaddingValues(vertical = 0.5.dp),
textStyle = ElementTheme.typography.fontBodySmMedium,
)
}
}
) {
IconButton(
modifier = Modifier.widthIn(min = 56.dp), modifier = Modifier.widthIn(min = 56.dp),
colors = colors, colors = colors,
onClick = onClick, onClick = onClick,
@@ -163,6 +151,15 @@ fun HorizontalFloatingToolbarItem(
contentDescription = tooltipLabel, contentDescription = tooltipLabel,
) )
} }
if (counter != null) {
CounterAtom(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 6.dp, end = 3.dp),
count = counter,
textStyle = ElementTheme.typography.fontBodyXsMedium,
)
}
} }
} }
} }
@@ -175,9 +172,7 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun HorizontalFloatingToolbarPreview() = ElementPreview { internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
Column {
ContentToPreview( ContentToPreview(
count = 1,
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = {}, onClick = {},
@@ -189,33 +184,6 @@ internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
} }
} }
) )
ContentToPreview(
count = 99,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
ContentToPreview(
count = 999,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
}
} }
@PreviewsDayNight @PreviewsDayNight
@@ -229,11 +197,10 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ContentToPreview( private fun ContentToPreview(
count: Int? = 6,
floatingActionButton: (@Composable () -> Unit)?, floatingActionButton: (@Composable () -> Unit)?,
) { ) {
HorizontalFloatingToolbar( HorizontalFloatingToolbar(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp), modifier = Modifier.padding(28.dp),
floatingActionButton = floatingActionButton, floatingActionButton = floatingActionButton,
) { ) {
listOf( listOf(
@@ -247,7 +214,7 @@ private fun ContentToPreview(
icon = icon, icon = icon,
tooltipLabel = "Label", tooltipLabel = "Label",
isSelected = index == 0, isSelected = index == 0,
counter = if (index == 0) count else null, counter = if (index == 0) 6 else null,
forceRenderingTooltip = true, forceRenderingTooltip = true,
onClick = { }, onClick = { },
) )
@@ -170,4 +170,11 @@ enum class FeatureFlags(
isFinished = false, isFinished = false,
isInLabs = 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,
),
} }
+33 -2
View File
@@ -20,16 +20,47 @@ android {
setupDependencyInjection() setupDependencyInjection()
// Temporary: `-PelementCallLocalVersion` points this at a locally published build of
// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal
// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it.
val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion")
.getOrElse(libs.versions.element.call.get())
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 { dependencies {
releaseImplementation(libs.matrix.sdk) releaseImplementation(libs.matrix.sdk)
if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { if (usesLocalRustSdk) {
println("\nNote: Using local binary of the Rust SDK.\n") 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) debugImplementation(projects.libraries.rustsdk)
} else { } else {
debugImplementation(libs.matrix.sdk) debugImplementation(libs.matrix.sdk)
} }
implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") }) 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.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@@ -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,
)
}
}
@@ -9,7 +9,6 @@
package io.element.android.libraries.matrix.ui.model package io.element.android.libraries.matrix.ui.model
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -21,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.R import io.element.android.libraries.matrix.ui.R
@Immutable
data class InviteSender( data class InviteSender(
val userId: UserId, val userId: UserId,
val displayName: String, val displayName: String,
+1 -1
View File
@@ -45,7 +45,7 @@ private const val versionMonth = 9
* Release number in the month. Value must be in [0,99]. * Release number in the month. Value must be in [0,99].
* Do not update this value. it is updated by the release script. * Do not update this value. it is updated by the release script.
*/ */
private const val versionReleaseNumber = 3 private const val versionReleaseNumber = 4
object Versions { object Versions {
/** /**
+42
View File
@@ -57,6 +57,48 @@ dependencyResolutionManagement {
flatDir { flatDir {
dirs("libraries/matrix/libs") 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")
}
}
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More