From f405532e4c27cfa858fba3910d7d39f17bd8f3b5 Mon Sep 17 00:00:00 2001
From: ganfra <francoisg@element.io>
Date: Thu, 29 Jul 2021 12:06:38 +0200
Subject: [PATCH] Introduces CallEventGrouper so we can manage properly call
 history

---
 .../timeline/TimelineEventController.kt       | 51 +++++-----
 .../timeline/factory/CallItemFactory.kt       | 92 ++++++++-----------
 .../factory/TimelineItemFactoryParams.kt      |  4 +-
 .../timeline/helper/CallEventGrouper.kt       | 64 +++++++++++++
 .../timeline/item/CallTileTimelineItem.kt     | 21 ++++-
 5 files changed, 156 insertions(+), 76 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/CallEventGrouper.kt

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
index 6c75bb1e63..0317d95b80 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
@@ -41,6 +41,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.MergedHeaderItem
 import im.vector.app.features.home.room.detail.timeline.factory.ReadReceiptsItemFactory
 import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory
 import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams
+import im.vector.app.features.home.room.detail.timeline.helper.CallEventGrouper
 import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
 import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
 import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper
@@ -48,7 +49,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiff
 import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
 import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
 import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
-import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
 import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
 import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem
 import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_
@@ -56,7 +56,6 @@ import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents
 import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
 import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
 import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
-import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
 import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
 import im.vector.app.features.media.ImageContentRenderer
 import im.vector.app.features.media.VideoContentRenderer
@@ -164,6 +163,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
 
     // Map eventId to adapter position
     private val adapterPositionMapping = HashMap<String, Int>()
+    private val callEventGroupers = HashMap<String, CallEventGrouper>()
+    private val receiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()
     private val modelCache = arrayListOf<CacheItemData?>()
     private var currentSnapshot: List<TimelineEvent> = emptyList()
     private var inSubmitList: Boolean = false
@@ -353,8 +354,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
         if (modelCache.isEmpty()) {
             return
         }
-        val receiptsByEvents = getReadReceiptsByShownEvent()
-        val lastSentEventWithoutReadReceipts = searchLastSentEventWithoutReadReceipts(receiptsByEvents)
+        preprocessReverseEvents()
+        val lastSentEventWithoutReadReceipts = searchLastSentEventWithoutReadReceipts(receiptsByEvent)
         (0 until modelCache.size).forEach { position ->
             val event = currentSnapshot[position]
             val nextEvent = currentSnapshot.nextOrNull(position)
@@ -362,22 +363,28 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
             val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull {
                 timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId)
             }
-            val params = TimelineItemFactoryParams(
-                    event = event,
-                    prevEvent = prevEvent,
-                    nextEvent = nextEvent,
-                    nextDisplayableEvent = nextDisplayableEvent,
-                    partialState = partialState,
-                    lastSentEventIdWithoutReadReceipts = lastSentEventWithoutReadReceipts,
-                    callback = callback
-            )
             // Should be build if not cached or if model should be refreshed
             if (modelCache[position] == null || modelCache[position]?.isCacheable == false) {
+                val callEventGrouper = if (EventType.isCallEvent(event.root.getClearType())) {
+                    (event.root.getClearContent()?.get("call_id") as? String)?.let { callId -> callEventGroupers[callId] }
+                } else {
+                    null
+                }
+                val params = TimelineItemFactoryParams(
+                        event = event,
+                        prevEvent = prevEvent,
+                        nextEvent = nextEvent,
+                        nextDisplayableEvent = nextDisplayableEvent,
+                        partialState = partialState,
+                        lastSentEventIdWithoutReadReceipts = lastSentEventWithoutReadReceipts,
+                        callback = callback,
+                        callEventGrouper = callEventGrouper
+                )
                 modelCache[position] = buildCacheItem(params)
             }
             val itemCachedData = modelCache[position] ?: return@forEach
             // Then update with additional models if needed
-            modelCache[position] = itemCachedData.enrichWithModels(event, nextEvent, position, receiptsByEvents)
+            modelCache[position] = itemCachedData.enrichWithModels(event, nextEvent, position, receiptsByEvent)
         }
     }
 
@@ -450,15 +457,18 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
         return null
     }
 
-    private fun getReadReceiptsByShownEvent(): Map<String, List<ReadReceipt>> {
-        val receiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()
-        if (!userPreferencesProvider.shouldShowReadReceipts()) {
-            return receiptsByEvent
-        }
-        var lastShownEventId: String? = null
+    private fun preprocessReverseEvents() {
+        receiptsByEvent.clear()
+        callEventGroupers.clear()
         val itr = currentSnapshot.listIterator(currentSnapshot.size)
+        var lastShownEventId: String? = null
         while (itr.hasPrevious()) {
             val event = itr.previous()
+            if (EventType.isCallEvent(event.root.getClearType())) {
+                (event.root.getClearContent()?.get("call_id") as? String)?.also { callId ->
+                    callEventGroupers.getOrPut(callId) { CallEventGrouper(session.myUserId, callId) }.add(event)
+                }
+            }
             val currentReadReceipts = ArrayList(event.readReceipts).filter {
                 it.user.userId != session.myUserId
             }
@@ -471,7 +481,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
             val existingReceipts = receiptsByEvent.getOrPut(lastShownEventId) { ArrayList() }
             existingReceipts.addAll(currentReadReceipts)
         }
-        return receiptsByEvent
     }
 
     private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem {
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 9697fb6672..189db25730 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,81 +16,79 @@
 package im.vector.app.features.home.room.detail.timeline.factory
 
 import im.vector.app.core.epoxy.VectorEpoxyModel
+import im.vector.app.core.resources.UserPreferencesProvider
 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
 import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
 import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
 import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
 import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
+import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
 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
-import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
-import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
-import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
-import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
-import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import org.matrix.android.sdk.api.util.toMatrixItem
 import javax.inject.Inject
 
 class CallItemFactory @Inject constructor(
         private val session: Session,
+        private val userPreferencesProvider: UserPreferencesProvider,
+        private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper,
         private val messageColorProvider: MessageColorProvider,
         private val messageInformationDataFactory: MessageInformationDataFactory,
         private val messageItemAttributesFactory: MessageItemAttributesFactory,
         private val avatarSizeProvider: AvatarSizeProvider,
-        private val roomSummariesHolder: RoomSummariesHolder,
-        private val callManager: WebRtcCallManager
-) {
+        private val roomSummariesHolder: RoomSummariesHolder) {
 
     fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
         val event = params.event
         if (event.root.eventId == null) return null
+        val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()
+        val callEventGrouper = params.callEventGrouper ?: return null
         val roomId = event.roomId
         val informationData = messageInformationDataFactory.create(params)
-        val callSignalingContent = event.getCallSignalingContent() ?: return null
-        val callId = callSignalingContent.callId ?: return null
-        val call = callManager.getCallById(callId)
-        val callKind = when {
-            call == null            -> CallTileTimelineItem.CallKind.UNKNOWN
-            call.mxCall.isVideoCall -> CallTileTimelineItem.CallKind.VIDEO
-            else                    -> CallTileTimelineItem.CallKind.AUDIO
-        }
+        val callKind = if (callEventGrouper.isVideo()) CallTileTimelineItem.CallKind.VIDEO else CallTileTimelineItem.CallKind.AUDIO
+        val isRinging = callEventGrouper.isRinging()
         return when (event.root.getClearType()) {
             EventType.CALL_ANSWER -> {
-                createCallTileTimelineItem(
-                        roomId = roomId,
-                        callId = callId,
-                        callStatus = CallTileTimelineItem.CallStatus.IN_CALL,
-                        callKind = callKind,
-                        callback = params.callback,
-                        highlight = params.isHighlighted,
-                        informationData = informationData,
-                        isStillActive = call != null
-                )
+                if (isRinging || showHiddenEvents) {
+                    createCallTileTimelineItem(
+                            roomId = roomId,
+                            callId = callEventGrouper.callId,
+                            callStatus = CallTileTimelineItem.CallStatus.IN_CALL,
+                            callKind = callKind,
+                            callback = params.callback,
+                            highlight = params.isHighlighted,
+                            informationData = informationData,
+                            isStillActive = isRinging
+                    )
+                } else {
+                    null
+                }
             }
             EventType.CALL_INVITE -> {
-                createCallTileTimelineItem(
-                        roomId = roomId,
-                        callId = callId,
-                        callStatus = CallTileTimelineItem.CallStatus.INVITED,
-                        callKind = callKind,
-                        callback = params.callback,
-                        highlight = params.isHighlighted,
-                        informationData = informationData,
-                        isStillActive = call != null
-                )
+                if (isRinging || showHiddenEvents) {
+                    createCallTileTimelineItem(
+                            roomId = roomId,
+                            callId = callEventGrouper.callId,
+                            callStatus = CallTileTimelineItem.CallStatus.INVITED,
+                            callKind = callKind,
+                            callback = params.callback,
+                            highlight = params.isHighlighted,
+                            informationData = informationData,
+                            isStillActive = isRinging
+                    )
+                } else {
+                    null
+                }
             }
             EventType.CALL_REJECT -> {
                 createCallTileTimelineItem(
                         roomId = roomId,
-                        callId = callId,
+                        callId = callEventGrouper.callId,
                         callStatus = CallTileTimelineItem.CallStatus.REJECTED,
                         callKind = callKind,
                         callback = params.callback,
@@ -102,8 +100,8 @@ class CallItemFactory @Inject constructor(
             EventType.CALL_HANGUP -> {
                 createCallTileTimelineItem(
                         roomId = roomId,
-                        callId = callId,
-                        callStatus = CallTileTimelineItem.CallStatus.ENDED,
+                        callId = callEventGrouper.callId,
+                        callStatus = if (callEventGrouper.callWasMissed()) CallTileTimelineItem.CallStatus.MISSED else CallTileTimelineItem.CallStatus.ENDED,
                         callKind = callKind,
                         callback = params.callback,
                         highlight = params.isHighlighted,
@@ -115,16 +113,6 @@ class CallItemFactory @Inject constructor(
         }
     }
 
-    private fun TimelineEvent.getCallSignalingContent(): CallSignalingContent? {
-        return when (root.getClearType()) {
-            EventType.CALL_INVITE -> root.getClearContent().toModel<CallInviteContent>()
-            EventType.CALL_HANGUP -> root.getClearContent().toModel<CallHangupContent>()
-            EventType.CALL_REJECT -> root.getClearContent().toModel<CallRejectContent>()
-            EventType.CALL_ANSWER -> root.getClearContent().toModel<CallAnswerContent>()
-            else                  -> null
-        }
-    }
-
     private fun createCallTileTimelineItem(
             roomId: String,
             callId: String,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt
index ea5d21dc10..e35dbee95d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt
@@ -17,6 +17,7 @@
 package im.vector.app.features.home.room.detail.timeline.factory
 
 import im.vector.app.features.home.room.detail.timeline.TimelineEventController
+import im.vector.app.features.home.room.detail.timeline.helper.CallEventGrouper
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 
 data class TimelineItemFactoryParams(
@@ -26,7 +27,8 @@ data class TimelineItemFactoryParams(
         val nextDisplayableEvent: TimelineEvent? = null,
         val partialState: TimelineEventController.PartialState = TimelineEventController.PartialState(),
         val lastSentEventIdWithoutReadReceipts: String? = null,
-        val callback: TimelineEventController.Callback? = null
+        val callback: TimelineEventController.Callback? = null,
+        val callEventGrouper: CallEventGrouper?= null
 ) {
 
     val highlightedEventId: String?
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/CallEventGrouper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/CallEventGrouper.kt
new file mode 100644
index 0000000000..f3488583f4
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/CallEventGrouper.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.helper
+
+import org.matrix.android.sdk.api.extensions.orFalse
+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.CallInviteContent
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+
+class CallEventGrouper(private val myUserId: String, val callId: String) {
+
+    private val events = HashSet<TimelineEvent>()
+
+    fun add(timelineEvent: TimelineEvent) {
+        events.add(timelineEvent)
+    }
+
+    fun isVideo(): Boolean {
+        val invite = getInvite() ?: return false
+        return invite.root.getClearContent().toModel<CallInviteContent>()?.isVideo().orFalse()
+    }
+
+    fun isRinging(): Boolean {
+        return getAnswer() == null && getHangup() == null && getReject() == null
+    }
+
+    /**
+     * Returns true if there are only events from the other side - we missed the call
+     */
+    fun callWasMissed(): Boolean {
+        return events.none { it.senderInfo.userId == myUserId }
+    }
+
+    private fun getAnswer(): TimelineEvent? {
+        return events.firstOrNull { it.root.getClearType() == EventType.CALL_ANSWER }
+    }
+
+    private fun getInvite(): TimelineEvent? {
+        return events.firstOrNull { it.root.getClearType() == EventType.CALL_INVITE }
+    }
+
+    private fun getHangup(): TimelineEvent? {
+        return events.firstOrNull { it.root.getClearType() == EventType.CALL_HANGUP }
+    }
+
+    private fun getReject(): TimelineEvent? {
+        return events.firstOrNull { it.root.getClearType() == EventType.CALL_REJECT }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt
index c59f2258f8..68ed0254ba 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt
@@ -15,6 +15,7 @@
  */
 package im.vector.app.features.home.room.detail.timeline.item
 
+import android.content.res.Resources
 import android.view.View
 import android.view.ViewGroup
 import android.widget.Button
@@ -72,10 +73,22 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
             CallStatus.IN_CALL  -> renderInCallStatus(holder)
             CallStatus.REJECTED -> renderRejectedStatus(holder)
             CallStatus.ENDED    -> renderEndedStatus(holder)
+            CallStatus.MISSED   -> renderMissedStatus(holder)
         }
         renderSendState(holder.view, null, holder.failedToSendIndicator)
     }
 
+    private fun renderMissedStatus(holder: Holder) {
+        holder.acceptRejectViewGroup.isVisible = false
+        holder.statusView.isVisible = true
+        val status = if (attributes.callKind == CallKind.VIDEO) {
+            holder.resources.getQuantityString(R.plurals.missed_video_call, 1)
+        } else {
+            holder.resources.getQuantityString(R.plurals.missed_audio_call, 1)
+        }
+        holder.statusView.text = status
+    }
+
     private fun renderEndedStatus(holder: Holder) {
         holder.acceptRejectViewGroup.isVisible = false
         holder.statusView.isVisible = true
@@ -91,7 +104,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
                 attributes.callback?.onTimelineItemAction(callbackAction)
             }
         } else {
-            holder.statusView.text = holder.view.context.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName())
+            holder.statusView.text = holder.resources.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName())
         }
     }
 
@@ -166,7 +179,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
                 if (attributes.informationData.sentByMe) {
                     holder.statusView.setText(R.string.call_tile_you_started_call)
                 } else {
-                    holder.statusView.text = holder.view.context.getString(R.string.call_tile_other_started_call, attributes.userOfInterest.getBestName())
+                    holder.statusView.text = holder.resources.getString(R.string.call_tile_other_started_call, attributes.userOfInterest.getBestName())
                 }
             }
         }
@@ -182,6 +195,9 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
         val statusView by bind<TextView>(R.id.itemCallStatusTextView)
         val endGuideline by bind<View>(R.id.messageEndGuideline)
         val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
+
+        val resources: Resources
+            get() = view.context.resources
     }
 
     companion object {
@@ -215,6 +231,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
         INVITED,
         IN_CALL,
         REJECTED,
+        MISSED,
         ENDED;
 
         fun isActive() = this == INVITED || this == IN_CALL