diff --git a/newsfragment/3520.misc b/newsfragment/3520.misc new file mode 100644 index 00000000000..d015c3e03d8 --- /dev/null +++ b/newsfragment/3520.misc @@ -0,0 +1 @@ +VoIP: Merge virtual room timeline in corresponding native room (call events only). \ No newline at end of file diff --git a/vector/src/main/java/im/vector/app/features/call/lookup/CallUserMapper.kt b/vector/src/main/java/im/vector/app/features/call/lookup/CallUserMapper.kt index 04177bd2b0b..aa7654c405d 100644 --- a/vector/src/main/java/im/vector/app/features/call/lookup/CallUserMapper.kt +++ b/vector/src/main/java/im/vector/app/features/call/lookup/CallUserMapper.kt @@ -27,11 +27,21 @@ import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams class CallUserMapper(private val session: Session, private val protocolsChecker: CallProtocolsChecker) { fun nativeRoomForVirtualRoom(roomId: String): String? { + if (!protocolsChecker.supportVirtualRooms) return null val virtualRoom = session.getRoom(roomId) ?: return null val virtualRoomEvent = virtualRoom.getAccountDataEvent(RoomAccountDataTypes.EVENT_TYPE_VIRTUAL_ROOM) return virtualRoomEvent?.content?.toModel()?.nativeRoomId } + fun virtualRoomForNativeRoom(roomId: String): String? { + if (!protocolsChecker.supportVirtualRooms) return null + val virtualRoomEvents = session.accountDataService().getRoomAccountDataEvents(setOf(RoomAccountDataTypes.EVENT_TYPE_VIRTUAL_ROOM)) + return virtualRoomEvents.firstOrNull { + val virtualRoomContent = it.content.toModel() + virtualRoomContent?.nativeRoomId == roomId + }?.roomId + } + suspend fun getOrCreateVirtualRoomForRoom(roomId: String, opponentUserId: String): String? { protocolsChecker.awaitCheckProtocols() if (!protocolsChecker.supportVirtualRooms) return null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 8307e935763..024ce9e6d58 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -1614,8 +1614,7 @@ class RoomDetailFragment @Inject constructor( override fun onEventLongClicked(informationData: MessageInformationData, messageContent: Any?, view: View): Boolean { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - val roomId = roomDetailArgs.roomId - + val roomId = roomDetailViewModel.timeline.getTimelineEventWithId(informationData.eventId)?.roomId ?: return false this.view?.hideKeyboard() MessageActionsBottomSheet diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 751114c2d9e..69aa1e83d39 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -48,8 +48,8 @@ import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrate import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler +import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder -import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper import im.vector.app.features.powerlevel.PowerLevelsObservableFactory @@ -118,7 +118,7 @@ class RoomDetailViewModel @AssistedInject constructor( private val chatEffectManager: ChatEffectManager, private val directRoomHelper: DirectRoomHelper, private val jitsiService: JitsiService, - timelineSettingsFactory: TimelineSettingsFactory + timelineFactory: TimelineFactory ) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener { @@ -126,9 +126,8 @@ class RoomDetailViewModel @AssistedInject constructor( private val eventId = initialState.eventId private val invisibleEventsObservable = BehaviorRelay.create() private val visibleEventsObservable = BehaviorRelay.create() - private val timelineSettings = timelineSettingsFactory.create() private var timelineEvents = PublishRelay.create>() - val timeline = room.createTimeline(eventId, timelineSettings) + val timeline = timelineFactory.createTimeline(viewModelScope, room, eventId) // Same lifecycle than the ViewModel (survive to screen rotation) val previewUrlRetriever = PreviewUrlRetriever(session, viewModelScope) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 9dcc3e81823..9697fb66727 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.call.vectorCallService import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController @@ -26,6 +27,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHold import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent @@ -38,6 +40,7 @@ import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class CallItemFactory @Inject constructor( + private val session: Session, private val messageColorProvider: MessageColorProvider, private val messageInformationDataFactory: MessageInformationDataFactory, private val messageItemAttributesFactory: MessageItemAttributesFactory, @@ -132,7 +135,8 @@ class CallItemFactory @Inject constructor( isStillActive: Boolean, callback: TimelineEventController.Callback? ): CallTileTimelineItem? { - val userOfInterest = roomSummariesHolder.get(roomId)?.toMatrixItem() ?: return null + val correctedRoomId = session.vectorCallService.userMapper.nativeRoomForVirtualRoom(roomId) ?: roomId + val userOfInterest = roomSummariesHolder.get(correctedRoomId)?.toMatrixItem() ?: return null val attributes = messageItemAttributesFactory.create(null, informationData, callback).let { CallTileTimelineItem.Attributes( callId = callId, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt new file mode 100644 index 00000000000..b57e39b3cfe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.factory + +import im.vector.app.features.call.vectorCallService +import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory +import im.vector.app.features.home.room.detail.timeline.merged.MergedTimelines +import kotlinx.coroutines.CoroutineScope +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import javax.inject.Inject + +private val secondaryTimelineAllowedTypes = listOf( + EventType.CALL_HANGUP, + EventType.CALL_INVITE, + EventType.CALL_REJECT, + EventType.CALL_ANSWER +) + +class TimelineFactory @Inject constructor(private val session: Session, private val timelineSettingsFactory: TimelineSettingsFactory) { + + fun createTimeline(coroutineScope: CoroutineScope, mainRoom: Room, eventId: String?): Timeline { + val settings = timelineSettingsFactory.create() + if (!session.vectorCallService.protocolChecker.supportVirtualRooms) { + return mainRoom.createTimeline(eventId, settings) + } + val virtualRoomId = session.vectorCallService.userMapper.virtualRoomForNativeRoom(mainRoom.roomId) + return if (virtualRoomId == null) { + mainRoom.createTimeline(eventId, settings) + } else { + val virtualRoom = session.getRoom(virtualRoomId)!! + MergedTimelines( + coroutineScope = coroutineScope, + mainTimeline = mainRoom.createTimeline(eventId, settings), + secondaryTimelineParams = MergedTimelines.SecondaryTimelineParams( + timeline = virtualRoom.createTimeline(null, settings), + shouldFilterTypes = true, + allowedTypes = secondaryTimelineAllowedTypes + ) + ) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt new file mode 100644 index 00000000000..0d5dbc5a8e3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.merged + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import kotlin.reflect.KMutableProperty0 + +/** + * This can be use to merge timeline tiles from 2 different rooms. + * Be aware it wont work properly with permalink. + */ +class MergedTimelines( + private val coroutineScope: CoroutineScope, + private val mainTimeline: Timeline, + private val secondaryTimelineParams: SecondaryTimelineParams) : Timeline by mainTimeline { + + data class SecondaryTimelineParams( + val timeline: Timeline, + val disableReadReceipts: Boolean = true, + val shouldFilterTypes: Boolean = false, + val allowedTypes: List = emptyList() + ) + + private var mainIsInit = false + private var secondaryIsInit = false + private val secondaryTimeline = secondaryTimelineParams.timeline + + private val listenersMapping = HashMap>() + private val mainTimelineEvents = ArrayList() + private val secondaryTimelineEvents = ArrayList() + private val positionsMapping = HashMap() + private val mergedEvents = ArrayList() + + private val processingSemaphore = Semaphore(1) + + private class ListenerInterceptor( + var timeline: Timeline?, + private val wrappedListener: Timeline.Listener, + private val shouldFilterTypes: Boolean, + private val allowedTypes: List, + private val onTimelineUpdate: (List) -> Unit + ) : Timeline.Listener by wrappedListener { + + override fun onTimelineUpdated(snapshot: List) { + val filteredEvents = if (shouldFilterTypes) { + snapshot.filter { + allowedTypes.contains(it.root.getClearType()) + } + } else { + snapshot + } + onTimelineUpdate(filteredEvents) + } + } + + override fun addListener(listener: Timeline.Listener): Boolean { + val mainTimelineListener = ListenerInterceptor( + timeline = mainTimeline, + wrappedListener = listener, + shouldFilterTypes = false, + allowedTypes = emptyList()) { + processTimelineUpdates(::mainIsInit, mainTimelineEvents, it) + } + val secondaryTimelineListener = ListenerInterceptor( + timeline = secondaryTimeline, + wrappedListener = listener, + shouldFilterTypes = secondaryTimelineParams.shouldFilterTypes, + allowedTypes = secondaryTimelineParams.allowedTypes) { + processTimelineUpdates(::secondaryIsInit, secondaryTimelineEvents, it) + } + listenersMapping[listener] = listOf(mainTimelineListener, secondaryTimelineListener) + return mainTimeline.addListener(mainTimelineListener) && secondaryTimeline.addListener(secondaryTimelineListener) + } + + override fun removeListener(listener: Timeline.Listener): Boolean { + return listenersMapping.remove(listener)?.let { + it.forEach { listener -> + listener.timeline?.removeListener(listener) + listener.timeline = null + } + true + } ?: false + } + + override fun removeAllListeners() { + mainTimeline.removeAllListeners() + secondaryTimeline.removeAllListeners() + } + + override fun start() { + mainTimeline.start() + secondaryTimeline.start() + } + + override fun dispose() { + mainTimeline.dispose() + secondaryTimeline.dispose() + } + + override fun restartWithEventId(eventId: String?) { + mainTimeline.restartWithEventId(eventId) + } + + override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { + return mainTimeline.hasMoreToLoad(direction) || secondaryTimeline.hasMoreToLoad(direction) + } + + override fun paginate(direction: Timeline.Direction, count: Int) { + mainTimeline.paginate(direction, count) + secondaryTimeline.paginate(direction, count) + } + + override fun pendingEventCount(): Int { + return mainTimeline.pendingEventCount() + secondaryTimeline.pendingEventCount() + } + + override fun failedToDeliverEventCount(): Int { + return mainTimeline.pendingEventCount() + secondaryTimeline.pendingEventCount() + } + + override fun getTimelineEventAtIndex(index: Int): TimelineEvent? { + return mergedEvents.getOrNull(index) + } + + override fun getIndexOfEvent(eventId: String?): Int? { + return positionsMapping[eventId] + } + + override fun getTimelineEventWithId(eventId: String?): TimelineEvent? { + return positionsMapping[eventId]?.let { + getTimelineEventAtIndex(it) + } + } + + private fun processTimelineUpdates(isInit: KMutableProperty0, eventsRef: MutableList, newData: List) { + coroutineScope.launch(Dispatchers.Default) { + processingSemaphore.withPermit { + isInit.set(true) + eventsRef.apply { + clear() + addAll(newData) + } + mergeTimeline() + } + } + } + + private suspend fun mergeTimeline() { + val merged = mutableListOf() + val mainItr = mainTimelineEvents.toList().listIterator() + val secondaryItr = secondaryTimelineEvents.toList().listIterator() + var index = 0 + var correctedSenderInfo: SenderInfo? = mainTimelineEvents.firstOrNull()?.senderInfo + if (!mainIsInit || !secondaryIsInit) { + return + } + while (merged.size < mainTimelineEvents.size + secondaryTimelineEvents.size) { + if (mainItr.hasNext()) { + val nextMain = mainItr.next() + correctedSenderInfo = nextMain.senderInfo + if (secondaryItr.hasNext()) { + val nextSecondary = secondaryItr.next() + if (nextSecondary.root.originServerTs ?: 0 > nextMain.root.originServerTs ?: 0) { + positionsMapping[nextSecondary.eventId] = index + merged.add(nextSecondary.correctBeforeMerging(correctedSenderInfo)) + mainItr.previous() + } else { + positionsMapping[nextMain.eventId] = index + merged.add(nextMain) + secondaryItr.previous() + } + } else { + positionsMapping[nextMain.eventId] = index + merged.add(nextMain) + } + } else if (secondaryItr.hasNext()) { + val nextSecondary = secondaryItr.next() + positionsMapping[nextSecondary.eventId] = index + merged.add(nextSecondary.correctBeforeMerging(correctedSenderInfo)) + } + index++ + } + mergedEvents.apply { + clear() + addAll(merged) + } + withContext(Dispatchers.Main) { + listenersMapping.keys.forEach { listener -> + tryOrNull { listener.onTimelineUpdated(merged) } + } + } + } + + private fun TimelineEvent.correctBeforeMerging(correctedSenderInfo: SenderInfo?): TimelineEvent { + return copy( + senderInfo = correctedSenderInfo ?: senderInfo, + readReceipts = if (secondaryTimelineParams.disableReadReceipts) emptyList() else readReceipts + ) + } +} diff --git a/vector/src/main/res/layout/item_timeline_event_call_tile_stub.xml b/vector/src/main/res/layout/item_timeline_event_call_tile_stub.xml index d57acd93f9d..79e260f6050 100644 --- a/vector/src/main/res/layout/item_timeline_event_call_tile_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_call_tile_stub.xml @@ -53,7 +53,7 @@ android:layout_marginTop="12dp" android:layout_marginEnd="8dp" android:layout_marginBottom="12dp" - android:textColor="?vctr_notice_secondary" + android:textColor="?vctr_content_secondary" tools:text="@string/video_call_in_progress" />