mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-09 16:22:46 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
|
||||
upload_reports:
|
||||
name: Project Check Suite
|
||||
|
||||
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
# Don't obfuscate anything for non-enterprise builds
|
||||
-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;
|
||||
}
|
||||
|
||||
@@ -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,9 +703,12 @@ class LoggedInFlowNode(
|
||||
) {
|
||||
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
|
||||
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(
|
||||
isOnline = isOnline,
|
||||
modifier = modifier,
|
||||
modifier = callContentModifier,
|
||||
) { contentModifier ->
|
||||
Box(modifier = contentModifier) {
|
||||
val ftueState by ftueService.state.collectAsState()
|
||||
@@ -716,6 +721,7 @@ class LoggedInFlowNode(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
+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)
|
||||
|
||||
+21
@@ -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,8 +41,19 @@ class DefaultElementCallEntryPoint(
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleIncomingCall(
|
||||
callData: CallData,
|
||||
|
||||
+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 }
|
||||
}
|
||||
+10
-8
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
// Call and unread
|
||||
Row(
|
||||
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
|
||||
.zIndex(-1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -358,18 +358,21 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
isAudio = room.activeCallIntent == CallIntent.AUDIO
|
||||
)
|
||||
}
|
||||
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
|
||||
if (isMuted) {
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
NotificationOffIndicatorAtom()
|
||||
} else if (room.numberOfUnreadMentions > 0) {
|
||||
MentionIndicatorAtom()
|
||||
}
|
||||
if (room.hasNewContent) {
|
||||
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
|
||||
val count = when {
|
||||
showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications
|
||||
!showUnreadCount -> 0
|
||||
else -> null
|
||||
val count = if (showUnreadCount) {
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
room.numberOfUnreadMessages
|
||||
} else {
|
||||
room.numberOfUnreadNotifications
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
UnreadIndicatorAtom(
|
||||
color = tint,
|
||||
@@ -454,6 +457,5 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
|
||||
isInviteSeen = data.name == "Bob",
|
||||
onClick = {},
|
||||
eventSink = {},
|
||||
showUnreadCount = data.numberOfUnreadNotifications > 0,
|
||||
)
|
||||
}
|
||||
|
||||
-18
@@ -82,24 +82,6 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
|
||||
numberOfUnreadMentions = 1,
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
@@ -223,7 +229,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
|
||||
|
||||
# Analytics
|
||||
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
|
||||
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_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" }
|
||||
|
||||
+25
-39
@@ -8,43 +8,39 @@
|
||||
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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 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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
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.
|
||||
* 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].
|
||||
* 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 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 contentPadding The padding to apply to the text inside the counter.
|
||||
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
|
||||
* @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.
|
||||
* Only used when [containerColor] is null.
|
||||
*/
|
||||
@@ -54,51 +50,41 @@ fun CounterAtom(
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
textStyle: TextStyle = CounterAtomDefaults.textStyle,
|
||||
dotSize: Dp = 12.0.dp,
|
||||
isCritical: Boolean = false,
|
||||
) {
|
||||
if (count < 1) return
|
||||
val countAsText = when (count) {
|
||||
in Int.MIN_VALUE..0 -> null
|
||||
in 1..MAX_COUNT -> count.toString()
|
||||
in 0..MAX_COUNT -> count.toString()
|
||||
else -> MAX_COUNT_STRING
|
||||
}
|
||||
|
||||
val minSingleDigitSize = if (countAsText?.length == 1) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = countAsText,
|
||||
text = MAX_COUNT_STRING,
|
||||
style = textStyle
|
||||
)
|
||||
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 {
|
||||
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(
|
||||
modifier = Modifier.sizeIn(
|
||||
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
|
||||
minHeight = minSingleDigitSize.toDp()
|
||||
).padding(contentPadding),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
object CounterAtomDefaults {
|
||||
@@ -109,7 +95,7 @@ object CounterAtomDefaults {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CounterAtomPreview() = ElementPreview {
|
||||
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(verticalArrangement = spacedBy(2.dp)) {
|
||||
CounterAtom(count = 0)
|
||||
CounterAtom(count = 4)
|
||||
CounterAtom(count = 99)
|
||||
|
||||
+16
-18
@@ -9,16 +9,15 @@
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -38,36 +37,35 @@ fun UnreadIndicatorAtom(
|
||||
isVisible: Boolean = true,
|
||||
contentDescription: String? = null,
|
||||
border: BorderStroke? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
) {
|
||||
when {
|
||||
!isVisible -> Spacer(modifier = modifier.size(size))
|
||||
else -> count?.let {
|
||||
CounterAtom(
|
||||
count != null && count >= 1 -> CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
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,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
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
|
||||
@Composable
|
||||
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
UnreadIndicatorAtom(count = null)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
UnreadIndicatorAtom(count = 1)
|
||||
UnreadIndicatorAtom(count = 10)
|
||||
UnreadIndicatorAtom(count = 99)
|
||||
UnreadIndicatorAtom(count = 999)
|
||||
}
|
||||
UnreadIndicatorAtom()
|
||||
}
|
||||
|
||||
+16
-49
@@ -8,18 +8,17 @@
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FloatingToolbarColors
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
|
||||
@@ -29,6 +28,7 @@ import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
@@ -139,20 +139,8 @@ fun HorizontalFloatingToolbarItem(
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
BadgedBox(
|
||||
badge = {
|
||||
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(
|
||||
Box {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.widthIn(min = 56.dp),
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
@@ -163,6 +151,15 @@ fun HorizontalFloatingToolbarItem(
|
||||
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
|
||||
@Composable
|
||||
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
|
||||
Column {
|
||||
ContentToPreview(
|
||||
count = 1,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
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
|
||||
@@ -229,11 +197,10 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
count: Int? = 6,
|
||||
floatingActionButton: (@Composable () -> Unit)?,
|
||||
) {
|
||||
HorizontalFloatingToolbar(
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
|
||||
modifier = Modifier.padding(28.dp),
|
||||
floatingActionButton = floatingActionButton,
|
||||
) {
|
||||
listOf(
|
||||
@@ -247,7 +214,7 @@ private fun ContentToPreview(
|
||||
icon = icon,
|
||||
tooltipLabel = "Label",
|
||||
isSelected = index == 0,
|
||||
counter = if (index == 0) count else null,
|
||||
counter = if (index == 0) 6 else null,
|
||||
forceRenderingTooltip = true,
|
||||
onClick = { },
|
||||
)
|
||||
|
||||
+7
@@ -170,4 +170,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-2
@@ -9,7 +9,6 @@
|
||||
package io.element.android.libraries.matrix.ui.model
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.ui.R
|
||||
|
||||
@Immutable
|
||||
data class InviteSender(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
|
||||
@@ -45,7 +45,7 @@ private const val versionMonth = 9
|
||||
* Release number in the month. Value must be in [0,99].
|
||||
* Do not update this value. it is updated by the release script.
|
||||
*/
|
||||
private const val versionReleaseNumber = 3
|
||||
private const val versionReleaseNumber = 4
|
||||
|
||||
object Versions {
|
||||
/**
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user