Merge pull request #6668 from element-hq/feature/bma/removeExternalCallSupport

Remove external call support
This commit is contained in:
Benoit Marty 2026-04-30 17:36:40 +02:00 committed by GitHub
commit d06397946e
38 changed files with 271 additions and 827 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 io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable { @Parcelize
@Parcelize data class CallData(
data class ExternalUrl(val url: String) : CallType { val sessionId: SessionId,
override fun toString(): String { val roomId: RoomId,
return "ExternalUrl" val isAudioCall: Boolean
} ) : NodeInputs, Parcelable
}
@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)"
}
}
}

View file

@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.UserId
interface ElementCallEntryPoint { interface ElementCallEntryPoint {
/** /**
* Start a call of the given type. * 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. * 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 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 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. * @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. * @param textContent The text content of the notification. If null the default content from the system will be used.
*/ */
suspend fun handleIncomingCall( suspend fun handleIncomingCall(
callType: CallType.RoomCall, callData: CallData,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,
roomName: String?, roomName: String?,

View file

@ -30,44 +30,10 @@
<activity <activity
android:name=".ui.ElementCallActivity" android:name=".ui.ElementCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="true"
android:label="@string/element_call" android:label="@string/element_call"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:taskAffinity="io.element.android.features.call"> 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>
<activity <activity
android:name=".ui.IncomingCallActivity" android:name=".ui.IncomingCallActivity"

View file

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

View file

@ -18,7 +18,7 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person import androidx.core.app.Person
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.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.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider import io.element.android.features.call.impl.utils.IntentProvider
@ -89,7 +89,14 @@ class RingingCallNotificationCreator(
.setImportant(true) .setImportant(true)
.build() .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( val notificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvents { sealed interface PictureInPictureEvent {
data class SetPipController(val pipController: PipController) : PictureInPictureEvents data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvents data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
} }

View file

@ -36,17 +36,17 @@ class PictureInPicturePresenter(
var isInPictureInPicture by remember { mutableStateOf(false) } var isInPictureInPicture by remember { mutableStateOf(false) }
var pipController by remember { mutableStateOf<PipController?>(null) } var pipController by remember { mutableStateOf<PipController?>(null) }
fun handleEvent(event: PictureInPictureEvents) { fun handleEvent(event: PictureInPictureEvent) {
when (event) { when (event) {
is PictureInPictureEvents.SetPipController -> { is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController pipController = event.pipController
} }
PictureInPictureEvents.EnterPictureInPicture -> { PictureInPictureEvent.EnterPictureInPicture -> {
coroutineScope.launch { coroutineScope.launch {
switchToPip(pipController) switchToPip(pipController)
} }
} }
is PictureInPictureEvents.OnPictureInPictureModeChanged -> { is PictureInPictureEvent.OnPictureInPictureModeChanged -> {
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}") Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
isInPictureInPicture = event.isInPip isInPictureInPicture = event.isInPip
if (event.isInPip) { if (event.isInPip) {

View file

@ -11,5 +11,5 @@ package io.element.android.features.call.impl.pip
data class PictureInPictureState( data class PictureInPictureState(
val supportPip: Boolean, val supportPip: Boolean,
val isInPictureInPicture: Boolean, val isInPictureInPicture: Boolean,
val eventSink: (PictureInPictureEvents) -> Unit, val eventSink: (PictureInPictureEvent) -> Unit,
) )

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl.pip
fun aPictureInPictureState( fun aPictureInPictureState(
supportPip: Boolean = false, supportPip: Boolean = false,
isInPictureInPicture: Boolean = false, isInPictureInPicture: Boolean = false,
eventSink: (PictureInPictureEvents) -> Unit = {}, eventSink: (PictureInPictureEvent) -> Unit = {},
): PictureInPictureState { ): PictureInPictureState {
return PictureInPictureState( return PictureInPictureState(
supportPip = supportPip, supportPip = supportPip,

View file

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

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents { sealed interface CallScreenEvent {
data object Hangup : CallScreenEvents data object Hangup : CallScreenEvent
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvent
data class OnWebViewError(val description: String?) : CallScreenEvents data class OnWebViewError(val description: String?) : CallScreenEvent
} }

View file

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

View file

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

View file

@ -26,15 +26,13 @@ internal fun aCallScreenState(
webViewError: String? = null, webViewError: String? = null,
userAgent: String = "", userAgent: String = "",
isCallActive: Boolean = true, isCallActive: Boolean = true,
isInWidgetMode: Boolean = false, eventSink: (CallScreenEvent) -> Unit = {},
eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState { ): CallScreenState {
return CallScreenState( return CallScreenState(
urlState = urlState, urlState = urlState,
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isCallActive, isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink, eventSink = eventSink,
) )
} }

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.impl.R import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
@ -69,7 +69,7 @@ internal fun CallScreenView(
fun handleBack(fromNative: Boolean = false) { fun handleBack(fromNative: Boolean = false) {
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) { when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
CallScreenBackPressAction.EnterPictureInPicture -> CallScreenBackPressAction.EnterPictureInPicture ->
pipState.eventSink(PictureInPictureEvents.EnterPictureInPicture) pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
CallScreenBackPressAction.DispatchEscapeToWebView -> CallScreenBackPressAction.DispatchEscapeToWebView ->
callWebView?.dispatchEscKeyEvent() callWebView?.dispatchEscKeyEvent()
null -> Timber.d("Back press with unsupported pip is a no-op") null -> Timber.d("Back press with unsupported pip is a no-op")
@ -88,7 +88,7 @@ internal fun CallScreenView(
append(stringResource(CommonStrings.error_unknown)) append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
}, },
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
) )
} else { } else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) } var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
@ -128,16 +128,16 @@ internal fun CallScreenView(
Timber.d("Can't start in-call audio mode since the app is already in it.") Timber.d("Can't start in-call audio mode since the app is already in it.")
} }
}, },
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
) )
webViewAudioManager = WebViewAudioManager( webViewAudioManager = WebViewAudioManager(
webView = webView, webView = webView,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
) )
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView) val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
}, },
onDestroyWebView = { onDestroyWebView = {
callWebView = null callWebView = null
@ -154,7 +154,7 @@ internal fun CallScreenView(
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}") Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog( ErrorDialog(
content = state.urlState.error.message.orEmpty(), content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
) )
} }

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

View file

@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark 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.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings 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.notifications.CallNotificationData
@ -118,10 +118,10 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onAnswer(notificationData: CallNotificationData) { private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallType.RoomCall( CallData(
notificationData.sessionId, sessionId = notificationData.sessionId,
notificationData.roomId, roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly isAudioCall = notificationData.audioOnly,
) )
) )
} }
@ -129,7 +129,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() { private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch { 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.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig 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.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
@ -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. * 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. * @param notificationData The data for the incoming call notification.
*/ */
suspend fun hangUpCall( suspend fun hangUpCall(
callType: CallType, callData: CallData,
notificationData: CallNotificationData? = null, notificationData: CallNotificationData? = null,
) )
/** /**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. * 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) @SingleIn(AppScope::class)
@ -143,7 +143,7 @@ class DefaultActiveCallManager(
return return
} }
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = notificationData.sessionId, sessionId = notificationData.sessionId,
roomId = notificationData.roomId, roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly, isAudioCall = notificationData.audioOnly,
@ -198,17 +198,17 @@ class DefaultActiveCallManager(
} }
override suspend fun hangUpCall( override suspend fun hangUpCall(
callType: CallType, callData: CallData,
notificationData: CallNotificationData?, notificationData: CallNotificationData?,
) = mutex.withLock { ) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType") Timber.tag(tag).d("Hang up call: $callData")
cancelIncomingCallNotification() cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run { val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing // activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters. // Build a currentActiveCall with the provided parameters.
notificationData?.let { notificationData?.let {
ActiveCall( ActiveCall(
callType = callType, callData = callData,
callState = CallState.Ringing( callState = CallState.Ringing(
notificationData = notificationData, notificationData = notificationData,
) )
@ -219,8 +219,8 @@ class DefaultActiveCallManager(
return@withLock return@withLock
} }
if (currentActiveCall.callType != callType) { if (currentActiveCall.callData != callData) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") Timber.tag(tag).w("Call type $callData does not match the active call type, ignoring")
return@withLock return@withLock
} }
if (currentActiveCall.callState is CallState.Ringing) { if (currentActiveCall.callState is CallState.Ringing) {
@ -244,8 +244,8 @@ class DefaultActiveCallManager(
activeCall.value = null activeCall.value = null
} }
override suspend fun joinedCall(callType: CallType) = mutex.withLock { override suspend fun joinedCall(callData: CallData) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType") Timber.tag(tag).d("Joined call: $callData")
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call") Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -254,7 +254,7 @@ class DefaultActiveCallManager(
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
callType = callType, callData = callData,
callState = CallState.InCall, callState = CallState.InCall,
) )
} }
@ -307,15 +307,15 @@ class DefaultActiveCallManager(
private fun observeRingingCall() { private fun observeRingingCall() {
activeCall activeCall
.filterNotNull() .filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall -> .flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall val callData = activeCall.callData
val ringingInfo = activeCall.callState as CallState.Ringing 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") Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf() 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") Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf() return@flatMapLatest flowOf()
} }
@ -346,17 +346,17 @@ class DefaultActiveCallManager(
// has joined the call from another session. // has joined the call from another session.
activeCall activeCall
.filterNotNull() .filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall -> .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 // 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") Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf() return@flatMapLatest flowOf()
} }
room.roomInfoFlow.map { room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}") 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 // We only want to check if the room active call status changes
@ -388,10 +388,7 @@ class DefaultActiveCallManager(
// Nothing to do // Nothing to do
} }
is CallState.InCall -> { is CallState.InCall -> {
when (val callType = value.callType) { defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(value.callData.roomId))
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
} }
} }
} }
@ -404,7 +401,7 @@ class DefaultActiveCallManager(
* Represents an active call. * Represents an active call.
*/ */
data class ActiveCall( data class ActiveCall(
val callType: CallType, val callData: CallData,
val callState: CallState, 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.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.PendingIntentCompat 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.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider { internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply { fun createIntent(context: Context, callData: CallData): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType) putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callData)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION) 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( return PendingIntentCompat.getActivity(
context, context,
DefaultElementCallEntryPoint.REQUEST_CODE, DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType), createIntent(context, callData),
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false false
)!! )!!

View file

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

View file

@ -8,11 +8,9 @@
package io.element.android.features.call.impl.pip package io.element.android.features.call.impl.pip
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -20,9 +18,7 @@ class PictureInPicturePresenterTest {
@Test @Test
fun `when pip is not supported, the state value supportPip is false`() = runTest { fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false) val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse() assertThat(initialState.supportPip).isFalse()
} }
@ -35,9 +31,7 @@ class PictureInPicturePresenterTest {
supportPip = true, supportPip = true,
pipView = FakePipView(setPipParamsResult = { }), pipView = FakePipView(setPipParamsResult = { }),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue() assertThat(initialState.supportPip).isTrue()
} }
@ -53,18 +47,16 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult, enterPipModeResult = enterPipModeResult,
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse() assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce() enterPipModeResult.assertions().isCalledOnce()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip // User stops pip
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse() assertThat(finalState.isInPictureInPicture).isFalse()
} }
@ -80,12 +72,10 @@ class PictureInPicturePresenterTest {
handUpResult = handUpResult handUpResult = handUpResult
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false }))) initialState.eventSink(PictureInPictureEvent.SetPipController(FakePipController(canEnterPipResult = { false })))
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
handUpResult.assertions().isCalledOnce() handUpResult.assertions().isCalledOnce()
} }
} }
@ -102,12 +92,10 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult enterPipModeResult = enterPipModeResult
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink( initialState.eventSink(
PictureInPictureEvents.SetPipController( PictureInPictureEvent.SetPipController(
FakePipController( FakePipController(
canEnterPipResult = { true }, canEnterPipResult = { true },
enterPipResult = enterPipResult, enterPipResult = enterPipResult,
@ -115,16 +103,16 @@ class PictureInPicturePresenterTest {
) )
) )
) )
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce() enterPipModeResult.assertions().isCalledOnce()
enterPipResult.assertions().isNeverCalled() enterPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() assertThat(pipState.isInPictureInPicture).isTrue()
enterPipResult.assertions().isCalledOnce() enterPipResult.assertions().isCalledOnce()
// User stops pip // User stops pip
exitPipResult.assertions().isNeverCalled() exitPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse() assertThat(finalState.isInPictureInPicture).isFalse()
exitPipResult.assertions().isCalledOnce() exitPipResult.assertions().isCalledOnce()

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,8 +13,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen 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.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.WidgetMessageSerializer import io.element.android.features.call.impl.utils.WidgetMessageSerializer
@ -39,6 +39,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
@ -59,46 +60,19 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest { fun `present - with CallData sets call as active, loads URL and runs WidgetDriver`() = 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 {
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver) val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {} val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {} val joinedCallLambda = lambdaRecorder<CallData, Unit> {}
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
widgetProvider = widgetProvider, widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda), screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda), activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Wait until the URL is loaded // Wait until the URL is loaded
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(1) skipItems(1)
@ -107,7 +81,6 @@ class CallScreenPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java) assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1) assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
@ -123,19 +96,17 @@ class CallScreenPresenterTest {
fun `present - set message interceptor, send and receive messages`() = runTest { fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView // And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message") widgetDriver.givenIncomingMessage("A message")
@ -154,24 +125,22 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup) initialState.eventSink(CallScreenEvent.Hangup)
// Let background coroutines run and the widget drive be received // Let background coroutines run and the widget drive be received
runCurrent() runCurrent()
@ -188,22 +157,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
@ -223,22 +190,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage( messageInterceptor.givenInterceptedMessage(
""" """
{ {
@ -260,22 +225,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
skipItems(2) skipItems(2)
// Wait for the timeout to trigger // Wait for the timeout to trigger
@ -300,7 +263,7 @@ class CallScreenPresenterTest {
val matrixClient = FakeMatrixClient(syncService = syncService) val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService() val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -338,53 +301,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( private fun TestScope.createCallScreenPresenter(
callType: CallType, callData: CallData,
navigator: CallScreenNavigator = FakeCallScreenNavigator(), navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
@ -401,7 +319,7 @@ class CallScreenPresenterTest {
} }
val clock = SystemClock { 0 } val clock = SystemClock { 0 }
return CallScreenPresenter( return CallScreenPresenter(
callType = callType, callData = callData,
navigator = navigator, navigator = navigator,
callWidgetProvider = widgetProvider, callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider, userAgentProvider = userAgentProvider,

View file

@ -18,9 +18,9 @@ import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvents import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenView import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandler import io.element.android.features.call.impl.ui.JavascriptBackHandler
import io.element.android.features.call.impl.ui.aCallScreenState import io.element.android.features.call.impl.ui.aCallScreenState
@ -39,7 +39,7 @@ import org.robolectric.shadows.ShadowWebView
class CallScreenViewTest { class CallScreenViewTest {
@Test @Test
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest { fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
val callEvents = EventsRecorder<CallScreenEvents>() val callEvents = EventsRecorder<CallScreenEvent>()
setCallScreenView( setCallScreenView(
state = aCallScreenState(eventSink = callEvents), state = aCallScreenState(eventSink = callEvents),
@ -72,7 +72,7 @@ class CallScreenViewTest {
@Config(shadows = [RecordingShadowWebView::class]) @Config(shadows = [RecordingShadowWebView::class])
@Test @Test
fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest { fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
val pipEvents = EventsRecorder<PictureInPictureEvents>() val pipEvents = EventsRecorder<PictureInPictureEvent>()
setCallScreenView( setCallScreenView(
state = aCallScreenState(), state = aCallScreenState(),
@ -88,8 +88,8 @@ class CallScreenViewTest {
} }
pipEvents.assertSize(2) pipEvents.assertSize(2)
pipEvents.assertTrue(0) { it is PictureInPictureEvents.SetPipController } pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
pipEvents.assertTrue(1) { it is PictureInPictureEvents.EnterPictureInPicture } pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
} }
} }

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode 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.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@ -277,13 +277,13 @@ class MessagesFlowNode(
} }
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall( val callData = CallData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
isAudioCall = isAudioCall isAudioCall = isAudioCall,
) )
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callData)
} }
override fun navigateToPinnedMessagesList() { override fun navigateToPinnedMessagesList() {
@ -506,13 +506,13 @@ class MessagesFlowNode(
} }
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall( val callData = CallData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
isAudioCall = isAudioCall isAudioCall = isAudioCall
) )
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callData)
} }
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { 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 im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig 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.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -225,13 +225,13 @@ class RoomDetailsFlowNode(
} }
override fun navigateToRoomCall(callIntent: CallIntent) { override fun navigateToRoomCall(callIntent: CallIntent) {
val inputs = CallType.RoomCall( val callData = CallData(
sessionId = room.sessionId, sessionId = room.sessionId,
roomId = room.roomId, roomId = room.roomId,
isAudioCall = callIntent == CallIntent.AUDIO isAudioCall = callIntent == CallIntent.AUDIO
) )
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(inputs) elementCallEntryPoint.startCall(callData)
} }
override fun navigateToReportRoom() { override fun navigateToReportRoom() {
@ -288,7 +288,7 @@ class RoomDetailsFlowNode(
override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) { override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallType.RoomCall( CallData(
roomId = dmRoomId, roomId = dmRoomId,
sessionId = room.sessionId, sessionId = room.sessionId,
isAudioCall = callIntent == CallIntent.AUDIO 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.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode import io.element.android.features.userprofile.impl.root.UserProfileNode
@ -86,7 +86,7 @@ class UserProfileFlowNode(
override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) { override fun startCall(dmRoomId: RoomId, callIntent: CallIntent) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallType.RoomCall( CallData(
sessionId = sessionId, sessionId = sessionId,
roomId = dmRoomId, roomId = dmRoomId,
isAudioCall = callIntent == CallIntent.AUDIO 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.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn 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.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -215,9 +215,9 @@ class DefaultNotificationResultProcessor(
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.") Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall( elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall( callData = CallData(
notifiableEvent.sessionId, sessionId = notifiableEvent.sessionId,
notifiableEvent.roomId, roomId = notifiableEvent.roomId,
isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO
), ),
eventId = notifiableEvent.eventId, eventId = notifiableEvent.eventId,

View file

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