Element Call: remove support for SPA call links.

Closes #6578
This commit is contained in:
Benoit Marty 2026-04-27 17:01:08 +02:00
parent 9a2ad3928a
commit 8860647477
30 changed files with 203 additions and 736 deletions

View file

@ -14,22 +14,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable {
@Parcelize
data class ExternalUrl(val url: String) : CallType {
override fun toString(): String {
return "ExternalUrl"
}
}
@Parcelize
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
val isAudioCall: Boolean
) : CallType {
override fun toString(): String {
return "RoomCall(sessionId=$sessionId, roomId=$roomId, isAudioCall=$isAudioCall)"
}
}
}
@Parcelize
data class CallData(
val sessionId: SessionId,
val roomId: RoomId,
val isAudioCall: Boolean
) : NodeInputs, Parcelable

View file

@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.UserId
interface ElementCallEntryPoint {
/**
* Start a call of the given type.
* @param callType The type of call to start.
* @param callData The data of call to start.
*/
fun startCall(callType: CallType)
fun startCall(callData: CallData)
/**
* Handle an incoming call.
* @param callType The type of call.
* @param callData The data of call.
* @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in.
@ -35,7 +35,7 @@ interface ElementCallEntryPoint {
* @param textContent The text content of the notification. If null the default content from the system will be used.
*/
suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,

View file

@ -30,44 +30,10 @@
<activity
android:name=".ui.ElementCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="true"
android:label="@string/element_call"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:taskAffinity="io.element.android.features.call">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://call.element.io/.well-known/assetlinks.json -->
<data android:host="call.element.io" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element.call" />
</intent-filter>
</activity>
android:taskAffinity="io.element.android.features.call" />
<activity
android:name=".ui.IncomingCallActivity"

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
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
@ -30,12 +30,12 @@ class DefaultElementCallEntryPoint(
const val REQUEST_CODE = 2255
}
override fun startCall(callType: CallType) {
context.startActivity(IntentProvider.createIntent(context, callType))
override fun startCall(callData: CallData) {
context.startActivity(IntentProvider.createIntent(context, callData))
}
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
@ -47,8 +47,8 @@ class DefaultElementCallEntryPoint(
textContent: String?,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
roomId = callType.roomId,
sessionId = callData.sessionId,
roomId = callData.roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
@ -58,7 +58,7 @@ class DefaultElementCallEntryPoint(
expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId,
textContent = textContent,
audioOnly = callType.isAudioCall
audioOnly = callData.isAudioCall,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}

View file

@ -18,7 +18,7 @@ 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.CallType
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
@ -89,7 +89,14 @@ class RingingCallNotificationCreator(
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId, isAudioCall = audioOnly))
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,

View file

@ -13,7 +13,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.IntentCompat
import dev.zacsweers.metro.Inject
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
@ -42,7 +42,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
context.bindings<CallBindings>().inject(this)
appCoroutineScope.launch {
activeCallManager.hangUpCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly

View file

@ -23,7 +23,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
@ -52,7 +52,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CallScreenPresenter(
@Assisted private val callType: CallType,
@Assisted private val callData: CallData,
@Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider,
userAgentProvider: UserAgentProvider,
@ -69,10 +69,9 @@ class CallScreenPresenter(
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
fun create(callData: CallData, navigator: CallScreenNavigator): CallScreenPresenter
}
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
@Composable
@ -90,9 +89,9 @@ class CallScreenPresenter(
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
activeCallManager.joinedCall(callData)
fetchRoomCallUrl(
inputs = callType,
callData = callData,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
languageTag = languageTag,
@ -100,19 +99,10 @@ class CallScreenPresenter(
)
}
onDispose {
appCoroutineScope.launch { activeCallManager.hangUpCall(callType) }
appCoroutineScope.launch { activeCallManager.hangUpCall(callData) }
}
}
when (callType) {
is CallType.ExternalUrl -> {
// No analytics yet for external calls
}
is CallType.RoomCall -> {
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
}
}
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver ->
@ -149,18 +139,15 @@ class CallScreenPresenter(
.launchIn(this)
}
if (callType is CallType.RoomCall) {
// Note: For external calls isWidgetLoaded will always be false
LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
}
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
}
}
}
@ -204,37 +191,29 @@ class CallScreenPresenter(
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvent,
)
}
private suspend fun fetchRoomCallUrl(
inputs: CallType,
callData: CallData,
urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
languageTag: String?,
theme: String?,
) {
urlState.runCatchingUpdatingState {
when (inputs) {
is CallType.ExternalUrl -> {
inputs.url
}
is CallType.RoomCall -> {
val result = callWidgetProvider.getWidget(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
isAudioCall = inputs.isAudioCall,
languageTag = languageTag,
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
val result = callWidgetProvider.getWidget(
sessionId = callData.sessionId,
roomId = callData.roomId,
clientId = UUID.randomUUID().toString(),
isAudioCall = callData.isAudioCall,
languageTag = languageTag,
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${callData.sessionId}, roomId: ${callData.roomId}")
result.url
}
}
@ -242,12 +221,11 @@ class CallScreenPresenter(
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
val client = matrixClientsProvider.getOrNull(callData.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${callData.sessionId}")
}
coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
Timber.d("Observing sync state in-call for sessionId: ${callData.sessionId}")
client.syncService.syncState
.collect { state ->
if (state != SyncState.Running) {
@ -256,7 +234,7 @@ class CallScreenPresenter(
}
}
onDispose {
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
Timber.d("Stopped observing sync state in-call for sessionId: ${callData.sessionId}")
// Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false)
}

View file

@ -15,6 +15,5 @@ data class CallScreenState(
val webViewError: String?,
val userAgent: String,
val isCallActive: Boolean,
val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit,
)

View file

@ -26,7 +26,6 @@ internal fun aCallScreenState(
webViewError: String? = null,
userAgent: String = "",
isCallActive: Boolean = true,
isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState {
return CallScreenState(
@ -34,7 +33,6 @@ internal fun aCallScreenState(
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink,
)
}

View file

@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.impl.ui
import io.element.android.features.call.api.CallType
import io.element.android.libraries.matrix.api.core.SessionId
fun CallType.getSessionId(): SessionId? {
return when (this) {
is CallType.ExternalUrl -> null
is CallType.RoomCall -> sessionId
}
}

View file

@ -35,8 +35,7 @@ import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents
@ -44,7 +43,6 @@ import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.architecture.Presenter
@ -64,7 +62,6 @@ class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
PipView {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService
@ -80,7 +77,7 @@ class ElementCallActivity :
private val requestPermissionsLauncher = registerPermissionResultLauncher()
private val webViewTarget = mutableStateOf<CallType?>(null)
private val webViewTarget = mutableStateOf<CallData?>(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null
@ -98,7 +95,7 @@ class ElementCallActivity :
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
setCallType(intent)
setCallData(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
if (!::presenter.isInitialized) {
return
@ -111,8 +108,8 @@ class ElementCallActivity :
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
val colors by remember(webViewTarget.value?.getSessionId()) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId())
val colors by remember(webViewTarget.value?.sessionId) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
@ -123,9 +120,8 @@ class ElementCallActivity :
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
if (state.isCallActive || !state.isInWidgetMode) {
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
}
}
@ -188,7 +184,7 @@ class ElementCallActivity :
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
setCallData(intent)
}
override fun onDestroy() {
@ -207,25 +203,24 @@ class ElementCallActivity :
finish()
}
private fun setCallType(intent: Intent?) {
val callType = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
private fun setCallData(intent: Intent?) {
val callData = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallData::class.java)
}
val currentCallType = webViewTarget.value
if (currentCallType == null) {
if (callType == null) {
val currentCallData = webViewTarget.value
if (currentCallData == null) {
if (callData == null) {
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
finish()
} else {
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
webViewTarget.value = callData
presenter = presenterFactory.create(callData, this)
}
} else {
if (callType == null) {
if (callData == null) {
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
} else if (callType != currentCallType) {
} else if (callData != currentCallData) {
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
setIntent(intent)
recreate()
@ -236,8 +231,6 @@ class ElementCallActivity :
}
}
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()

View file

@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
@ -118,10 +118,10 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(
CallType.RoomCall(
notificationData.sessionId,
notificationData.roomId,
isAudioCall = notificationData.audioOnly
CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly,
)
)
}
@ -129,7 +129,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch {
activeCallManager.hangUpCall(callType = activeCall.callType)
activeCallManager.hangUpCall(callData = activeCall.callData)
}
}
}

View file

@ -20,7 +20,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
@ -73,20 +73,20 @@ interface ActiveCallManager {
/**
* Called to hang up the active call. It will hang up the call and remove any existing UI and the active call.
* @param callType The type of call that the user hangs up, either an external url one or a room one.
* @param callData The data about the call.
* @param notificationData The data for the incoming call notification.
*/
suspend fun hangUpCall(
callType: CallType,
callData: CallData,
notificationData: CallNotificationData? = null,
)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param callType The type of call that the user joined, either an external url one or a room one.
* @param callData The data about the call.
*/
suspend fun joinedCall(callType: CallType)
suspend fun joinedCall(callData: CallData)
}
@SingleIn(AppScope::class)
@ -143,7 +143,7 @@ class DefaultActiveCallManager(
return
}
activeCall.value = ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly,
@ -198,17 +198,17 @@ class DefaultActiveCallManager(
}
override suspend fun hangUpCall(
callType: CallType,
callData: CallData,
notificationData: CallNotificationData?,
) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType")
Timber.tag(tag).d("Hang up call: $callData")
cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters.
notificationData?.let {
ActiveCall(
callType = callType,
callData = callData,
callState = CallState.Ringing(
notificationData = notificationData,
)
@ -219,8 +219,8 @@ class DefaultActiveCallManager(
return@withLock
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
if (currentActiveCall.callData != callData) {
Timber.tag(tag).w("Call type $callData does not match the active call type, ignoring")
return@withLock
}
if (currentActiveCall.callState is CallState.Ringing) {
@ -244,8 +244,8 @@ class DefaultActiveCallManager(
activeCall.value = null
}
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType")
override suspend fun joinedCall(callData: CallData) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callData")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -254,7 +254,7 @@ class DefaultActiveCallManager(
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
callType = callType,
callData = callData,
callState = CallState.InCall,
)
}
@ -307,15 +307,15 @@ class DefaultActiveCallManager(
private fun observeRingingCall() {
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val callData = activeCall.callData
val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
val client = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
val room = client.getRoom(callType.roomId) ?: run {
val room = client.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
@ -346,17 +346,17 @@ class DefaultActiveCallManager(
// has joined the call from another session.
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val callData = activeCall.callData
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
val room = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull()?.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
it.hasRoomCall to (callData.sessionId in it.activeRoomCallParticipants)
}
}
// We only want to check if the room active call status changes
@ -388,10 +388,7 @@ class DefaultActiveCallManager(
// Nothing to do
}
is CallState.InCall -> {
when (val callType = value.callType) {
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(value.callData.roomId))
}
}
}
@ -404,7 +401,7 @@ class DefaultActiveCallManager(
* Represents an active call.
*/
data class ActiveCall(
val callType: CallType,
val callData: CallData,
val callState: CallState,
)

View file

@ -1,98 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.impl.utils
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
@Inject
class CallIntentDataParser {
private val validHttpSchemes = sequenceOf("https")
private val knownHosts = sequenceOf(
"call.element.io",
)
fun parse(data: String?): String? {
val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
}
?.takeIf { it.host in knownHosts }
?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
}
}
/**
* Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
}
private const val APP_PROMPT_PARAMETER = "appPrompt"
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"

View file

@ -12,21 +12,21 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
fun createIntent(context: Context, callData: CallData): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callData)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
fun getPendingIntent(context: Context, callData: CallData): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType),
createIntent(context, callData),
PendingIntent.FLAG_CANCEL_CURRENT,
false
)!!

View file

@ -11,7 +11,7 @@ package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
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
@ -37,7 +37,7 @@ class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@ -53,7 +53,7 @@ class DefaultElementCallEntryPointTest {
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.ui
import com.google.common.truth.Truth.assertThat
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 org.junit.Test
class CallDataTest {
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallData(A_SESSION_ID, A_ROOM_ID, false).toString())
.isEqualTo("CallData(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
}
}

View file

@ -13,7 +13,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvents
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
@ -59,38 +59,13 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse()
assertThat(initialState.isCallActive).isFalse()
analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
fun `present - with CallData sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val joinedCallLambda = lambdaRecorder<CallData, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda),
@ -107,7 +82,6 @@ class CallScreenPresenterTest {
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
@ -123,7 +97,7 @@ class CallScreenPresenterTest {
fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {},
)
@ -154,7 +128,7 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -188,7 +162,7 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -223,7 +197,7 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -260,7 +234,7 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -300,7 +274,7 @@ class CallScreenPresenterTest {
val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -338,53 +312,8 @@ class CallScreenPresenterTest {
}
}
@Test
fun `present - error from WebView are updating the state`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isEqualTo("A Webview error")
}
}
@Test
fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
val messageInterceptor = FakeWidgetMessageInterceptor()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// Emit a message
messageInterceptor.givenInterceptedMessage("A message")
// WebView emits an error, but it will be ignored
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createCallScreenPresenter(
callType: CallType,
callData: CallData,
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
@ -401,7 +330,7 @@ class CallScreenPresenterTest {
}
val clock = SystemClock { 0 }
return CallScreenPresenter(
callType = callType,
callData = callData,
navigator = navigator,
callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider,

View file

@ -1,45 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.getSessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import org.junit.Test
class CallTypeTest {
@Test
fun `getSessionId returns null for ExternalUrl`() {
assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull()
}
@Test
fun `getSessionId returns the sessionId for RoomCall`() {
assertThat(
CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
isAudioCall = false,
).getSessionId()
).isEqualTo(A_SESSION_ID)
}
@Test
fun `ExternalUrl stringification does not contain the URL`() {
assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl")
}
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false).toString())
.isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
}
}

View file

@ -1,226 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
doTest("", null)
}
@Test
fun `invalid data returns null`() {
doTest("!", null)
}
@Test
fun `data with no scheme returns null`() {
doTest("test", null)
}
@Test
fun `Element Call http urls returns null`() {
doTest("http://call.element.io", null)
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
@Test
fun `Element Call urls with unknown host returns null`() {
// Check valid host first, should not return null
doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
// Unknown host should return null
doTest("https://unknown.io", null)
doTest("https://call.unknown.io", null)
doTest("https://call.element.com", null)
doTest("https://call.element.io.tld", null)
}
@Test
fun `Element Call urls will be returned as is`() {
doTest(
url = "https://call.element.io",
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
doTest("https://app.element.io", null)
doTest("http://", null)
doTest("https://", null)
}
@Test
fun `Element Call url with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element invalid scheme returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "bad.scheme:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with url extra param appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
}
}

View file

@ -13,7 +13,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
@ -77,7 +77,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,
@ -104,7 +104,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = true,
@ -132,7 +132,7 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
assertThat(manager.activeCall.value?.callData?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
@ -178,7 +178,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hangUpCall - removes existing call if the CallType matches`() = runTest {
fun `hangUpCall - removes existing call if the CallData matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -188,7 +188,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
@ -215,7 +215,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
@ -242,7 +242,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
// Do not register the incoming call, so the manager doesn't know about it
manager.hangUpCall(
callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false),
callData = CallData(notificationData.sessionId, notificationData.roomId, false),
notificationData = notificationData,
)
coVerify {
@ -320,7 +320,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest {
fun `hangUpCall - does nothing if the CallData doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -329,7 +329,13 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.ExternalUrl("https://example.com"))
manager.hangUpCall(
CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID_2,
isAudioCall = true,
)
)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
@ -344,10 +350,10 @@ class DefaultActiveCallManagerTest {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, true))
manager.joinedCall(CallData(A_SESSION_ID, A_ROOM_ID, true))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
isAudioCall = true,
@ -450,7 +456,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,

View file

@ -8,7 +8,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallType) -> Unit = {},
var hangUpCallResult: (CallData, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallData) -> Unit = {},
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
@ -26,12 +26,12 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData)
}
override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callType, notificationData)
override suspend fun hangUpCall(callData: CallData, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callData, notificationData)
}
override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
override suspend fun joinedCall(callData: CallData) = simulateLongTask {
joinedCallResult(callData)
}
fun setActiveCall(value: ActiveCall?) {

View file

@ -8,16 +8,16 @@
package io.element.android.features.call.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = { lambdaError() },
var startCallResult: (CallData) -> Unit = { lambdaError() },
var handleIncomingCallResult: (
CallType.RoomCall,
CallData,
EventId,
UserId,
String?,
@ -27,12 +27,12 @@ class FakeElementCallEntryPoint(
String?,
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint {
override fun startCall(callType: CallType) {
startCallResult(callType)
override fun startCall(callData: CallData) {
startCallResult(callData)
}
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
@ -44,7 +44,7 @@ class FakeElementCallEntryPoint(
textContent: String?,
) {
handleIncomingCallResult(
callType,
callData,
eventId,
senderId,
roomName,

View file

@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@ -277,13 +277,13 @@ class MessagesFlowNode(
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
isAudioCall = isAudioCall,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToPinnedMessagesList() {
@ -506,13 +506,13 @@ class MessagesFlowNode(
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {

View file

@ -25,7 +25,7 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
@ -225,13 +225,13 @@ class RoomDetailsFlowNode(
}
override fun navigateToRoomCall(callIntent: CallIntent) {
val inputs = CallType.RoomCall(
val callData = CallData(
sessionId = room.sessionId,
roomId = room.roomId,
isAudioCall = callIntent == CallIntent.AUDIO
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(inputs)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToReportRoom() {
@ -288,7 +288,7 @@ class RoomDetailsFlowNode(
override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) {
elementCallEntryPoint.startCall(
CallType.RoomCall(
CallData(
roomId = dmRoomId,
sessionId = room.sessionId,
isAudioCall = callIntent == CallIntent.AUDIO

View file

@ -20,7 +20,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
@ -86,7 +86,7 @@ class UserProfileFlowNode(
override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) {
elementCallEntryPoint.startCall(
CallType.RoomCall(
CallData(
sessionId = sessionId,
roomId = dmRoomId,
isAudioCall = callIntent == CallIntent.AUDIO

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.push.impl.notifications
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
@ -215,9 +215,9 @@ class DefaultNotificationResultProcessor(
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(
notifiableEvent.sessionId,
notifiableEvent.roomId,
callData = CallData(
sessionId = notifiableEvent.sessionId,
roomId = notifiableEvent.roomId,
isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO
),
eventId = notifiableEvent.eventId,

View file

@ -8,7 +8,7 @@
package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -104,7 +104,7 @@ class DefaultNotificationResultProcessorTest {
@Test
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
val handleIncomingCallLambda = lambdaRecorder<
CallType.RoomCall,
CallData,
EventId,
UserId,
String?,
@ -140,7 +140,7 @@ class DefaultNotificationResultProcessorTest {
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<
CallType.RoomCall,
CallData,
EventId,
UserId,
String?,
@ -176,7 +176,7 @@ class DefaultNotificationResultProcessorTest {
fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest {
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<
CallType.RoomCall,
CallData,
EventId,
UserId,
String?,

View file

@ -1,14 +0,0 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# element://call?url=some-encoded-url
# For instance
# element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall
adb shell am start -a android.intent.action.VIEW -d element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall

View file

@ -1,14 +0,0 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# io.element.call:/?url=some-encoded-url
# For instance
# io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall
adb shell am start -a android.intent.action.VIEW -d io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall