From 24de6c0101a7dde94c10d1150b89c6b65617c985 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 3 Dec 2020 19:39:01 +0100 Subject: [PATCH] VoIP: add tiles for call events --- .../im/vector/app/core/extensions/TextView.kt | 16 ++ .../timeline/TimelineEventController.kt | 19 ++- .../timeline/factory/CallItemFactory.kt | 151 +++++++++++++++++ .../timeline/factory/TimelineItemFactory.kt | 19 ++- .../helper/TimelineDisplayableEvents.kt | 1 + .../timeline/item/CallTileTimelineItem.kt | 157 ++++++++++++++++++ .../main/res/drawable/ic_call_audio_small.xml | 9 + .../res/drawable/ic_call_conference_small.xml | 14 ++ .../main/res/drawable/ic_call_video_small.xml | 12 ++ .../layout/item_timeline_event_base_state.xml | 8 +- .../item_timeline_event_call_tile_stub.xml | 91 ++++++++++ vector/src/main/res/values/strings.xml | 7 + vector/src/main/res/values/styles_riot.xml | 2 + 13 files changed, 494 insertions(+), 12 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt create mode 100644 vector/src/main/res/drawable/ic_call_audio_small.xml create mode 100644 vector/src/main/res/drawable/ic_call_conference_small.xml create mode 100644 vector/src/main/res/drawable/ic_call_video_small.xml create mode 100644 vector/src/main/res/layout/item_timeline_event_call_tile_stub.xml diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt index 44b85df93a..28524f6a91 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt @@ -22,7 +22,11 @@ import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan import android.widget.TextView import androidx.annotation.AttrRes +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar import im.vector.app.R @@ -71,6 +75,18 @@ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int, } } +fun TextView.setLeftDrawable(@DrawableRes iconRes: Int, @ColorRes tintColor: Int? = null) { + val icon = if(tintColor != null){ + val tint = ContextCompat.getColor(context, tintColor) + ContextCompat.getDrawable(context, iconRes)?.also { + DrawableCompat.setTint(it, tint) + } + }else { + ContextCompat.getDrawable(context, iconRes) + } + setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) +} + /** * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar */ 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 bddc7fa126..20fbe52731 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 @@ -43,6 +43,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisi import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem +import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData @@ -184,10 +185,22 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec override fun intercept(models: MutableList>) = synchronized(modelCache) { positionOfReadMarker = null adapterPositionMapping.clear() - models.forEachIndexed { index, epoxyModel -> + val callIds = mutableSetOf() + val modelsIterator = models.listIterator() + modelsIterator.withIndex().forEach { + val index = it.index + val epoxyModel = it.value + if (epoxyModel is CallTileTimelineItem) { + val callId = epoxyModel.attributes.callId + if (callIds.contains(callId)) { + modelsIterator.remove() + return@forEach + } + callIds.add(callId) + } if (epoxyModel is BaseEventItem) { - epoxyModel.getEventIds().forEach { - adapterPositionMapping[it] = index + epoxyModel.getEventIds().forEach { eventId -> + adapterPositionMapping[eventId] = index } } } 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 new file mode 100644 index 0000000000..36acf5d766 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2019 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.core.epoxy.VectorEpoxyModel +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.RoomSummaryHolder +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.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.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.CallSignallingContent +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 messageColorProvider: MessageColorProvider, + private val messageInformationDataFactory: MessageInformationDataFactory, + private val messageItemAttributesFactory: MessageItemAttributesFactory, + private val avatarSizeProvider: AvatarSizeProvider, + private val roomSummaryHolder: RoomSummaryHolder, + private val callManager: WebRtcCallManager +) { + + fun create(event: TimelineEvent, + highlight: Boolean, + callback: TimelineEventController.Callback? + ): VectorEpoxyModel<*>? { + if (event.root.eventId == null) return null + val informationData = messageInformationDataFactory.create(event, null) + val callSignalingContent = event.getCallSignallingContent() ?: return null + val callId = callSignalingContent.callId ?: return null + val call = callManager.getCallById(callId) + val callKind = if (call?.mxCall?.isVideoCall.orFalse()) { + CallTileTimelineItem.CallKind.VIDEO + } else { + CallTileTimelineItem.CallKind.AUDIO + } + return when (event.root.getClearType()) { + EventType.CALL_ANSWER -> { + if (call == null) return null + createCallTileTimelineItem( + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.IN_CALL, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData + ) + } + EventType.CALL_INVITE -> { + if (call == null) return null + createCallTileTimelineItem( + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.INVITED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData + ) + } + EventType.CALL_REJECT -> { + createCallTileTimelineItem( + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.REJECTED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData + ) + } + EventType.CALL_HANGUP -> { + createCallTileTimelineItem( + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.ENDED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData + ) + } + else -> null + } + } + + private fun TimelineEvent.getCallSignallingContent(): CallSignallingContent? { + return when (root.getClearType()) { + EventType.CALL_INVITE -> root.getClearContent().toModel() + EventType.CALL_HANGUP -> root.getClearContent().toModel() + EventType.CALL_REJECT -> root.getClearContent().toModel() + EventType.CALL_ANSWER -> root.getClearContent().toModel() + else -> null + } + } + + private fun createCallTileTimelineItem( + callId: String, + callKind: CallTileTimelineItem.CallKind, + callStatus: CallTileTimelineItem.CallStatus, + informationData: MessageInformationData, + highlight: Boolean, + callback: TimelineEventController.Callback? + ): CallTileTimelineItem? { + + val userOfInterest = roomSummaryHolder.roomSummary?.toMatrixItem() ?: return null + val attributes = messageItemAttributesFactory.create(null, informationData, callback).let { + CallTileTimelineItem.Attributes( + callId = callId, + callKind = callKind, + callStatus = callStatus, + informationData = informationData, + avatarRenderer = it.avatarRenderer, + messageColorProvider = messageColorProvider, + itemClickListener = it.itemClickListener, + itemLongClickListener = it.itemLongClickListener, + reactionPillCallback = it.reactionPillCallback, + readReceiptsCallback = it.readReceiptsCallback, + userOfInterest = userOfInterest + ) + } + return CallTileTimelineItem_() + .attributes(attributes) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 243cbbd0e6..4e3e6b84a1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -34,6 +34,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me private val roomCreateItemFactory: RoomCreateItemFactory, private val roomSummaryHolder: RoomSummaryHolder, private val verificationConclusionItemFactory: VerificationItemFactory, + private val callItemFactory: CallItemFactory, private val userPreferencesProvider: UserPreferencesProvider) { fun create(event: TimelineEvent, @@ -45,7 +46,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me val computedModel = try { when (event.root.getClearType()) { EventType.STICKER, - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -60,17 +61,19 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.STATE_ROOM_GUEST_ACCESS, EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET, - EventType.CALL_INVITE, - EventType.CALL_HANGUP, - EventType.CALL_ANSWER, EventType.STATE_ROOM_POWER_LEVELS, EventType.REACTION, - EventType.REDACTION -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + EventType.REDACTION -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback) // State room create - EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) + EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) + // Calls + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.CALL_REJECT, + EventType.CALL_ANSWER -> callItemFactory.create(event, highlight, callback) // Crypto - EventType.ENCRYPTED -> { + EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it messageItemFactory.create(event, nextEvent, highlight, callback) @@ -84,7 +87,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_MAC, - EventType.CALL_CANDIDATES -> { + EventType.CALL_CANDIDATES -> { // TODO These are not filtered out by timeline when encrypted // For now manually ignore if (userPreferencesProvider.shouldShowHiddenEvents()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 4fcac6c7f7..eb5b8081f9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -38,6 +38,7 @@ object TimelineDisplayableEvents { EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_ANSWER, + EventType.CALL_REJECT, EventType.ENCRYPTED, EventType.STATE_ROOM_ENCRYPTION, EventType.STATE_ROOM_GUEST_ACCESS, 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 new file mode 100644 index 0000000000..85f093bfec --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2019 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.item + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.extensions.setLeftDrawable +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.extensions.setTextWithColoredPart +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.TimelineEventController +import org.matrix.android.sdk.api.util.MatrixItem +import timber.log.Timber + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_state) +abstract class CallTileTimelineItem : AbsBaseMessageItem() { + + override val baseAttributes: AbsBaseMessageItem.Attributes + get() = attributes + + @EpoxyAttribute + lateinit var attributes: Attributes + + override fun getViewType() = STUB_ID + + override fun bind(holder: Holder) { + super.bind(holder) + holder.endGuideline.updateLayoutParams { + this.marginEnd = leftGuideline + } + + holder.creatorNameView.text = attributes.userOfInterest.getBestName() + attributes.avatarRenderer.render(attributes.userOfInterest, holder.creatorAvatarView) + holder.callKindView.setText(attributes.callKind.title) + holder.callKindView.setLeftDrawable(attributes.callKind.icon) + if (attributes.callStatus == CallStatus.INVITED && !attributes.informationData.sentByMe) { + holder.acceptRejectViewGroup.isVisible = true + holder.acceptView.setOnClickListener { + Timber.v("On accept call: $attributes.callId ") + } + holder.rejectView.setLeftDrawable(R.drawable.ic_call_hangup, R.color.riotx_notice) + holder.rejectView.setOnClickListener { + Timber.v("On reject call: $attributes.callId") + } + holder.statusView.isVisible = false + when (attributes.callKind) { + CallKind.CONFERENCE -> { + holder.rejectView.setText(R.string.ignore) + holder.acceptView.setText(R.string.join) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_audio_small, R.color.riotx_accent) + } + CallKind.AUDIO -> { + holder.rejectView.setText(R.string.call_notification_reject) + holder.acceptView.setText(R.string.call_notification_answer) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_audio_small, R.color.riotx_accent) + } + CallKind.VIDEO -> { + holder.rejectView.setText(R.string.call_notification_reject) + holder.acceptView.setText(R.string.call_notification_answer) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_video_small, R.color.riotx_accent) + } + } + } else { + holder.acceptRejectViewGroup.isVisible = false + holder.statusView.isVisible = true + } + holder.statusView.setCallStatus(attributes) + renderSendState(holder.view, null, holder.failedToSendIndicator) + } + + private fun TextView.setCallStatus(attributes: Attributes) { + when (attributes.callStatus) { + CallStatus.INVITED -> if (attributes.informationData.sentByMe) { + setText(R.string.call_tile_you_started_call) + } + CallStatus.IN_CALL -> setText(R.string.call_tile_in_call) + CallStatus.REJECTED -> if (attributes.informationData.sentByMe) { + setTextWithColoredPart(R.string.call_tile_you_declined, R.string.call_tile_call_back) + } else { + text = context.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName()) + } + CallStatus.ENDED -> setText(R.string.call_tile_ended) + } + } + + class Holder : AbsBaseMessageItem.Holder(STUB_ID) { + val acceptView by bind