Refactor poll item factory to make it testable.

This commit is contained in:
Onuray Sahin 2022-06-21 17:03:56 +03:00
parent 41431cd1d2
commit bd9fa48312
3 changed files with 223 additions and 156 deletions

View file

@ -16,13 +16,8 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import dagger.Lazy import dagger.Lazy
import im.vector.app.R import im.vector.app.R
@ -35,6 +30,7 @@ import im.vector.app.core.time.Clock
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.containsOnlyEmojis import im.vector.app.core.utils.containsOnlyEmojis
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.factory.MessageItemFactoryHelper.annotateWithEdited
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
@ -57,14 +53,6 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_
import im.vector.app.features.home.room.detail.timeline.item.PollItem
import im.vector.app.features.home.room.detail.timeline.item.PollItem_
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollEnded
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollReady
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollSending
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollUndisclosed
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollVoted
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
@ -81,18 +69,11 @@ import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.toLocationData import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.poll.PollState
import im.vector.app.features.poll.PollState.Ended
import im.vector.app.features.poll.PollState.Ready
import im.vector.app.features.poll.PollState.Sending
import im.vector.app.features.poll.PollState.Undisclosed
import im.vector.app.features.poll.PollState.Voted
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.AudioWaveformView import im.vector.app.features.voice.AudioWaveformView
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span import me.gujun.android.span.span
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.api.session.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
@ -113,8 +94,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
@ -149,6 +128,7 @@ class MessageItemFactory @Inject constructor(
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider, private val urlMapProvider: UrlMapProvider,
private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory, private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
private val pollItemFactory: PollItemFactory,
) { ) {
// TODO inject this properly? // TODO inject this properly?
@ -208,7 +188,7 @@ class MessageItemFactory @Inject constructor(
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes) is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> buildAudioContent(params, messageContent, informationData, highlight, attributes) is MessageAudioContent -> buildAudioContent(params, messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessagePollContent -> pollItemFactory.create(messageContent, informationData, highlight, callback, attributes)
is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes)
is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
@ -244,93 +224,6 @@ class MessageItemFactory @Inject constructor(
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
} }
private fun buildPollItem(
pollContent: MessagePollContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): PollItem {
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val pollState = createPollState(informationData, pollResponseSummary, pollContent)
val pollCreationInfo = pollContent.getBestPollCreationInfo()
val questionText = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val question = createPollQuestion(informationData, questionText, callback)
val optionViewStates = pollCreationInfo?.answers?.mapToOptions(pollState, informationData)
val totalVotesText = createTotalVotesText(pollState, pollResponseSummary)
return PollItem_()
.attributes(attributes)
.eventId(informationData.eventId)
.pollQuestion(question)
.canVote(pollState.isVotable())
.totalVotesText(totalVotesText)
.optionViewStates(optionViewStates)
.edited(informationData.hasBeenEdited)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
private fun createPollState(
informationData: MessageInformationData,
pollResponseSummary: PollResponseData?,
pollContent: MessagePollContent,
): PollState = when {
!informationData.sendState.isSent() -> Sending
pollResponseSummary?.isClosed.orFalse() -> Ended
pollContent.getBestPollCreationInfo()?.kind == PollType.UNDISCLOSED -> Undisclosed
pollResponseSummary?.myVote?.isNotEmpty().orFalse() -> Voted(pollResponseSummary?.totalVotes ?: 0)
else -> Ready
}
private fun List<PollAnswer>.mapToOptions(
pollState: PollState,
informationData: MessageInformationData,
) = map { answer ->
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val optionId = answer.id ?: ""
val optionAnswer = answer.getBestAnswer() ?: ""
val voteSummary = pollResponseSummary?.votes?.get(answer.id)
val voteCount = voteSummary?.total ?: 0
val votePercentage = voteSummary?.percentage ?: 0.0
val isMyVote = pollResponseSummary?.myVote == answer.id
val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount
when (pollState) {
Sending -> PollSending(optionId, optionAnswer)
Ready -> PollReady(optionId, optionAnswer)
is Voted -> PollVoted(optionId, optionAnswer, voteCount, votePercentage, isMyVote)
Undisclosed -> PollUndisclosed(optionId, optionAnswer, isMyVote)
Ended -> PollEnded(optionId, optionAnswer, voteCount, votePercentage, isWinner)
}
}
private fun createPollQuestion(
informationData: MessageInformationData,
question: String,
callback: TimelineEventController.Callback?,
) = if (informationData.hasBeenEdited) {
annotateWithEdited(question, callback, informationData)
} else {
question
}.toEpoxyCharSequence()
private fun createTotalVotesText(
pollState: PollState,
pollResponseSummary: PollResponseData?,
): String {
val votes = pollResponseSummary?.totalVotes ?: 0
return when {
pollState is Ended -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, votes, votes)
pollState is Undisclosed -> ""
pollState is Voted -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, votes, votes)
votes == 0 -> stringProvider.getString(R.string.poll_no_votes_cast)
else -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, votes, votes)
}
}
private fun buildAudioMessageItem( private fun buildAudioMessageItem(
params: TimelineItemFactoryParams, params: TimelineItemFactoryParams,
messageContent: MessageAudioContent, messageContent: MessageAudioContent,
@ -627,7 +520,7 @@ class MessageItemFactory @Inject constructor(
return MessageTextItem_() return MessageTextItem_()
.message( .message(
if (informationData.hasBeenEdited) { if (informationData.hasBeenEdited) {
annotateWithEdited(linkifiedBody, callback, informationData) annotateWithEdited(stringProvider, colorProvider, dimensionConverter, linkifiedBody, callback, informationData)
} else { } else {
linkifiedBody linkifiedBody
}.toEpoxyCharSequence() }.toEpoxyCharSequence()
@ -645,50 +538,6 @@ class MessageItemFactory @Inject constructor(
.movementMethod(createLinkMovementMethod(callback)) .movementMethod(createLinkMovementMethod(callback))
} }
private fun annotateWithEdited(
linkifiedBody: CharSequence,
callback: TimelineEventController.Callback?,
informationData: MessageInformationData,
): Spannable {
val spannable = SpannableStringBuilder()
spannable.append(linkifiedBody)
val editedSuffix = stringProvider.getString(R.string.edited_suffix)
spannable.append(" ").append(editedSuffix)
val color = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)
val editStart = spannable.lastIndexOf(editedSuffix)
val editEnd = editStart + editedSuffix.length
spannable.setSpan(
ForegroundColorSpan(color),
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
// Note: text size is set to 14sp
spannable.setSpan(
AbsoluteSizeSpan(dimensionConverter.spToPx(13)),
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
spannable.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
callback?.onEditedDecorationClicked(informationData)
}
override fun updateDrawState(ds: TextPaint) {
// nop
}
},
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
return spannable
}
private fun buildNoticeMessageItem( private fun buildNoticeMessageItem(
messageContent: MessageNoticeContent, messageContent: MessageNoticeContent,
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
@ -735,7 +584,7 @@ class MessageItemFactory @Inject constructor(
return MessageTextItem_() return MessageTextItem_()
.message( .message(
if (informationData.hasBeenEdited) { if (informationData.hasBeenEdited) {
annotateWithEdited(message, callback, informationData) annotateWithEdited(stringProvider, colorProvider, dimensionConverter, message, callback, informationData)
} else { } else {
message message
}.toEpoxyCharSequence() }.toEpoxyCharSequence()

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2022 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 android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.view.View
import im.vector.app.R
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
object MessageItemFactoryHelper {
fun annotateWithEdited(
stringProvider: StringProvider,
colorProvider: ColorProvider,
dimensionConverter: DimensionConverter,
linkifiedBody: CharSequence,
callback: TimelineEventController.Callback?,
informationData: MessageInformationData,
): Spannable {
val spannable = SpannableStringBuilder()
spannable.append(linkifiedBody)
val editedSuffix = stringProvider.getString(R.string.edited_suffix)
spannable.append(" ").append(editedSuffix)
val color = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)
val editStart = spannable.lastIndexOf(editedSuffix)
val editEnd = editStart + editedSuffix.length
spannable.setSpan(
ForegroundColorSpan(color),
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
// Note: text size is set to 14sp
spannable.setSpan(
AbsoluteSizeSpan(dimensionConverter.spToPx(13)),
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
spannable.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
callback?.onEditedDecorationClicked(informationData)
}
override fun updateDrawState(ds: TextPaint) {
// nop
}
},
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
return spannable
}
}

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2022 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 androidx.annotation.VisibleForTesting
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.factory.MessageItemFactoryHelper.annotateWithEdited
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollItem_
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.poll.PollState
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollType
import javax.inject.Inject
class PollItemFactory @Inject constructor(
private val stringProvider: StringProvider,
private val avatarSizeProvider: AvatarSizeProvider,
private val colorProvider: ColorProvider,
private val dimensionConverter: DimensionConverter,
) {
fun create(
pollContent: MessagePollContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): VectorEpoxyModel<*>? {
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val pollState = createPollState(informationData, pollResponseSummary, pollContent)
val pollCreationInfo = pollContent.getBestPollCreationInfo()
val questionText = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val question = createPollQuestion(informationData, questionText, callback)
val optionViewStates = pollCreationInfo?.answers?.mapToOptions(pollState, informationData)
val totalVotesText = createTotalVotesText(pollState, pollResponseSummary)
return PollItem_()
.attributes(attributes)
.eventId(informationData.eventId)
.pollQuestion(question)
.canVote(pollState.isVotable())
.totalVotesText(totalVotesText)
.optionViewStates(optionViewStates)
.edited(informationData.hasBeenEdited)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
@VisibleForTesting
private fun createPollState(
informationData: MessageInformationData,
pollResponseSummary: PollResponseData?,
pollContent: MessagePollContent,
): PollState = when {
!informationData.sendState.isSent() -> PollState.Sending
pollResponseSummary?.isClosed.orFalse() -> PollState.Ended
pollContent.getBestPollCreationInfo()?.kind == PollType.UNDISCLOSED -> PollState.Undisclosed
pollResponseSummary?.myVote?.isNotEmpty().orFalse() -> PollState.Voted(pollResponseSummary?.totalVotes ?: 0)
else -> PollState.Ready
}
@VisibleForTesting
private fun List<PollAnswer>.mapToOptions(
pollState: PollState,
informationData: MessageInformationData,
) = map { answer ->
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val optionId = answer.id ?: ""
val optionAnswer = answer.getBestAnswer() ?: ""
val voteSummary = pollResponseSummary?.votes?.get(answer.id)
val voteCount = voteSummary?.total ?: 0
val votePercentage = voteSummary?.percentage ?: 0.0
val isMyVote = pollResponseSummary?.myVote == answer.id
val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount
when (pollState) {
PollState.Sending -> PollOptionViewState.PollSending(optionId, optionAnswer)
PollState.Ready -> PollOptionViewState.PollReady(optionId, optionAnswer)
is PollState.Voted -> PollOptionViewState.PollVoted(optionId, optionAnswer, voteCount, votePercentage, isMyVote)
PollState.Undisclosed -> PollOptionViewState.PollUndisclosed(optionId, optionAnswer, isMyVote)
PollState.Ended -> PollOptionViewState.PollEnded(optionId, optionAnswer, voteCount, votePercentage, isWinner)
}
}
private fun createPollQuestion(
informationData: MessageInformationData,
question: String,
callback: TimelineEventController.Callback?,
) = if (informationData.hasBeenEdited) {
annotateWithEdited(stringProvider, colorProvider, dimensionConverter, question, callback, informationData)
} else {
question
}.toEpoxyCharSequence()
private fun createTotalVotesText(
pollState: PollState,
pollResponseSummary: PollResponseData?,
): String {
val votes = pollResponseSummary?.totalVotes ?: 0
return when {
pollState is PollState.Ended -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, votes, votes)
pollState is PollState.Undisclosed -> ""
pollState is PollState.Voted -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, votes, votes)
votes == 0 -> stringProvider.getString(R.string.poll_no_votes_cast)
else -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, votes, votes)
}
}
}