Timeline call tiles: refact grouping events and fix some issues

This commit is contained in:
ganfra 2021-08-12 11:10:00 +02:00
parent bcc9a75bdb
commit 0d56707fd3
8 changed files with 193 additions and 102 deletions

View file

@ -31,8 +31,6 @@ import im.vector.app.core.epoxy.LoadingItem_
import im.vector.app.core.extensions.localDateTime
import im.vector.app.core.extensions.nextOrNull
import im.vector.app.core.extensions.prevOrNull
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.home.room.detail.JitsiState
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.RoomDetailViewState
@ -41,7 +39,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.TimelineEventsGroups
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
@ -81,10 +79,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
private val session: Session,
private val callManager: WebRtcCallManager,
@TimelineEventControllerHandler
private val backgroundHandler: Handler,
private val userPreferencesProvider: UserPreferencesProvider,
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper,
private val readReceiptsItemFactory: ReadReceiptsItemFactory
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor {
@ -166,7 +162,7 @@ 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 timelineEventsGroups = TimelineEventsGroups()
private val receiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()
private val modelCache = arrayListOf<CacheItemData?>()
private var currentSnapshot: List<TimelineEvent> = emptyList()
@ -366,11 +362,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
// 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 timelineEventsGroup = timelineEventsGroups.getOrNull(event)
val params = TimelineItemFactoryParams(
event = event,
prevEvent = prevEvent,
@ -379,7 +371,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
partialState = partialState,
lastSentEventIdWithoutReadReceipts = lastSentEventWithoutReadReceipts,
callback = callback,
callEventGrouper = callEventGrouper
eventsGroup = timelineEventsGroup
)
modelCache[position] = buildCacheItem(params)
}
@ -460,16 +452,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private fun preprocessReverseEvents() {
receiptsByEvent.clear()
callEventGroupers.clear()
timelineEventsGroups.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)
}
}
timelineEventsGroups.addOrIgnore(event)
val currentReadReceipts = ArrayList(event.readReceipts).filter {
it.user.userId != session.myUserId
}

View file

@ -21,6 +21,7 @@ import im.vector.app.features.call.vectorCallService
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.CallSignalingEventsGroup
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
@ -45,7 +46,7 @@ class CallItemFactory @Inject constructor(
val event = params.event
if (event.root.eventId == null) return null
val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()
val callEventGrouper = params.callEventGrouper ?: return null
val callEventGrouper = params.eventsGroup?.let { CallSignalingEventsGroup(it) } ?: return null
val roomId = event.roomId
val informationData = messageInformationDataFactory.create(params)
val callKind = if (callEventGrouper.isVideo()) CallTileTimelineItem.CallKind.VIDEO else CallTileTimelineItem.CallKind.AUDIO
@ -60,7 +61,8 @@ class CallItemFactory @Inject constructor(
callback = params.callback,
highlight = params.isHighlighted,
informationData = informationData,
isStillActive = callEventGrouper.isInCall()
isStillActive = callEventGrouper.isInCall(),
formattedDuration = callEventGrouper.formattedDuration()
)
} else {
null
@ -76,7 +78,8 @@ class CallItemFactory @Inject constructor(
callback = params.callback,
highlight = params.isHighlighted,
informationData = informationData,
isStillActive = callEventGrouper.isRinging()
isStillActive = callEventGrouper.isRinging(),
formattedDuration = callEventGrouper.formattedDuration()
)
} else {
null
@ -91,7 +94,8 @@ class CallItemFactory @Inject constructor(
callback = params.callback,
highlight = params.isHighlighted,
informationData = informationData,
isStillActive = false
isStillActive = false,
formattedDuration = callEventGrouper.formattedDuration()
)
}
EventType.CALL_HANGUP -> {
@ -103,7 +107,8 @@ class CallItemFactory @Inject constructor(
callback = params.callback,
highlight = params.isHighlighted,
informationData = informationData,
isStillActive = false
isStillActive = false,
formattedDuration = callEventGrouper.formattedDuration()
)
}
else -> null
@ -118,6 +123,7 @@ class CallItemFactory @Inject constructor(
informationData: MessageInformationData,
highlight: Boolean,
isStillActive: Boolean,
formattedDuration: String,
callback: TimelineEventController.Callback?
): CallTileTimelineItem? {
val correctedRoomId = session.vectorCallService.userMapper.nativeRoomForVirtualRoom(roomId) ?: roomId
@ -129,6 +135,7 @@ class CallItemFactory @Inject constructor(
callStatus = callStatus,
informationData = informationData,
avatarRenderer = it.avatarRenderer,
formattedDuration = formattedDuration,
messageColorProvider = messageColorProvider,
itemClickListener = it.itemClickListener,
itemLongClickListener = it.itemLongClickListener,

View file

@ -17,7 +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 im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
data class TimelineItemFactoryParams(
@ -28,7 +28,7 @@ data class TimelineItemFactoryParams(
val partialState: TimelineEventController.PartialState = TimelineEventController.PartialState(),
val lastSentEventIdWithoutReadReceipts: String? = null,
val callback: TimelineEventController.Callback? = null,
val callEventGrouper: CallEventGrouper?= null
val eventsGroup: TimelineEventsGroup? = null
) {
val highlightedEventId: String?

View file

@ -16,16 +16,16 @@
package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.ActiveSessionDataSource
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.JitsiWidgetEventsGroup
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.widgets.model.WidgetContent
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
@ -38,6 +38,7 @@ class WidgetItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider,
private val messageColorProvider: MessageColorProvider,
private val avatarRenderer: AvatarRenderer,
private val userPreferencesProvider: UserPreferencesProvider,
private val roomSummariesHolder: RoomSummariesHolder
) {
@ -58,8 +59,12 @@ class WidgetItemFactory @Inject constructor(
val event = params.event
val roomId = event.roomId
val userOfInterest = roomSummariesHolder.get(roomId)?.toMatrixItem() ?: return null
val isActive = widgetContent.isActive()
val callStatus = if (isActive && widgetContent.id == params.partialState.jitsiState.widgetId) {
val isActiveTile = widgetContent.isActive()
val jitsiWidgetEventsGroup = params.eventsGroup?.let { JitsiWidgetEventsGroup(it) } ?: return null
val isCallStillActive = jitsiWidgetEventsGroup.isStillActive()
val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()
if (isActiveTile && !isCallStillActive && !showHiddenEvents) return null
val callStatus = if (isActiveTile && widgetContent.id == params.partialState.jitsiState.widgetId) {
if (params.partialState.jitsiState.hasJoined) {
CallTileTimelineItem.CallStatus.IN_CALL
} else {
@ -68,7 +73,6 @@ class WidgetItemFactory @Inject constructor(
} else {
CallTileTimelineItem.CallStatus.ENDED
}
val fakeCallId = widgetContent.id ?: prevWidgetContent?.id ?: return null
val attributes = CallTileTimelineItem.Attributes(
callId = fakeCallId,
@ -83,7 +87,8 @@ class WidgetItemFactory @Inject constructor(
readReceiptsCallback = params.callback,
userOfInterest = userOfInterest,
callback = params.callback,
isStillActive = isActive
isStillActive = isCallStillActive,
formattedDuration = ""
)
return CallTileTimelineItem_()
.attributes(attributes)

View file

@ -1,68 +0,0 @@
/*
* 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
}
fun isInCall(): Boolean{
return getHangup() == null && getReject() == null
}
/**
* Returns true if there are only events from one side.
*/
fun callWasMissed(): Boolean {
return events.distinctBy { it.senderInfo.userId }.size == 1
}
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 }
}
}

View file

@ -0,0 +1,136 @@
/*
* 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 im.vector.app.core.utils.TextUtils
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
import org.matrix.android.sdk.api.session.widgets.model.WidgetContent
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.threeten.bp.Duration
class TimelineEventsGroup(val groupId: String) {
val events: Set<TimelineEvent>
get() = _events
private val _events = HashSet<TimelineEvent>()
fun add(timelineEvent: TimelineEvent) {
_events.add(timelineEvent)
}
}
class TimelineEventsGroups {
private val groups = HashMap<String, TimelineEventsGroup>()
fun addOrIgnore(event: TimelineEvent) {
val groupId = event.getGroupIdOrNull() ?: return
groups.getOrPut(groupId) { TimelineEventsGroup(groupId) }.add(event)
}
fun getOrNull(event: TimelineEvent): TimelineEventsGroup? {
val groupId = event.getGroupIdOrNull() ?: return null
return groups[groupId]
}
private fun TimelineEvent.getGroupIdOrNull(): String? {
val type = root.getClearType()
val content = root.getClearContent()
return if (EventType.isCallEvent(type)) {
(content?.get("call_id") as? String)
} else {
val widgetContent: WidgetContent = root.getClearContent().toModel() ?: return null
val isJitsi = WidgetType.fromString(widgetContent.type ?: "") == WidgetType.Jitsi
if (isJitsi) {
widgetContent.id
} else {
null
}
}
}
fun clear() {
groups.clear()
}
}
class JitsiWidgetEventsGroup(private val group: TimelineEventsGroup) {
fun isStillActive(): Boolean {
return group.events.none {
it.root.getClearContent().toModel<WidgetContent>()?.isActive() == false
}
}
}
class CallSignalingEventsGroup(private val group: TimelineEventsGroup) {
val callId: String = group.groupId
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
}
fun isInCall(): Boolean {
return getHangup() == null && getReject() == null
}
fun formattedDuration(): String {
val start = getAnswer()?.root?.originServerTs
val end = getHangup()?.root?.originServerTs
return if (start == null || end == null) {
""
} else {
val durationInMillis = (end - start).coerceAtLeast(0L)
val duration = Duration.ofMillis(durationInMillis)
TextUtils.formatDuration(duration)
}
}
/**
* Returns true if there are only events from one side.
*/
fun callWasMissed(): Boolean {
return group.events.distinctBy { it.senderInfo.userId }.size == 1
}
private fun getAnswer(): TimelineEvent? {
return group.events.firstOrNull { it.root.getClearType() == EventType.CALL_ANSWER }
}
private fun getInvite(): TimelineEvent? {
return group.events.firstOrNull { it.root.getClearType() == EventType.CALL_INVITE }
}
private fun getHangup(): TimelineEvent? {
return group.events.firstOrNull { it.root.getClearType() == EventType.CALL_HANGUP }
}
private fun getReject(): TimelineEvent? {
return group.events.firstOrNull { it.root.getClearType() == EventType.CALL_REJECT }
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.content.res.Resources
import android.telecom.Call
import android.view.View
import android.view.ViewGroup
import android.widget.Button
@ -95,7 +96,19 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
private fun renderEndedStatus(holder: Holder) {
holder.acceptRejectViewGroup.isVisible = false
holder.statusView.setStatus(R.string.call_tile_ended)
when (attributes.callKind) {
CallKind.VIDEO -> {
val endCallStatus = holder.resources.getString(R.string.call_tile_video_call_has_ended, attributes.formattedDuration)
holder.statusView.setStatus(endCallStatus)
}
CallKind.AUDIO -> {
val endCallStatus = holder.resources.getString(R.string.call_tile_voice_call_has_ended, attributes.formattedDuration)
holder.statusView.setStatus(endCallStatus)
}
CallKind.CONFERENCE -> {
holder.statusView.setStatus(R.string.call_tile_ended)
}
}
}
private fun renderRejectedStatus(holder: Holder) {
@ -194,6 +207,10 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
}
}
when {
// Invite state for conference should show as InCallStatus
attributes.callKind == CallKind.CONFERENCE -> {
holder.statusView.setStatus(R.string.call_tile_video_active)
}
attributes.informationData.sentByMe -> {
holder.statusView.setStatus(R.string.call_ringing)
}
@ -207,8 +224,13 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
}
private fun TextView.setStatus(@StringRes statusRes: Int, @DrawableRes drawableRes: Int? = null) {
val status = resources.getString(statusRes)
setStatus(status, drawableRes)
}
private fun TextView.setStatus(status: String, @DrawableRes drawableRes: Int? = null) {
setLeftDrawable(drawableRes ?: attributes.callKind.icon)
setText(statusRes)
text = status
}
class Holder : AbsBaseMessageItem.Holder(STUB_ID) {
@ -235,6 +257,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
val callStatus: CallStatus,
val userOfInterest: MatrixItem,
val isStillActive: Boolean,
val formattedDuration: String,
val callback: TimelineEventController.Callback? = null,
override val informationData: MessageInformationData,
override val avatarRenderer: AvatarRenderer,

View file

@ -20,7 +20,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="4dp"
android:layout_marginTop="8dp"
android:drawablePadding="6dp"
android:gravity="center"
android:textColor="?vctr_content_primary"