From 277c74185180ce45376675543300e6ee603c139a Mon Sep 17 00:00:00 2001 From: jubb Date: Thu, 24 Jun 2021 16:15:13 +1000 Subject: [PATCH] feat: AlbumThumbnailView.kt view visible and binding to thumbnail slides --- .../components/AlbumThumbnailView.java | 3 +- .../components/ConversationItemThumbnail.java | 1 - .../components/v2/AlbumThumbnailView.kt | 99 ++++++++++ .../components/v2/ThumbnailDimensDelegate.kt | 91 +++++++++ .../securesms/components/v2/ThumbnailView.kt | 174 ++++++++++++++++++ .../v2/messages/VisibleMessageContentView.kt | 12 +- app/src/main/res/layout/album_thumbnail_1.xml | 19 ++ app/src/main/res/layout/album_thumbnail_2.xml | 4 +- app/src/main/res/layout/album_thumbnail_3.xml | 6 +- app/src/main/res/layout/album_thumbnail_4.xml | 8 +- app/src/main/res/layout/album_thumbnail_5.xml | 10 +- .../main/res/layout/album_thumbnail_many.xml | 10 +- 12 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/v2/AlbumThumbnailView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailDimensDelegate.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailView.kt create mode 100644 app/src/main/res/layout/album_thumbnail_1.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java index 9f472fb069..6c8c881264 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -11,6 +11,8 @@ import android.widget.FrameLayout; import android.widget.TextView; import network.loki.messenger.R; + +import org.thoughtcrime.securesms.components.v2.ThumbnailView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; @@ -149,7 +151,6 @@ public class AlbumThumbnailView extends FrameLayout { private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) { ThumbnailView cell = findViewById(id); cell.setImageResource(glideRequests, slide, false, false); - cell.setLoadIndicatorVisibile(slide.isInProgress()); cell.setThumbnailClickListener(defaultThumbnailClickListener); cell.setOnLongClickListener(defaultLongClickListener); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java index 379b5c77a7..3afd68db1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -72,7 +72,6 @@ public class ConversationItemThumbnail extends FrameLayout { } } - @SuppressWarnings("SuspiciousNameCombination") @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/v2/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/v2/AlbumThumbnailView.kt new file mode 100644 index 0000000000..0031033f39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/v2/AlbumThumbnailView.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.components.v2 + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import kotlinx.android.synthetic.main.album_thumbnail_view.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideRequests + +class AlbumThumbnailView: FrameLayout { + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private val albumCellContainer by lazy { album_cell_container } + private lateinit var cornerMask: CornerMask + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.album_thumbnail_view, this) + cornerMask = CornerMask(this) + cornerMask.setRadius(80) + } + + override fun dispatchDraw(canvas: Canvas?) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) + } + + // endregion + + // region Interaction + + fun bind(glideRequests: GlideRequests, message: MmsMessageRecord, isStart: Boolean, isEnd: Boolean) { + // TODO: optimize for same size + val slides = message.slideDeck.thumbnailSlides + if (slides.isEmpty()) { + // this should never be encountered because it's checked by parent + return + } + calculateRadius(isStart, isEnd, message.isOutgoing) + albumCellContainer.removeAllViews() + LayoutInflater.from(context).inflate(layoutRes(slides.size), albumCellContainer) + // iterate + slides.take(5).forEachIndexed { position, slide -> + getThumbnailView(position).setImageResource(glideRequests, slide, showControls = false, isPreview = false) + } + } + + // endregion + + + fun layoutRes(slideCount: Int) = when (slideCount) { + 1 -> R.layout.album_thumbnail_1 // single + 2 -> R.layout.album_thumbnail_2// two sidebyside + 3 -> R.layout.album_thumbnail_3// three stacked + 4 -> R.layout.album_thumbnail_4// four square + 5 -> R.layout.album_thumbnail_5// + else -> R.layout.album_thumbnail_many// five or more + } + + fun getThumbnailView(position: Int): ThumbnailView = when (position) { + 0 -> albumCellContainer.findViewById(R.id.album_cell_container).findViewById(R.id.album_cell_1) + 1 -> albumCellContainer.findViewById(R.id.album_cell_container).findViewById(R.id.album_cell_2) + 2 -> albumCellContainer.findViewById(R.id.album_cell_container).findViewById(R.id.album_cell_3) + 3 -> albumCellContainer.findViewById(R.id.album_cell_container).findViewById(R.id.album_cell_4) + 4 -> albumCellContainer.findViewById(R.id.album_cell_container).findViewById(R.id.album_cell_5) + else -> throw Exception("Can't get thumbnail view for non-existent thumbnail at position: $position") + } + + fun calculateRadius(isStart: Boolean, isEnd: Boolean, outgoing: Boolean) { + val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).toInt() + val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).toInt() + val (startTop, endTop, startBottom, endBottom) = when { + // single message, consistent dimen + isStart && isEnd -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen) + // start of message cluster, collapsed BL + isStart -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen) + // end of message cluster, collapsed TL + isEnd -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen) + // else in the middle, no rounding left side + else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen) + } + // TL, TR, BR, BL (CW direction) + cornerMask.setRadii( + if (!outgoing) startTop else endTop, // TL + if (!outgoing) endTop else startTop, // TR + if (!outgoing) endBottom else startBottom, // BR + if (!outgoing) startBottom else endBottom // BL + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailDimensDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailDimensDelegate.kt new file mode 100644 index 0000000000..c4c7246d02 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailDimensDelegate.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.components.v2 + +class ThumbnailDimensDelegate { + + companion object { + // dimens array constants + private const val WIDTH = 0 + private const val HEIGHT = 1 + private const val DIMENS_ARRAY_SIZE = 2 + + // bounds array constants + private const val MIN_WIDTH = 0 + private const val MIN_HEIGHT = 1 + private const val MAX_WIDTH = 2 + private const val MAX_HEIGHT = 3 + private const val BOUNDS_ARRAY_SIZE = 4 + + // const zero int array + private val EMPTY_DIMENS = intArrayOf(0,0) + + } + + private val measured: IntArray = IntArray(DIMENS_ARRAY_SIZE) + private val dimens: IntArray = IntArray(DIMENS_ARRAY_SIZE) + private val bounds: IntArray = IntArray(BOUNDS_ARRAY_SIZE) + + fun resourceSize(): IntArray { + if (dimens.all { it == 0 }) { + // dimens are (0, 0), don't go any further + return EMPTY_DIMENS + } + + val naturalWidth = dimens[WIDTH].toDouble() + val naturalHeight = dimens[HEIGHT].toDouble() + val minWidth = dimens[MIN_WIDTH] + val maxWidth = dimens[MAX_WIDTH] + val minHeight = dimens[MIN_HEIGHT] + val maxHeight = dimens[MAX_HEIGHT] + + // calculate actual measured + var measuredWidth: Double = naturalWidth + var measuredHeight: Double = naturalHeight + + val widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth + val heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight + + if (!widthInBounds || !heightInBounds) { + val minWidthRatio: Double = naturalWidth / minWidth + val maxWidthRatio: Double = naturalWidth / maxWidth + val minHeightRatio: Double = naturalHeight / minHeight + val maxHeightRatio: Double = naturalHeight / maxHeight + if (maxWidthRatio > 1 || maxHeightRatio > 1) { + if (maxWidthRatio >= maxHeightRatio) { + measuredWidth /= maxWidthRatio + measuredHeight /= maxWidthRatio + } else { + measuredWidth /= maxHeightRatio + measuredHeight /= maxHeightRatio + } + measuredWidth = Math.max(measuredWidth, minWidth.toDouble()) + measuredHeight = Math.max(measuredHeight, minHeight.toDouble()) + } else if (minWidthRatio < 1 || minHeightRatio < 1) { + if (minWidthRatio <= minHeightRatio) { + measuredWidth /= minWidthRatio + measuredHeight /= minWidthRatio + } else { + measuredWidth /= minHeightRatio + measuredHeight /= minHeightRatio + } + measuredWidth = Math.min(measuredWidth, maxWidth.toDouble()) + measuredHeight = Math.min(measuredHeight, maxHeight.toDouble()) + } + } + measured[WIDTH] = measuredWidth.toInt() + measured[HEIGHT] = measuredHeight.toInt() + return measured + } + + fun setBounds(minWidth: Int, minHeight: Int, maxWidth: Int, maxHeight: Int) { + bounds[MIN_WIDTH] = minWidth + bounds[MIN_HEIGHT] = minHeight + bounds[MAX_WIDTH] = maxWidth + bounds[MAX_HEIGHT] = maxHeight + } + + fun setDimens(width: Int, height: Int) { + dimens[WIDTH] = width + dimens[HEIGHT] = height + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailView.kt new file mode 100644 index 0000000000..89af2faad9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/v2/ThumbnailView.kt @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.components.v2 + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.core.view.isVisible +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestOptions +import kotlinx.android.synthetic.main.thumbnail_view.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.ViewUtil +import org.thoughtcrime.securesms.components.TransferControlView +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.mms.GlideRequest +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.mms.SlideClickListener + +class ThumbnailView: FrameLayout { + + companion object { + private const val WIDTH = 0 + private const val HEIGHT = 1 + } + + // region Lifecycle + constructor(context: Context) : super(context) { initialize(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } + + private val image by lazy { thumbnail_image } + private val playOverlay by lazy { play_overlay } + private val captionIcon by lazy { thumbnail_caption_icon } + val loadIndicator: ProgressBar by lazy { thumbnail_load_indicator } + private val transferControls by lazy { ViewUtil.inflateStub(this, R.id.transfer_controls_stub) } + + private val dimensDelegate = ThumbnailDimensDelegate() + + var thumbnailClickListener: SlideClickListener? = null + + private var slide: Slide? = null + + private fun initialize(attrs: AttributeSet?) { + inflate(context, R.layout.thumbnail_view, this) + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) + + dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)) + + typedArray.recycle() + } + } + + // region Lifecycle + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val adjustedDimens = dimensDelegate.resourceSize() + if (adjustedDimens[WIDTH] == 0 && adjustedDimens[HEIGHT] == 0) { + return super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + val finalWidth: Int = adjustedDimens[WIDTH] + paddingLeft + paddingRight + val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom + + super.onMeasure( + MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) + ) + } + + private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) + private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) + + // endregion + + // region Interaction + fun setImageResource(glide: GlideRequests, slide: Slide, showControls: Boolean, isPreview: Boolean) { + return setImageResource(glide, slide, showControls, isPreview, 0, 0) + } + + fun setImageResource(glide: GlideRequests, slide: Slide, + showControls: Boolean, isPreview: Boolean, + naturalWidth: Int, naturalHeight: Int) { + + val currentSlide = this.slide + + if (showControls) { + transferControls.setSlide(slide) +// transferControls.setDownloadClickListener() TODO: re-add this + } else { + transferControls.isVisible = false + } + + playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) + + if (equals(currentSlide, slide)) { + // don't re-load slide + return + } + + + if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { + // not reloading slide for fast preflight + this.slide = slide + } + + this.slide = slide + + captionIcon.isVisible = slide.caption.isPresent + loadIndicator.isVisible = slide.isInProgress + + dimensDelegate.setDimens(naturalWidth, naturalHeight) + invalidate() + + when { + slide.thumbnailUri != null -> { + buildThumbnailGlideRequest(glide, slide).into(image) + } + slide.hasPlaceholder() -> { + buildPlaceholderGlideRequest(glide, slide).into(image) + } + else -> { + glide.clear(image) + } + } + } + + fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + + val dimens = dimensDelegate.resourceSize() + + val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .let { request -> + if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { + request.override(getDefaultWidth(), getDefaultHeight()) + } else { + request.override(dimens[WIDTH], dimens[HEIGHT]) + } + } + .transition(DrawableTransitionOptions.withCrossFade()) + .centerCrop() + + return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) + } + + fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + + val dimens = dimensDelegate.resourceSize() + + return glide.asBitmap() + .load(slide.getPlaceholderRes(context.theme)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .let { request -> + if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { + request.override(getDefaultWidth(), getDefaultHeight()) + } else { + request.override(dimens[WIDTH], dimens[HEIGHT]) + } + } + .fitCenter() + } + // endregion + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index b085171a0d..c40599630c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -1,11 +1,9 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context -import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.text.util.Linkify import android.util.AttributeSet -import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.widget.LinearLayout @@ -17,11 +15,10 @@ import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import kotlinx.android.synthetic.main.view_visible_message_content.view.* import network.loki.messenger.R -import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.messaging.utilities.UpdateMessageData.Companion.fromJSON import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.components.v2.AlbumThumbnailView import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.loki.utilities.UiMode @@ -89,9 +86,10 @@ class VisibleMessageContentView : LinearLayout { documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) mainContainer.addView(documentView) } else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) { - val dummyTextView = TextView(context) - dummyTextView.text = "asifuygaihsfo" - mainContainer.addView(dummyTextView) + val albumThumbnailView = AlbumThumbnailView(context) + mainContainer.addView(albumThumbnailView) + // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups + albumThumbnailView.bind(glide, message, isStartOfMessageCluster, isEndOfMessageCluster) } else if (message.isOpenGroupInvitation) { val openGroupInvitationView = OpenGroupInvitationView(context) openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message)) diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml new file mode 100644 index 0000000000..3946b674ec --- /dev/null +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_2.xml b/app/src/main/res/layout/album_thumbnail_2.xml index 3fad6e678e..a1c132c272 100644 --- a/app/src/main/res/layout/album_thumbnail_2.xml +++ b/app/src/main/res/layout/album_thumbnail_2.xml @@ -7,13 +7,13 @@ android:layout_width="@dimen/album_total_width" android:layout_height="@dimen/album_2_total_height"> - - - - - - - - - - - - - - - - - - -