WIP: Support using Element Call for voice calls in DMs

This commit is contained in:
Valere
2026-01-12 10:48:20 +01:00
committed by Benoit Marty
parent 66c3bf267a
commit ec420332c3
26 changed files with 122 additions and 42 deletions

View File

@@ -26,9 +26,10 @@ sealed interface CallType : NodeInputs, Parcelable {
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
val voiceIntent: Boolean
) : CallType {
override fun toString(): String {
return "RoomCall(sessionId=$sessionId, roomId=$roomId)"
return "RoomCall(sessionId=$sessionId, roomId=$roomId, voiceIntent=$voiceIntent)"
}
}
}

View File

@@ -88,7 +88,8 @@ class RingingCallNotificationCreator(
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
// TODO
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId, voiceIntent = false))
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,

View File

@@ -45,8 +45,9 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
),
notificationData = notificationData,
// TODO
voiceIntent = false
)
)
}
}

View File

@@ -226,6 +226,7 @@ class CallScreenPresenter(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
voiceOnly = inputs.voiceIntent,
languageTag = languageTag,
theme = theme,
).getOrThrow()

View File

@@ -112,7 +112,14 @@ class IncomingCallActivity : AppCompatActivity() {
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
elementCallEntryPoint.startCall(
CallType.RoomCall(
notificationData.sessionId,
notificationData.roomId,
// TODO
voiceIntent = false
)
)
}
private fun onCancel() {

View File

@@ -146,6 +146,8 @@ class DefaultActiveCallManager(
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
// TODO
voiceIntent = false,
),
callState = CallState.Ringing(notificationData),
)

View File

@@ -16,6 +16,7 @@ interface CallWidgetProvider {
suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
voiceOnly: Boolean,
clientId: String,
languageTag: String?,
theme: String?,

View File

@@ -32,6 +32,7 @@ class DefaultCallWidgetProvider(
override suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
voiceOnly: Boolean,
clientId: String,
languageTag: String?,
theme: String?,
@@ -50,6 +51,7 @@ class DefaultCallWidgetProvider(
baseUrl = baseUrl,
encrypted = isEncrypted,
direct = room.isDm(),
voiceOnly = voiceOnly,
hasActiveCall = roomInfo.hasRoomCall,
)
val callUrl = room.generateWidgetWebViewUrl(

View File

@@ -272,10 +272,11 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun navigateToRoomCall(roomId: RoomId) {
override fun navigateToRoomCall(roomId: RoomId, voiceIntent: Boolean) {
val callType = CallType.RoomCall(
sessionId = sessionId,
roomId = roomId,
voiceIntent = voiceIntent
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
@@ -488,10 +489,11 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun navigateToRoomCall(roomId: RoomId) {
override fun navigateToRoomCall(roomId: RoomId, voiceOnly: Boolean) {
val callType = CallType.RoomCall(
sessionId = sessionId,
roomId = roomId,
voiceIntent = voiceOnly
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)

View File

@@ -125,7 +125,7 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToRoomCall(roomId: RoomId)
fun navigateToRoomCall(roomId: RoomId, voiceIntent: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
fun navigateToPinnedMessagesList()
@@ -279,7 +279,9 @@ class MessagesNode(
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onJoinCallClick = { voiceOnly ->
callback.navigateToRoomCall(room.roomId, voiceOnly)
},
onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList,
modifier = modifier,
knockRequestsBannerView = {

View File

@@ -130,7 +130,7 @@ fun MessagesView(
onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceIntent: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
@@ -423,7 +423,7 @@ private fun MessagesViewContent(
onMessageLongClick: (TimelineItem.Event) -> Unit,
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceIntent: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,

View File

@@ -130,7 +130,7 @@ class ThreadedMessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToRoomCall(roomId: RoomId)
fun navigateToRoomCall(roomId: RoomId, voiceOnly: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
}
@@ -281,7 +281,9 @@ class ThreadedMessagesNode(
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onJoinCallClick = { voiceIntent ->
callback.navigateToRoomCall(room.roomId, voiceIntent)
},
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},

View File

@@ -100,7 +100,7 @@ fun TimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceIntent: Boolean) -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,

View File

@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
@@ -35,7 +36,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun CallMenuItem(
roomCallState: RoomCallState,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceOnly: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
when (roomCallState) {
@@ -52,7 +53,7 @@ internal fun CallMenuItem(
is RoomCallState.OnGoing -> {
OnGoingCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
onJoinCallClick = { onJoinCallClick(roomCallState.isVoiceIntent) },
modifier = modifier,
)
}
@@ -62,18 +63,30 @@ internal fun CallMenuItem(
@Composable
private fun StandByCallMenuItem(
roomCallState: RoomCallState.StandBy,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceOnly: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier,
onClick = onJoinCallClick,
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
Row(modifier = modifier) {
IconButton(
modifier = modifier,
onClick = { onJoinCallClick(true) },
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VoiceCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
IconButton(
modifier = modifier,
onClick = { onJoinCallClick(false) },
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
}
@@ -96,7 +109,11 @@ private fun OnGoingCallMenuItem(
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
imageVector = if (roomCallState.isVoiceIntent) {
CompoundIcons.VoiceCallSolid()
} else {
CompoundIcons.VideoCallSolid()
},
contentDescription = null
)
Spacer(Modifier.width(8.dp))

View File

@@ -46,7 +46,7 @@ internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event,
roomCallState: RoomCallState,
onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceOnly: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(

View File

@@ -72,7 +72,7 @@ internal fun TimelineItemRow(
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceIntent: Boolean) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =

View File

@@ -66,7 +66,7 @@ internal fun MessagesViewTopBar(
dmUserIdentityState: IdentityState?,
sharedHistoryIcon: SharedHistoryIcon,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onJoinCallClick: (voiceIntent: Boolean) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {

View File

@@ -18,10 +18,12 @@ sealed interface RoomCallState {
data class StandBy(
val canStartCall: Boolean,
// TODO: add is DM to know if should show the voice call option?
) : RoomCallState
data class OnGoing(
val canJoinCall: Boolean,
val isVoiceIntent: Boolean,
val isUserInTheCall: Boolean,
val isUserLocallyInTheCall: Boolean,
) : RoomCallState

View File

@@ -17,6 +17,7 @@ open class RoomCallStateProvider : PreviewParameterProvider<RoomCallState> {
anOngoingCallState(),
anOngoingCallState(canJoinCall = false),
anOngoingCallState(canJoinCall = true, isUserInTheCall = true),
anOngoingCallState(canJoinCall = true, isVoiceIntent = true),
RoomCallState.Unavailable,
)
}
@@ -25,10 +26,12 @@ fun anOngoingCallState(
canJoinCall: Boolean = true,
isUserInTheCall: Boolean = false,
isUserLocallyInTheCall: Boolean = isUserInTheCall,
isVoiceIntent: Boolean = false,
) = RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
isUserLocallyInTheCall = isUserLocallyInTheCall,
isVoiceIntent = isVoiceIntent
)
fun aStandByCallState(

View File

@@ -56,6 +56,7 @@ class RoomCallStatePresenter(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
isUserLocallyInTheCall = isUserLocallyInTheCall,
isVoiceIntent = false // TODO
)
else -> RoomCallState.StandBy(canStartCall = canJoinCall)
}

View File

@@ -227,6 +227,8 @@ class RoomDetailsFlowNode(
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
roomId = room.roomId,
// TODO
voiceIntent = false
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(inputs)
@@ -285,7 +287,14 @@ class RoomDetailsFlowNode(
}
override fun startCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
elementCallEntryPoint.startCall(
CallType.RoomCall(
roomId = dmRoomId,
sessionId = room.sessionId,
// TODO
voiceIntent = false
)
)
}
override fun startVerifyUserFlow(userId: UserId) {

View File

@@ -84,7 +84,8 @@ class UserProfileFlowNode(
}
override fun startCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionId, roomId = dmRoomId))
// TODO
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionId, roomId = dmRoomId, voiceIntent = false))
}
override fun startVerifyUserFlow(userId: UserId) {

View File

@@ -16,6 +16,7 @@ interface CallWidgetSettingsProvider {
widgetId: String = UUID.randomUUID().toString(),
encrypted: Boolean,
direct: Boolean,
voiceOnly: Boolean,
hasActiveCall: Boolean,
): MatrixWidgetSettings
}

View File

@@ -30,7 +30,14 @@ class DefaultCallWidgetSettingsProvider(
private val callAnalyticsCredentialsProvider: CallAnalyticCredentialsProvider,
private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean, direct: Boolean, hasActiveCall: Boolean): MatrixWidgetSettings {
override suspend fun provide(
baseUrl: String,
widgetId: String,
encrypted: Boolean,
direct: Boolean,
voiceOnly: Boolean,
hasActiveCall: Boolean
): MatrixWidgetSettings {
val isAnalyticsEnabled = analyticsService.userConsentFlow.first()
val properties = VirtualElementCallWidgetProperties(
elementCallUrl = baseUrl,
@@ -47,14 +54,18 @@ class DefaultCallWidgetSettingsProvider(
parentUrl = null,
)
val config = VirtualElementCallWidgetConfig(
// TODO remove this once we have the next EC version
preload = false,
// TODO remove this once we have the next EC version
skipLobby = null,
// // TODO remove this once we have the next EC version
// preload = false,
// // TODO remove this once we have the next EC version
// skipLobby = null,
intent = when {
direct && hasActiveCall -> CallIntent.JOIN_EXISTING_DM
direct && hasActiveCall -> {
if (voiceOnly) CallIntent.JOIN_EXISTING_DM_VOICE else CallIntent.JOIN_EXISTING_DM
}
hasActiveCall -> CallIntent.JOIN_EXISTING
direct -> CallIntent.START_CALL_DM
direct -> {
if (voiceOnly) CallIntent.START_CALL_DM_VOICE else CallIntent.START_CALL_DM
}
else -> CallIntent.START_CALL
}.also {
Timber.d("Starting/joining call with intent: $it")

View File

@@ -12,7 +12,14 @@ import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
class FakeCallWidgetSettingsProvider(
private val provideFn: (String, String, Boolean, Boolean, Boolean) -> MatrixWidgetSettings = { _, _, _, _, _ -> MatrixWidgetSettings("id", true, "url") }
private val provideFn: (
String,
String,
Boolean,
Boolean,
Boolean,
Boolean
) -> MatrixWidgetSettings = { _, _, _, _, _, _ -> MatrixWidgetSettings("id", true, "url") }
) : CallWidgetSettingsProvider {
val providedBaseUrls = mutableListOf<String>()
@@ -21,9 +28,10 @@ class FakeCallWidgetSettingsProvider(
widgetId: String,
encrypted: Boolean,
direct: Boolean,
voiceOnly: Boolean,
hasActiveCall: Boolean
): MatrixWidgetSettings {
providedBaseUrls += baseUrl
return provideFn(baseUrl, widgetId, encrypted, direct, hasActiveCall)
return provideFn(baseUrl, widgetId, encrypted, direct, voiceOnly, hasActiveCall)
}
}

View File

@@ -300,7 +300,12 @@ class DefaultPushHandler(
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
callType = CallType.RoomCall(
notifiableEvent.sessionId,
notifiableEvent.roomId,
// TODO
voiceIntent = false
),
eventId = notifiableEvent.eventId,
senderId = notifiableEvent.senderId,
roomName = notifiableEvent.roomName,