Add support for shade and arbitrary overlay drawables to CIV2 Media items.

This commit is contained in:
Alex Hart 2023-09-01 11:30:47 -03:00 committed by Nicholas Tinsley
parent 21b0a4d370
commit bc1c8032c1
10 changed files with 284 additions and 160 deletions

View file

@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.ui.payment.PaymentMessageView;
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationBodyUtil;
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.MessageTable;
@ -1529,7 +1529,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void linkifyMessageBody(@NonNull Spannable messageBody,
boolean shouldLinkifyAllLinks)
{
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
V2ConversationItemUtils.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
if (conversationMessage.hasStyleLinks()) {
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {

View file

@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.widget.ImageView
import android.widget.Space
import org.thoughtcrime.securesms.components.QuoteView
import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaIncomingBinding
@ -20,7 +19,7 @@ import org.thoughtcrime.securesms.util.views.Stub
*/
data class V2ConversationItemMediaBindingBridge(
val textBridge: V2ConversationItemTextOnlyBindingBridge,
val thumbnailStub: Stub<ImageView>,
val thumbnailStub: Stub<V2ConversationItemThumbnail>,
val quoteStub: Stub<QuoteView>,
val bodyContentSpacer: Space
)

View file

@ -5,20 +5,12 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import androidx.core.view.updateLayoutParams
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.transition.Transition
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.QuoteView
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.changeConstraints
@ -37,10 +29,6 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
V2FooterPositionDelegate(binding)
) {
private var thumbnailSlide: Slide? = null
private var placeholderTarget: PlaceholderTarget? = null
private val thumbnailSize = intArrayOf(0, 0)
init {
binding.textBridge.conversationItemBodyWrapper.clipToOutline = true
}
@ -64,7 +52,7 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
binding.textBridge.root.changeConstraints {
val maxBodyWidth = if (hasThumbnail()) {
thumbnailSize[0]
binding.thumbnailStub.get().thumbWidth
} else {
0
}
@ -128,125 +116,12 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
}
private fun presentThumbnail() {
val slideDeck = requireMediaMessage().slideDeck
if (slideDeck.thumbnailSlides.isEmpty() || slideDeck.thumbnailSlides.size > 1) {
binding.thumbnailStub.visibility = View.GONE
thumbnailSize[0] = -1
thumbnailSize[1] = -1
return
if (binding.thumbnailStub.resolved() || requireMediaMessage().slideDeck.thumbnailSlides.size == 1) {
binding.thumbnailStub.get().presentThumbnail(
mediaMessage = requireMediaMessage(),
conversationContext = conversationContext
)
}
binding.thumbnailStub.visibility = View.VISIBLE
val thumbnail = slideDeck.thumbnailSlides.first()
// TODO [alex] -- Is this correct?
if (thumbnail == thumbnailSlide) {
return
}
thumbnailSlide = thumbnail
conversationContext.glideRequests.clear(binding.thumbnailStub.get())
if (placeholderTarget != null) {
conversationContext.glideRequests.clear(placeholderTarget)
}
// endif
val thumbnailUri = thumbnail.uri
val thumbnailBlur = thumbnail.placeholderBlur
val thumbnailAttachment = thumbnail.asAttachment()
val thumbnailWidth = thumbnailAttachment.width
val thumbnailHeight = thumbnailAttachment.height
val maxWidth = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_width)
val maxHeight = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_height)
setThumbnailSize(
thumbnailWidth,
thumbnailHeight,
maxWidth,
maxHeight
)
binding.thumbnailStub.get().updateLayoutParams {
width = thumbnailSize[0]
height = thumbnailSize[1]
}
if (thumbnailBlur != null) {
val placeholderTarget = PlaceholderTarget(binding.thumbnailStub.get())
conversationContext
.glideRequests
.load(thumbnailBlur)
.centerInside()
.dontAnimate()
.override(thumbnailSize[0], thumbnailSize[1])
.into(placeholderTarget)
this.placeholderTarget = placeholderTarget
}
if (thumbnailUri != null) {
conversationContext
.glideRequests
.load(DecryptableStreamUriLoader.DecryptableUri(thumbnailUri))
.centerInside()
.dontAnimate()
.override(thumbnailSize[0], thumbnailSize[1])
.into(binding.thumbnailStub.get())
}
}
private fun setThumbnailSize(
thumbnailWidth: Int,
thumbnailHeight: Int,
maxWidth: Int,
maxHeight: Int
) {
if (thumbnailWidth == 0 || thumbnailHeight == 0) {
thumbnailSize[0] = maxWidth
thumbnailSize[1] = maxHeight
return
}
if (thumbnailWidth <= maxWidth && thumbnailHeight <= maxHeight) {
thumbnailSize[0] = thumbnailWidth
thumbnailSize[1] = thumbnailHeight
return
}
if (thumbnailWidth > maxWidth) {
val thumbnailScale = 1 - ((thumbnailWidth - maxWidth) / thumbnailWidth.toFloat())
thumbnailSize[0] = (thumbnailWidth * thumbnailScale).toInt()
thumbnailSize[1] = (thumbnailHeight * thumbnailScale).toInt()
}
if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) {
return
}
if (thumbnailHeight > maxHeight) {
val thumbnailScale = 1 - ((thumbnailHeight - maxHeight) / thumbnailHeight.toFloat())
thumbnailSize[0] = (thumbnailWidth * thumbnailScale).toInt()
thumbnailSize[1] = (thumbnailHeight * thumbnailScale).toInt()
}
if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) {
return
}
setThumbnailSize(
thumbnailSize[0],
thumbnailSize[1],
maxWidth,
maxHeight
)
}
private fun hasGroupSenderName(): Boolean {
@ -265,25 +140,7 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
return hasThumbnail() || hasQuote()
}
private fun isThumbnailMetricsSatisfied(maxWidth: Int, maxHeight: Int): Boolean {
return thumbnailSize[0] in 1..maxWidth && thumbnailSize[1] in 1..maxHeight
}
private fun requireMediaMessage(): MediaMmsMessageRecord {
return conversationMessage.messageRecord as MediaMmsMessageRecord
}
private inner class PlaceholderTarget(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
override fun onLoadFailed(errorDrawable: Drawable?) {
view.background = errorDrawable
}
override fun onResourceCleared(placeholder: Drawable?) {
view.background = placeholder
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
view.background = resource
}
}
}

View file

@ -355,7 +355,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
private fun linkifyMessageBody(messageBody: Spannable) {
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
V2ConversationItemUtils.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
if (conversationMessage.hasStyleLinks()) {
messageBody.getSpans(0, messageBody.length, PlaceholderURLSpan::class.java).forEach { placeholder ->

View file

@ -11,6 +11,7 @@ import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils.isThumbnailAtBottomOfBubble
import org.thoughtcrime.securesms.util.hasNoBubble
/**
@ -34,6 +35,10 @@ class V2ConversationItemTheme(
fun getFooterIconColor(
conversationMessage: ConversationMessage
): Int {
if (conversationMessage.messageRecord.isThumbnailAtBottomOfBubble(context)) {
return ContextCompat.getColor(context, R.color.signal_colorOnCustom)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterIconColor,
@ -45,6 +50,10 @@ class V2ConversationItemTheme(
fun getFooterTextColor(
conversationMessage: ConversationMessage
): Int {
if (conversationMessage.messageRecord.isThumbnailAtBottomOfBubble(context)) {
return ContextCompat.getColor(context, R.color.signal_colorOnCustom)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterTextColor,

View file

@ -0,0 +1,252 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.content.Context
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Size
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.graphics.withTranslation
import androidx.core.view.updateLayoutParams
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.transition.Transition
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils.isThumbnailAtBottomOfBubble
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.Slide
/**
* ImageView subclass that adds support for a foreground drawable and
* gradient at the bottom, rendered in onDrawForeground.
*
* This is lighter weight then adding a bunch of extra, possibly unnecessary views
* to the relevant layouts.
*
* Also encapsulates the logic around presenting thumbnails to the user.
*/
class V2ConversationItemThumbnail @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
companion object {
private val FOREGROUND_SHADE_HEIGHT = 32.dp
private val UNSET_SIZE = Size(-1, -1)
private const val MAX_SIZE_RECURSION_DEPTH = 5
}
private val gradientPaint = Paint().apply {
isAntiAlias = true
}
private val rect = Rect()
private var drawForegroundShade: Boolean = true
private var compatForegroundDrawable: Drawable? = null
private var thumbnailSlide: Slide? = null
private var fastPreflightId: String? = null
private var placeholderTarget: PlaceholderTarget? = null
private var thumbnailSize = UNSET_SIZE
val thumbWidth: Int get() = thumbnailSize.width
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
gradientPaint.shader = LinearGradient(
w / 2f,
0f,
w / 2f,
FOREGROUND_SHADE_HEIGHT.toFloat(),
intArrayOf(0x00000000, 0x88000000.toInt()),
floatArrayOf(0f, 1f),
Shader.TileMode.CLAMP
)
}
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
canvas.getClipBounds(rect)
if (drawForegroundShade) {
canvas.withTranslation(y = rect.height() - FOREGROUND_SHADE_HEIGHT.toFloat()) {
canvas.drawPaint(gradientPaint)
}
}
// Sizing?
compatForegroundDrawable?.draw(canvas)
}
fun presentThumbnail(
mediaMessage: MediaMmsMessageRecord,
conversationContext: V2ConversationContext
) {
val slideDeck = mediaMessage.slideDeck
if (slideDeck.thumbnailSlides.isEmpty() || slideDeck.thumbnailSlides.size > 1) {
visibility = View.GONE
thumbnailSize = UNSET_SIZE
return
}
visibility = View.VISIBLE
val thumbnail = slideDeck.thumbnailSlides.first()
val fastPreflightId = slideDeck.thumbnailSlides.first().fastPreflightId
if (thumbnail == thumbnailSlide && fastPreflightId == this.fastPreflightId) {
return
}
thumbnailSlide = thumbnail
this.fastPreflightId = fastPreflightId
conversationContext.glideRequests.clear(this)
if (placeholderTarget != null) {
conversationContext.glideRequests.clear(placeholderTarget)
}
val thumbnailUri = thumbnail.uri
val thumbnailBlur = thumbnail.placeholderBlur
val thumbnailAttachment = thumbnail.asAttachment()
val thumbnailWidth = thumbnailAttachment.width
val thumbnailHeight = thumbnailAttachment.height
val maxWidth = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_width)
val maxHeight = context.resources.getDimensionPixelSize(R.dimen.media_bubble_max_height)
setThumbnailSize(
thumbnailWidth,
thumbnailHeight,
maxWidth,
maxHeight,
0
)
updateLayoutParams {
width = thumbnailSize.width
height = thumbnailSize.height
}
if (thumbnailBlur != null) {
val placeholderTarget = PlaceholderTarget(this)
conversationContext
.glideRequests
.load(thumbnailBlur)
.centerInside()
.dontAnimate()
.override(thumbnailSize.width, thumbnailSize.height)
.into(placeholderTarget)
this.placeholderTarget = placeholderTarget
}
if (thumbnailUri != null) {
conversationContext
.glideRequests
.load(DecryptableStreamUriLoader.DecryptableUri(thumbnailUri))
.centerInside()
.dontAnimate()
.override(thumbnailSize.width, thumbnailSize.height)
.into(this)
}
setDrawForegroundShade(mediaMessage.isThumbnailAtBottomOfBubble(context))
}
private fun setDrawForegroundShade(drawForegroundShade: Boolean) {
this.drawForegroundShade = drawForegroundShade
invalidate()
}
private fun setCompatForegroundDrawable(drawable: Drawable?) {
this.compatForegroundDrawable = drawable
invalidate()
}
private fun setThumbnailSize(
thumbnailWidth: Int,
thumbnailHeight: Int,
maxWidth: Int,
maxHeight: Int,
depth: Int
) {
if (thumbnailWidth == 0 || thumbnailHeight == 0 || depth >= MAX_SIZE_RECURSION_DEPTH) {
thumbnailSize = Size(maxWidth, maxHeight)
return
}
if (thumbnailWidth <= maxWidth && thumbnailHeight <= maxHeight) {
thumbnailSize = Size(thumbnailWidth, thumbnailHeight)
return
}
if (thumbnailWidth > maxWidth) {
val thumbnailScale = 1 - ((thumbnailWidth - maxWidth) / thumbnailWidth.toFloat())
thumbnailSize = Size(
(thumbnailWidth * thumbnailScale).toInt(),
(thumbnailHeight * thumbnailScale).toInt()
)
}
if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) {
return
}
if (thumbnailHeight > maxHeight) {
val thumbnailScale = 1 - ((thumbnailHeight - maxHeight) / thumbnailHeight.toFloat())
thumbnailSize = Size(
(thumbnailWidth * thumbnailScale).toInt(),
(thumbnailHeight * thumbnailScale).toInt()
)
}
if (isThumbnailMetricsSatisfied(maxWidth, maxHeight)) {
return
}
setThumbnailSize(
thumbnailSize.width,
thumbnailSize.height,
maxWidth,
maxHeight,
depth + 1
)
}
private fun isThumbnailMetricsSatisfied(maxWidth: Int, maxHeight: Int): Boolean {
return thumbnailSize.width in 1..maxWidth && thumbnailSize.height in 1..maxHeight
}
private class PlaceholderTarget(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
override fun onLoadFailed(errorDrawable: Drawable?) {
view.background = errorDrawable
}
override fun onResourceCleared(placeholder: Drawable?) {
view.background = placeholder
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
view.background = resource
}
}
}

View file

@ -5,19 +5,26 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.content.Context
import android.text.Spannable
import android.text.Spanned
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.core.text.util.LinkifyCompat
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.UrlClickHandler
import org.thoughtcrime.securesms.util.hasOnlyThumbnail
/**
* Utilities for presenting the body of a conversation message.
*/
object V2ConversationBodyUtil {
object V2ConversationItemUtils {
fun MessageRecord.isThumbnailAtBottomOfBubble(context: Context): Boolean {
return hasOnlyThumbnail(context) && isDisplayBodyEmpty(context)
}
@JvmStatic
fun linkifyUrlLinks(messageBody: Spannable, shouldLinkifyAllLinks: Boolean, urlClickHandler: UrlClickHandler) {

View file

@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.widget.ImageView
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
@ -24,7 +23,7 @@ class V2FooterPositionDelegate private constructor(
private val footerViews: List<View>,
private val bodyContainer: View,
private val body: EmojiTextView,
private val thumbnailView: Stub<ImageView>?
private val thumbnailView: Stub<V2ConversationItemThumbnail>?
) : V2ConversationItemLayout.OnMeasureListener {
constructor(binding: V2ConversationItemTextOnlyBindingBridge) : this(

View file

@ -2,7 +2,8 @@
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
<org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemThumbnail
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -8,7 +8,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationBodyUtil
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils
import org.thoughtcrime.securesms.util.UrlClickHandler
@Suppress("ClassName")
@ -20,7 +20,7 @@ class ConversationItemTest_linkifyUrlLinks(private val input: String, private va
fun test1() {
val spannableStringBuilder = SpannableStringBuilder(input)
V2ConversationBodyUtil.linkifyUrlLinks(spannableStringBuilder, true, UrlHandler)
V2ConversationItemUtils.linkifyUrlLinks(spannableStringBuilder, true, UrlHandler)
val spans = spannableStringBuilder.getSpans(0, expectedUrl.length, URLSpan::class.java)
assertEquals(1, spans.size)