mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Merge branch 'develop' into renovate/androidx.compose.material3-material3-1.x
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
Changes in Element X v26.09.4
|
||||
=============================
|
||||
|
||||
## Hotfix 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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -53,9 +53,10 @@
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:theme="@style/Theme.ElementX.Splash"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<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
|
||||
private fun MainContent(appBindings: AppBindings) {
|
||||
val migrationState = appBindings.migrationEntryPoint().present()
|
||||
|
||||
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Multibinds
|
||||
import io.element.android.features.api.MigrationEntryPoint
|
||||
import io.element.android.features.callnative.api.NativeCallPip
|
||||
import io.element.android.features.enterprise.api.AppStartupHook
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
@@ -52,6 +53,13 @@ interface AppBindings {
|
||||
|
||||
fun buildMeta(): BuildMeta
|
||||
|
||||
/**
|
||||
* For picture-in-picture. Only an Activity is told it is being left, and only the call knows
|
||||
* whether that is worth shrinking for, so `MainActivity` needs this much of the native call and
|
||||
* no more.
|
||||
*/
|
||||
fun nativeCallPip(): NativeCallPip
|
||||
|
||||
fun sentrySdkDsn(): SentrySdkDsn?
|
||||
|
||||
@Multibinds(allowEmpty = true)
|
||||
|
||||
@@ -50,6 +50,7 @@ dependencies {
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(projects.features.announcement.api)
|
||||
implementation(projects.features.callnative.api)
|
||||
implementation(projects.features.ftue.api)
|
||||
implementation(projects.features.linknewdevice.api)
|
||||
implementation(projects.features.share.api)
|
||||
|
||||
@@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode
|
||||
import io.element.android.appnav.room.RoomNavigationTarget
|
||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.callnative.api.NativeCallHost
|
||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
@@ -144,6 +145,7 @@ class LoggedInFlowNode(
|
||||
private val shareEntryPoint: ShareEntryPoint,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val nativeCallHost: NativeCallHost,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
@@ -701,15 +703,19 @@ class LoggedInFlowNode(
|
||||
) {
|
||||
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
ConnectivityIndicatorContainer(
|
||||
isOnline = isOnline,
|
||||
modifier = modifier,
|
||||
) { contentModifier ->
|
||||
Box(modifier = contentModifier) {
|
||||
val ftueState by ftueService.state.collectAsState()
|
||||
BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
|
||||
if (ftueState is FtueState.Complete) {
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
|
||||
// Outside the connectivity indicator so the two strips stack rather than fight: an
|
||||
// offline banner during a call belongs under the call bar, not over it.
|
||||
nativeCallHost.Render(modifier = modifier) { callContentModifier ->
|
||||
ConnectivityIndicatorContainer(
|
||||
isOnline = isOnline,
|
||||
modifier = callContentModifier,
|
||||
) { contentModifier ->
|
||||
Box(modifier = contentModifier) {
|
||||
val ftueState by ftueService.state.collectAsState()
|
||||
BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
|
||||
if (ftueState is FtueState.Complete) {
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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()
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
@@ -86,6 +86,7 @@ dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.element.call.embedded)
|
||||
implementation(projects.features.callnative.api)
|
||||
api(projects.features.call.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
|
||||
+22
-1
@@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.features.call.impl.utils.IntentProvider
|
||||
import io.element.android.features.callnative.api.NativeCallEntryPoint
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultElementCallEntryPoint(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val activeCallManager: ActiveCallManager,
|
||||
private val nativeCallEntryPoint: NativeCallEntryPoint,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
||||
) : ElementCallEntryPoint {
|
||||
companion object {
|
||||
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
|
||||
@@ -31,7 +41,18 @@ class DefaultElementCallEntryPoint(
|
||||
}
|
||||
|
||||
override fun startCall(callData: CallData) {
|
||||
context.startActivity(IntentProvider.createIntent(context, callData))
|
||||
// The flag is backed by storage, so it has to be read asynchronously. Caching it in an
|
||||
// eagerly started StateFlow would race: a call placed just after app start could be routed
|
||||
// on the default value before the real one arrived.
|
||||
appCoroutineScope.launch {
|
||||
val useNativeCall = featureFlagService.isFeatureEnabled(FeatureFlags.NativeCall)
|
||||
Timber.i("startCall: roomId=${callData.roomId}, nativeCall=$useNativeCall")
|
||||
if (useNativeCall) {
|
||||
nativeCallEntryPoint.startCall(callData)
|
||||
} else {
|
||||
context.startActivity(IntentProvider.createIntent(context, callData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleIncomingCall(
|
||||
|
||||
+24
-10
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.Person
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.call.api.CallData
|
||||
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
|
||||
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||
import io.element.android.features.call.impl.utils.IntentProvider
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
|
||||
* Request code for the full screen intent.
|
||||
*/
|
||||
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
|
||||
|
||||
/**
|
||||
* Request code for the answer action.
|
||||
*
|
||||
* Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target
|
||||
* `IncomingCallActivity`: they carry different extras, and a shared request code would let
|
||||
* `FLAG_CANCEL_CURRENT` on one replace the other.
|
||||
*/
|
||||
const val ANSWER_REQUEST_CODE = 3
|
||||
}
|
||||
|
||||
suspend fun createNotification(
|
||||
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
|
||||
.setImportant(true)
|
||||
.build()
|
||||
|
||||
val answerIntent = IntentProvider.getPendingIntent(
|
||||
context,
|
||||
CallData(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
isAudioCall = audioOnly,
|
||||
),
|
||||
)
|
||||
val notificationData = CallNotificationData(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
|
||||
audioOnly = audioOnly,
|
||||
)
|
||||
|
||||
// Through IncomingCallActivity rather than straight to ElementCallActivity, so that
|
||||
// answering goes through ElementCallEntryPoint and lands in whichever call stack the
|
||||
// NativeCall flag selects. Naming the WebView activity here meant the notification's Answer
|
||||
// button always opened the WebView, even with native calls turned on.
|
||||
val answerIntent = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
ANSWER_REQUEST_CODE,
|
||||
Intent(context, IncomingCallActivity::class.java).apply {
|
||||
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
|
||||
putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true)
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
)!!
|
||||
|
||||
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
DECLINE_REQUEST_CODE,
|
||||
|
||||
+42
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||
* Extra key for the notification data.
|
||||
*/
|
||||
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
|
||||
|
||||
/**
|
||||
* Answer straight away rather than showing the incoming-call screen.
|
||||
*
|
||||
* Set by the notification's Answer action, which has already asked the user - showing them
|
||||
* the same question again would be the bug. The screen is for the full-screen intent, where
|
||||
* the user has not answered anything yet.
|
||||
*/
|
||||
const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY"
|
||||
}
|
||||
|
||||
@Inject
|
||||
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||
)
|
||||
|
||||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||
if (answerImmediatelyIfAsked(intent)) return
|
||||
if (notificationData != null) {
|
||||
setContent {
|
||||
val colors by remember {
|
||||
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Answer action can arrive while this activity is already showing the ringing screen, because
|
||||
* the full screen intent put it there and it is `singleTask`. That delivers a new intent instead
|
||||
* of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and
|
||||
* [setIntent] has to run first, or everything reading `intent` afterwards would still see the one
|
||||
* the activity was created with.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
answerImmediatelyIfAsked(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers and finishes when the intent is the notification's Answer action, and reports whether
|
||||
* it did.
|
||||
*
|
||||
* Answering goes through this activity rather than straight to a call screen so that it goes
|
||||
* through `ElementCallEntryPoint`, which is what decides between the WebView and the native
|
||||
* stack. The notification action used to name `ElementCallActivity` outright, so answering from
|
||||
* the notification always opened the WebView however the flag was set.
|
||||
*/
|
||||
private fun answerImmediatelyIfAsked(intent: Intent?): Boolean {
|
||||
if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false
|
||||
val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java)
|
||||
?: return false
|
||||
onAnswer(notificationData)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onAnswer(notificationData: CallNotificationData) {
|
||||
elementCallEntryPoint.startCall(
|
||||
CallData(
|
||||
|
||||
+14
-2
@@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.call.api.CallData
|
||||
import io.element.android.features.call.api.CurrentCall
|
||||
import io.element.android.features.call.api.RingingCallTracker
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
@@ -90,7 +92,8 @@ interface ActiveCallManager {
|
||||
}
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
@ContributesBinding(AppScope::class, binding = binding<ActiveCallManager>())
|
||||
@ContributesBinding(AppScope::class, binding = binding<RingingCallTracker>())
|
||||
class DefaultActiveCallManager(
|
||||
@ApplicationContext context: Context,
|
||||
@AppCoroutineScope
|
||||
@@ -103,7 +106,16 @@ class DefaultActiveCallManager(
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val systemClock: SystemClock,
|
||||
) : ActiveCallManager {
|
||||
) : ActiveCallManager, RingingCallTracker {
|
||||
/**
|
||||
* The two [RingingCallTracker] entry points are the existing methods under the names a caller
|
||||
* outside this module can reach. Deliberately not new behaviour: answering has always had to do
|
||||
* exactly this, and the native path simply could not say so from another module.
|
||||
*/
|
||||
override suspend fun onCallJoined(callData: CallData) = joinedCall(callData)
|
||||
|
||||
override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData)
|
||||
|
||||
private val tag = "ActiveCallManager"
|
||||
private var timedOutCallJob: Job? = null
|
||||
|
||||
|
||||
+7
-4
@@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.features.call.api.CurrentCall
|
||||
import io.element.android.features.call.api.CurrentCallService
|
||||
import io.element.android.features.call.api.CurrentCallTracker
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultCurrentCallService : CurrentCallService {
|
||||
@ContributesBinding(AppScope::class, binding = binding<CurrentCallService>())
|
||||
@ContributesBinding(AppScope::class, binding = binding<CurrentCallTracker>())
|
||||
class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker {
|
||||
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
|
||||
|
||||
fun onCallStarted(call: CurrentCall) {
|
||||
override fun onCallStarted(call: CurrentCall) {
|
||||
currentCall.value = call
|
||||
}
|
||||
|
||||
fun onCallEnded() {
|
||||
override fun onCallEnded() {
|
||||
currentCall.value = CurrentCall.None
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -16,15 +16,21 @@ import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||
import io.element.android.features.call.utils.FakeActiveCallManager
|
||||
import io.element.android.features.call.utils.FakeNativeCallEntryPoint
|
||||
import io.element.android.features.callnative.api.NativeCallEntryPoint
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
@@ -32,10 +38,13 @@ import org.robolectric.Shadows.shadowOf
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class DefaultElementCallEntryPointTest : RobolectricTest() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
|
||||
val entryPoint = createEntryPoint()
|
||||
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
|
||||
// The flag is read asynchronously before routing.
|
||||
advanceUntilIdle()
|
||||
|
||||
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
|
||||
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
|
||||
@@ -43,6 +52,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
|
||||
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `startCall - routes to the native call stack when the feature flag is on`() = runTest {
|
||||
val startNativeCallLambda = lambdaRecorder<CallData, Unit> {}
|
||||
val entryPoint = createEntryPoint(
|
||||
nativeCallEntryPoint = FakeNativeCallEntryPoint(startNativeCallLambda),
|
||||
isNativeCallEnabled = true,
|
||||
)
|
||||
val callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = true)
|
||||
|
||||
entryPoint.startCall(callData)
|
||||
advanceUntilIdle()
|
||||
|
||||
startNativeCallLambda.assertions().isCalledOnce().with(value(callData))
|
||||
// The WebView Activity must not also be launched.
|
||||
assertThat(shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity).isNull()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
|
||||
@@ -70,8 +97,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() {
|
||||
|
||||
private fun TestScope.createEntryPoint(
|
||||
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||
nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(),
|
||||
isNativeCallEnabled: Boolean = false,
|
||||
) = DefaultElementCallEntryPoint(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
activeCallManager = activeCallManager,
|
||||
nativeCallEntryPoint = nativeCallEntryPoint,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled),
|
||||
),
|
||||
appCoroutineScope = this,
|
||||
)
|
||||
}
|
||||
|
||||
+31
@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import coil3.ImageLoader
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
@@ -26,6 +27,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
class RingingCallNotificationCreatorTest : RobolectricTest() {
|
||||
@Test
|
||||
@@ -89,6 +91,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
|
||||
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest {
|
||||
val notificationCreator = createRingingCallNotificationCreator(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
|
||||
)
|
||||
|
||||
val notification = notificationCreator.createTestNotification()
|
||||
|
||||
// Not ElementCallActivity: naming the WebView activity here made the Answer button open the
|
||||
// WebView call whatever FeatureFlags.NativeCall was set to.
|
||||
val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent
|
||||
assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
|
||||
assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
|
||||
assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createNotification - tapping the notification body answers too`() = runTest {
|
||||
val notificationCreator = createRingingCallNotificationCreator(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
|
||||
)
|
||||
|
||||
val notification = notificationCreator.createTestNotification()
|
||||
|
||||
val contentIntent = shadowOf(notification?.contentIntent).savedIntent
|
||||
assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
|
||||
assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
|
||||
}
|
||||
|
||||
private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
+180
@@ -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
|
||||
}
|
||||
}
|
||||
+62
@@ -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,
|
||||
)
|
||||
}
|
||||
+57
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+103
@@ -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)
|
||||
}
|
||||
}
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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 }
|
||||
}
|
||||
@@ -64,6 +64,8 @@ detekt_compose_rules = "0.4.28"
|
||||
ktlint = "1.8.0"
|
||||
androidx-test-ext-junit = "1.3.0"
|
||||
kover = "0.9.9"
|
||||
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android
|
||||
element_call = "0.1.0-rc.6"
|
||||
|
||||
[libraries]
|
||||
# Project
|
||||
@@ -182,7 +184,11 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
|
||||
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
|
||||
# All new features should not be implemented in the pull request that upgrades the version, developers should
|
||||
# only fix API breaks and may add some TODOs.
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.28"
|
||||
# `strictly` because element-call-matrix is a published AAR compiled against one SDK version and declares
|
||||
# it as a plain `require`: without this, a library built against a newer SDK would silently drag this app
|
||||
# onto it. With it, this pin always wins, and a library that needs something newer fails at configuration
|
||||
# time naming both versions instead of at runtime with NoSuchMethodError.
|
||||
matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.09.28" } }
|
||||
rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0"
|
||||
|
||||
# Others
|
||||
@@ -236,6 +242,13 @@ metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro"
|
||||
|
||||
# Element Call
|
||||
element_call_embedded = "io.element.android:element-call-embedded:0.26.0"
|
||||
# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together.
|
||||
element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" }
|
||||
element_call_api = { module = "io.element.android:element-call-api" }
|
||||
element_call_impl = { module = "io.element.android:element-call" }
|
||||
element_call_ui = { module = "io.element.android:element-call-ui" }
|
||||
element_call_matrix = { module = "io.element.android:element-call-matrix" }
|
||||
element_call_test = { module = "io.element.android:element-call-test" }
|
||||
|
||||
# Auto services
|
||||
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
|
||||
|
||||
+7
@@ -162,4 +162,11 @@ enum class FeatureFlags(
|
||||
isFinished = false,
|
||||
isInLabs = false,
|
||||
),
|
||||
NativeCall(
|
||||
key = "feature.native_call",
|
||||
title = "Native calls (experimental)",
|
||||
description = "Place calls with the native MatrixRTC stack instead of the Element Call WebView.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -20,16 +20,47 @@ android {
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
// Temporary: `-PelementCallLocalVersion` points this at a locally published build of
|
||||
// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal
|
||||
// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it.
|
||||
val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion")
|
||||
.getOrElse(libs.versions.element.call.get())
|
||||
|
||||
val usesLocalRustSdk = file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()
|
||||
|
||||
if (usesLocalRustSdk) {
|
||||
// element-call-matrix declares the Rust SDK as an ordinary Maven dependency, so with the local
|
||||
// AAR in place a debug classpath would carry two copies of org.matrix.rustcomponents.sdk.
|
||||
// Point the library's dependency at the same local AAR the app is using.
|
||||
configurations.matching { it.name.startsWith("debug") }.configureEach {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
substitute(module("org.matrix.rustcomponents:sdk-android"))
|
||||
.using(project(":libraries:rustsdk"))
|
||||
.because("the local Rust SDK AAR replaces the published one on debug builds")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
releaseImplementation(libs.matrix.sdk)
|
||||
if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) {
|
||||
println("\nNote: Using local binary of the Rust SDK.\n")
|
||||
if (usesLocalRustSdk) {
|
||||
println(
|
||||
"\nNote: Using local binary of the Rust SDK." +
|
||||
"\n element-call-matrix is substituted onto it as well, so the native call runs" +
|
||||
"\n against this SDK rather than the one it was published against.\n"
|
||||
)
|
||||
debugImplementation(projects.libraries.rustsdk)
|
||||
} else {
|
||||
debugImplementation(libs.matrix.sdk)
|
||||
}
|
||||
implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") })
|
||||
|
||||
// The native call's Matrix transport. Only this module may hand it a raw SDK client.
|
||||
implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
|
||||
implementation(libs.element.call.api)
|
||||
implementation(projects.features.callnative.api)
|
||||
implementation(libs.element.call.matrix)
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,48 @@ dependencyResolutionManagement {
|
||||
flatDir {
|
||||
dirs("libraries/matrix/libs")
|
||||
}
|
||||
// Temporary: the native call component and the matrix-rust-rtc core publish as GitHub release
|
||||
// assets, which carry full Maven metadata. Both blocks go once they reach Maven Central.
|
||||
ivy {
|
||||
url = uri("https://github.com/element-hq/element-call-android/releases/download")
|
||||
patternLayout { artifact("v[revision]/[artifact]-[revision](-[classifier])(.[ext])") }
|
||||
metadataSources { gradleMetadata() }
|
||||
content {
|
||||
// Per module: `element-call.*` would also catch element-call-embedded, the WebView call.
|
||||
includeModule("io.element.android", "element-call-bom")
|
||||
includeModule("io.element.android", "element-call-api")
|
||||
includeModule("io.element.android", "element-call")
|
||||
includeModule("io.element.android", "element-call-ui")
|
||||
includeModule("io.element.android", "element-call-matrix")
|
||||
includeModule("io.element.android", "element-call-test")
|
||||
}
|
||||
}
|
||||
ivy {
|
||||
url = uri("https://github.com/element-hq/matrix-rust-rtc/releases/download")
|
||||
patternLayout { artifact("v[revision]/[artifact]-[revision](-[classifier])(.[ext])") }
|
||||
// The core ships an AAR and nothing else; element-call's module metadata names it with an
|
||||
// explicit aar artifact selector, so it needs no POM.
|
||||
metadataSources { artifact() }
|
||||
content { includeModule("io.element.android", "matrix-rtc-android") }
|
||||
}
|
||||
// Also temporary: testing an unreleased element-call-android build. Publish it there with
|
||||
// `publishToMavenLocal -PVERSION_NAME=0.1.0-local`, then build here with
|
||||
// `-PelementCallLocalVersion=0.1.0-local`. Absent without the property, so that everything
|
||||
// resolves for a contributor who has never built the library.
|
||||
providers.gradleProperty("elementCallLocalVersion").orNull?.let { elementCallVersion ->
|
||||
logger.lifecycle("Note: resolving io.element.android:element-call-* $elementCallVersion from mavenLocal")
|
||||
mavenLocal {
|
||||
content {
|
||||
// Per module: compound-android and element-call-embedded stay on Maven Central.
|
||||
includeModule("io.element.android", "element-call-bom")
|
||||
includeModule("io.element.android", "element-call-api")
|
||||
includeModule("io.element.android", "element-call")
|
||||
includeModule("io.element.android", "element-call-ui")
|
||||
includeModule("io.element.android", "element-call-matrix")
|
||||
includeModule("io.element.android", "element-call-test")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -18,6 +18,7 @@ uses-permission: name='android.permission.USE_FULL_SCREEN_INTENT'
|
||||
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
|
||||
uses-permission: name='android.permission.VIBRATE'
|
||||
uses-permission: name='android.permission.ACCESS_LOCAL_NETWORK'
|
||||
uses-permission: name='android.permission.FOREGROUND_SERVICE_CAMERA'
|
||||
uses-permission: name='android.permission.USE_BIOMETRIC'
|
||||
uses-permission: name='android.permission.USE_FINGERPRINT'
|
||||
uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
|
||||
|
||||
Reference in New Issue
Block a user