diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java index f6137294c1..0ee7b7ef18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.attachments; import android.net.Uri; @@ -244,7 +249,11 @@ public abstract class Attachment implements Parcelable { @Nullable public byte[] getIncrementalDigest() { - return incrementalDigest; + if (incrementalDigest != null && incrementalDigest.length > 0) { + return incrementalDigest; + } else { + return null; + } } @Nullable 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 2143327932..6912873ef2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components; import android.content.Context; @@ -12,10 +17,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.views.Stub; import java.util.List; @@ -24,13 +31,15 @@ public class AlbumThumbnailView extends FrameLayout { private @Nullable SlideClickListener thumbnailClickListener; private @Nullable SlidesClickedListener downloadClickListener; + private @Nullable SlidesClickedListener cancelDownloadClickListener; + private @Nullable SlideClickListener playVideoClickListener; private int currentSizeClass; private final int[] corners = new int[4]; - private ViewGroup albumCellContainer; - private Stub transferControls; + private final ViewGroup albumCellContainer; + private final Stub transferControlsStub; private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> { if (thumbnailClickListener != null) { @@ -42,19 +51,18 @@ public class AlbumThumbnailView extends FrameLayout { public AlbumThumbnailView(@NonNull Context context) { super(context); - initialize(); + inflate(getContext(), R.layout.album_thumbnail_view, this); + + albumCellContainer = findViewById(R.id.album_cell_container); + transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); } public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); - initialize(); - } - - private void initialize() { inflate(getContext(), R.layout.album_thumbnail_view, this); - albumCellContainer = findViewById(R.id.album_cell_container); - transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); + albumCellContainer = findViewById(R.id.album_cell_container); + transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); } public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List slides, boolean showControls) { @@ -63,16 +71,17 @@ public class AlbumThumbnailView extends FrameLayout { } if (showControls) { - transferControls.get().setShowDownloadText(true); - transferControls.get().setSlides(slides); - transferControls.get().setDownloadClickListener(v -> { - if (downloadClickListener != null) { - downloadClickListener.onClick(v, slides); - } - }); + transferControlsStub.get().setShowSecondaryText(true); + transferControlsStub.get().setDownloadClickListener( + v -> { + if (downloadClickListener != null) { + downloadClickListener.onClick(v, slides); + } + }); + transferControlsStub.get().setSlides(slides); } else { - if (transferControls.resolved()) { - transferControls.get().setVisibility(GONE); + if (transferControlsStub.resolved()) { + transferControlsStub.get().setVisibility(GONE); } } @@ -85,6 +94,7 @@ public class AlbumThumbnailView extends FrameLayout { showSlides(glideRequests, slides); applyCorners(); + forceLayout(); } public void setCellBackgroundColor(@ColorInt int color) { @@ -101,10 +111,19 @@ public class AlbumThumbnailView extends FrameLayout { thumbnailClickListener = listener; } - public void setDownloadClickListener(@Nullable SlidesClickedListener listener) { - downloadClickListener = listener; + public void setDownloadClickListener(SlidesClickedListener listener) { + this.downloadClickListener = listener; } + public void setCancelDownloadClickListener(SlidesClickedListener listener) { + this.cancelDownloadClickListener = listener; + } + + public void setPlayVideoClickListener(SlideClickListener listener) { + this.playVideoClickListener = listener; + } + + public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) { corners[0] = topLeft; corners[1] = topRight; @@ -117,23 +136,46 @@ public class AlbumThumbnailView extends FrameLayout { private void inflateLayout(int sizeClass) { albumCellContainer.removeAllViews(); + int resId; switch (sizeClass) { case 2: - inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer); + resId = R.layout.album_thumbnail_2; break; case 3: - inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer); + resId = R.layout.album_thumbnail_3; break; case 4: - inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer); + resId = R.layout.album_thumbnail_4; break; case 5: - inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer); + resId = R.layout.album_thumbnail_5; break; default: - inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer); + resId = R.layout.album_thumbnail_many; break; } + + inflate(getContext(), resId, albumCellContainer); + if (transferControlsStub.resolved()) { + int size; + switch (sizeClass) { + case 2: + size = R.dimen.album_2_total_height; + break; + case 3: + size = R.dimen.album_3_total_height; + break; + case 4: + size = R.dimen.album_4_total_height; + break; + default: + size = R.dimen.album_5_total_height; + break; + } + ViewGroup.LayoutParams params = transferControlsStub.get().getLayoutParams(); + params.height = getContext().getResources().getDimensionPixelSize(size); + transferControlsStub.get().setLayoutParams(params); + } } private void applyCorners() { @@ -214,19 +256,20 @@ public class AlbumThumbnailView extends FrameLayout { } private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List slides) { - setSlide(glideRequests, slides.get(0), R.id.album_cell_1); - setSlide(glideRequests, slides.get(1), R.id.album_cell_2); + boolean showControls = TransferControlView.containsPlayableSlides(slides); + setSlide(glideRequests, slides.get(0), R.id.album_cell_1, showControls); + setSlide(glideRequests, slides.get(1), R.id.album_cell_2, showControls); if (slides.size() >= 3) { - setSlide(glideRequests, slides.get(2), R.id.album_cell_3); + setSlide(glideRequests, slides.get(2), R.id.album_cell_3, showControls); } if (slides.size() >= 4) { - setSlide(glideRequests, slides.get(3), R.id.album_cell_4); + setSlide(glideRequests, slides.get(3), R.id.album_cell_4, showControls); } if (slides.size() >= 5) { - setSlide(glideRequests, slides.get(4), R.id.album_cell_5); + setSlide(glideRequests, slides.get(4), R.id.album_cell_5, showControls && slides.size() == 5); } if (slides.size() > 5) { @@ -235,11 +278,17 @@ public class AlbumThumbnailView extends FrameLayout { } } - private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) { + private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id, boolean showControls) { ThumbnailView cell = findViewById(id); - cell.setImageResource(glideRequests, slide, false, false); + cell.showSecondaryText(false); cell.setThumbnailClickListener(defaultThumbnailClickListener); + cell.setDownloadClickListener(downloadClickListener); + cell.setCancelDownloadClickListener(cancelDownloadClickListener); + if (MediaUtil.isInstantVideoSupported(slide)) { + cell.setPlayVideoClickListener(playVideoClickListener); + } cell.setOnLongClickListener(defaultLongClickListener); + cell.setImageResource(glideRequests, slide, showControls, false); } private int sizeClass(int size) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt index 7ee62e28cb..9cecb89ac6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components import android.content.Context @@ -86,7 +91,7 @@ class ConversationItemThumbnail @JvmOverloads constructor( } } - override fun onSaveInstanceState(): Parcelable? { + override fun onSaveInstanceState(): Parcelable { val root = super.onSaveInstanceState() return bundleOf( STATE_ROOT to root, @@ -255,9 +260,19 @@ class ConversationItemThumbnail @JvmOverloads constructor( state.applyState(thumbnail, album) } - fun setProgressWheelClickListener(listener: SlideClickListener?) { + fun setPlayVideoClickListener(listener: SlideClickListener?) { state = state.copy( - thumbnailViewState = state.thumbnailViewState.copy(progressWheelClickListener = listener) + thumbnailViewState = state.thumbnailViewState.copy(playVideoClickListener = listener), + albumViewState = state.albumViewState.copy(playVideoClickListener = listener) + ) + + state.applyState(thumbnail, album) + } + + fun setCancelDownloadClickListener(listener: SlidesClickedListener?) { + state = state.copy( + thumbnailViewState = state.thumbnailViewState.copy(cancelDownloadClickListener = listener), + albumViewState = state.albumViewState.copy(cancelDownloadClickListener = listener) ) state.applyState(thumbnail, album) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt index f8ef85a79a..77681e4c15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components import android.graphics.Color @@ -31,7 +36,9 @@ data class ConversationItemThumbnailState( @IgnoredOnParcel private val downloadClickListener: SlidesClickedListener? = null, @IgnoredOnParcel - private val progressWheelClickListener: SlideClickListener? = null, + private val cancelDownloadClickListener: SlidesClickedListener? = null, + @IgnoredOnParcel + private val playVideoClickListener: SlideClickListener? = null, @IgnoredOnParcel private val longClickListener: OnLongClickListener? = null, private val visibility: Int = View.GONE, @@ -57,7 +64,8 @@ data class ConversationItemThumbnailState( thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft) thumbnailView.get().setThumbnailClickListener(clickListener) thumbnailView.get().setDownloadClickListener(downloadClickListener) - thumbnailView.get().setProgressWheelClickListener(progressWheelClickListener) + thumbnailView.get().setCancelDownloadClickListener(cancelDownloadClickListener) + thumbnailView.get().setPlayVideoClickListener(playVideoClickListener) thumbnailView.get().setOnLongClickListener(longClickListener) thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight) } @@ -72,6 +80,10 @@ data class ConversationItemThumbnailState( @IgnoredOnParcel private val downloadClickListener: SlidesClickedListener? = null, @IgnoredOnParcel + private val cancelDownloadClickListener: SlidesClickedListener? = null, + @IgnoredOnParcel + private val playVideoClickListener: SlideClickListener? = null, + @IgnoredOnParcel private val longClickListener: OnLongClickListener? = null, private val visibility: Int = View.GONE, private val cellBackgroundColor: Int = Color.TRANSPARENT, @@ -92,6 +104,8 @@ data class ConversationItemThumbnailState( albumView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft) albumView.get().setThumbnailClickListener(clickListener) albumView.get().setDownloadClickListener(downloadClickListener) + albumView.get().setCancelDownloadClickListener(cancelDownloadClickListener) + albumView.get().setPlayVideoClickListener(playVideoClickListener) albumView.get().setOnLongClickListener(longClickListener) albumView.get().setCellBackgroundColor(cellBackgroundColor) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java index 35d069b618..d49809e2bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java @@ -217,7 +217,7 @@ public class LinkPreviewView extends FrameLayout { thumbnail.setVisibility(VISIBLE); thumbnailState.applyState(thumbnail); thumbnail.get().setImageResource(glideRequests, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false); - thumbnail.get().showDownloadText(false); + thumbnail.get().showSecondaryText(false); } else if (callLinkRootKey != null) { thumbnail.setVisibility(VISIBLE); thumbnailState.applyState(thumbnail); @@ -228,7 +228,7 @@ public class LinkPreviewView extends FrameLayout { .asDrawable(getContext(), AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes())) ); - thumbnail.get().showDownloadText(false); + thumbnail.get().showSecondaryText(false); } else { thumbnail.setVisibility(GONE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index b20a2a6096..65a1733937 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components; import android.content.Context; @@ -31,6 +36,7 @@ import org.signal.core.util.logging.Log; import org.signal.glide.transforms.SignalDownsampleStrategy; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequest; @@ -41,7 +47,6 @@ import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.stories.StoryTextPostModel; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; @@ -50,6 +55,7 @@ import org.thoughtcrime.securesms.util.views.Stub; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.concurrent.ExecutionException; @@ -80,12 +86,12 @@ public class ThumbnailView extends FrameLayout { private final CornerMask cornerMask; - private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState(); - private Stub transferControlViewStub; - private SlideClickListener thumbnailClickListener = null; - private SlidesClickedListener downloadClickListener = null; - private SlideClickListener progressWheelClickListener = null; - private Slide slide = null; + private final Stub transferControlViewStub; + private SlideClickListener thumbnailClickListener = null; + private SlidesClickedListener downloadClickListener = null; + private SlidesClickedListener cancelDownloadClickListener = null; + private SlideClickListener playVideoClickListener = null; + private Slide slide = null; public ThumbnailView(Context context) { @@ -278,15 +284,13 @@ public class ThumbnailView extends FrameLayout { @Override public void setFocusable(boolean focusable) { super.setFocusable(focusable); - transferControlsState = transferControlsState.withFocusable(focusable); - transferControlsState.applyState(transferControlViewStub); + transferControlViewStub.get().setFocusable(focusable); } @Override public void setClickable(boolean clickable) { super.setClickable(clickable); - transferControlsState = transferControlsState.withClickable(clickable); - transferControlsState.applyState(transferControlViewStub); + transferControlViewStub.get().setClickable(clickable); } public @Nullable Drawable getImageDrawable() { @@ -359,24 +363,15 @@ public class ThumbnailView extends FrameLayout { } if (showControls) { - int transferState = TransferControlView.getTransferState(Collections.singletonList(slide)); - if (transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) { - transferControlViewStub.setVisibility(View.GONE); - } else { - transferControlViewStub.setVisibility(View.VISIBLE); + transferControlViewStub.get().setDownloadClickListener(new DownloadClickDispatcher()); + transferControlViewStub.get().setCancelClickListener(new CancelClickDispatcher()); + if (MediaUtil.isInstantVideoSupported(slide)) { + transferControlViewStub.get().setInstantPlaybackClickListener(new InstantVideoClickDispatcher()); } - - transferControlsState = transferControlsState.withSlide(slide) - .withDownloadClickListener(new DownloadClickDispatcher()); - - if (FeatureFlags.instantVideoPlayback()) { - transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher()); - } - - transferControlsState.applyState(transferControlViewStub); - } else { - transferControlViewStub.setVisibility(View.GONE); + transferControlViewStub.get().setSlides(List.of(slide)); } + int transferState = TransferControlView.getTransferState(List.of(slide)); + transferControlViewStub.get().setVisible(showControls && transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE); if (slide.getUri() != null && slide.hasPlayOverlay() && (slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE || isPreview)) @@ -525,8 +520,41 @@ public class ThumbnailView extends FrameLayout { this.downloadClickListener = listener; } - public void setProgressWheelClickListener(SlideClickListener listener) { - this.progressWheelClickListener = listener; + public void setCancelDownloadClickListener(SlidesClickedListener listener) { + this.cancelDownloadClickListener = listener; + } + + public void setPlayVideoClickListener(SlideClickListener listener) { + this.playVideoClickListener = listener; + } + + private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) { + if (Util.equals(slide, other)) { + + if (slide != null && other != null) { + byte[] digestLeft = slide.asAttachment().getDigest(); + byte[] digestRight = other.asAttachment().getDigest(); + + return Arrays.equals(digestLeft, digestRight); + } + } + + return false; + } + + private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { + GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri()))) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE) + .transition(withCrossFade())); + + boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23; + + if (slide.isInProgress() || doNotShowMissingThumbnailImage) { + return request; + } else { + return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); + } } public void clear(GlideRequests glideRequests) { @@ -543,13 +571,12 @@ public class ThumbnailView extends FrameLayout { slide = null; } - public void showDownloadText(boolean showDownloadText) { - transferControlsState = transferControlsState.withDownloadText(showDownloadText); - transferControlsState.applyState(transferControlViewStub); + public void showSecondaryText(boolean showSecondaryText) { + transferControlViewStub.get().setShowSecondaryText(showSecondaryText); } public void showProgressSpinner() { - transferControlViewStub.get().showProgressSpinner(); + transferControlViewStub.get().setVisible(true); } public void setScaleType(@NonNull ImageView.ScaleType scaleType) { @@ -566,20 +593,6 @@ public class ThumbnailView extends FrameLayout { invalidate(); } - private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { - GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri()))) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE) - .transition(withCrossFade())); - - boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23; - - if (slide.isInProgress() || doNotShowMissingThumbnailImage) { - return request; - } else { - return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); - } - } private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { GlideRequest bitmap = glideRequests.asBitmap(); @@ -621,19 +634,6 @@ public class ThumbnailView extends FrameLayout { return 0; } - private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) { - if (Util.equals(slide, other)) { - - if (slide != null && other != null) { - byte[] digestLeft = slide.asAttachment().getDigest(); - byte[] digestRight = other.asAttachment().getDigest(); - - return Arrays.equals(digestLeft, digestRight); - } - } - - return false; - } public interface ThumbnailRequestListener extends RequestListener { void onLoadCanceled(); @@ -670,14 +670,26 @@ public class ThumbnailView extends FrameLayout { } } - private class ProgressWheelClickDispatcher implements View.OnClickListener { + private class CancelClickDispatcher implements View.OnClickListener { @Override public void onClick(View view) { - Log.i(TAG, "onClick() for progress wheel"); - if (progressWheelClickListener != null && slide != null) { - progressWheelClickListener.onClick(view, slide); + Log.i(TAG, "onClick() for cancel button"); + if (cancelDownloadClickListener != null && slide != null) { + cancelDownloadClickListener.onClick(view, Collections.singletonList(slide)); } else { - Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener); + Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelDownloadClickListener); + } + } + } + + private class InstantVideoClickDispatcher implements View.OnClickListener { + @Override + public void onClick(View view) { + Log.i(TAG, "onClick() for instant video playback"); + if (playVideoClickListener != null && slide != null) { + playVideoClickListener.onClick(view, slide); + } else { + Log.w(TAG, "Received an instant video click, but unable to execute it. slide: " + slide + " playVideoClickListener: " + playVideoClickListener); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailViewTransferControlsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailViewTransferControlsState.kt deleted file mode 100644 index c8d60ea185..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailViewTransferControlsState.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.components - -import android.view.View.OnClickListener -import org.thoughtcrime.securesms.mms.Slide -import org.thoughtcrime.securesms.util.views.Stub - -/** - * State object for transfer controls. - */ -data class ThumbnailViewTransferControlsState( - val isFocusable: Boolean = true, - val isClickable: Boolean = true, - val slide: Slide? = null, - val downloadClickedListener: OnClickListener? = null, - val progressWheelClickedListener: OnClickListener? = null, - val showDownloadText: Boolean = true -) { - - fun withFocusable(isFocusable: Boolean): ThumbnailViewTransferControlsState = copy(isFocusable = isFocusable) - fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable) - fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide) - fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener) - fun withProgressWheelClickListener(progressWheelClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(progressWheelClickedListener = progressWheelClickedListener) - fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText) - - fun applyState(transferControlView: Stub) { - if (transferControlView.resolved()) { - transferControlView.get().isFocusable = isFocusable - transferControlView.get().isClickable = isClickable - if (slide != null) { - transferControlView.get().setSlide(slide) - } - transferControlView.get().setDownloadClickListener(downloadClickedListener) - transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener) - transferControlView.get().setShowDownloadText(showDownloadText) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java deleted file mode 100644 index 4d04d72c15..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java +++ /dev/null @@ -1,273 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.animation.LayoutTransition; -import android.content.Context; -import android.util.AttributeSet; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.annimon.stream.Stream; -import com.pnikosis.materialishprogress.ProgressWheel; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.database.AttachmentTable; -import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.mms.Slide; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public final class TransferControlView extends FrameLayout { - - private static final String TAG = "TransferControlView"; - private static final int UPLOAD_TASK_WEIGHT = 1; - - /** - * A weighting compared to {@link #UPLOAD_TASK_WEIGHT} - */ - private static final int COMPRESSION_TASK_WEIGHT = 3; - - @Nullable private List slides; - @Nullable private View current; - - private final ProgressWheel progressWheel; - private final View downloadDetails; - private final TextView downloadDetailsText; - - private final Map networkProgress; - private final Map compresssionProgress; - - public TransferControlView(Context context) { - this(context, null); - } - - public TransferControlView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - inflate(context, R.layout.transfer_controls_view, this); - - setLongClickable(false); - setBackground(ContextCompat.getDrawable(context, R.drawable.transfer_controls_background)); - setVisibility(GONE); - setLayoutTransition(new LayoutTransition()); - - this.networkProgress = new HashMap<>(); - this.compresssionProgress = new HashMap<>(); - - this.progressWheel = findViewById(R.id.progress_wheel); - this.downloadDetails = findViewById(R.id.download_details); - this.downloadDetailsText = findViewById(R.id.download_details_text); - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - downloadDetails.setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - downloadDetails.setClickable(clickable); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - EventBus.getDefault().unregister(this); - } - - public void setSlide(final @NonNull Slide slides) { - setSlides(Collections.singletonList(slides)); - } - - public void setSlides(final @NonNull List slides) { - if (slides.isEmpty()) { - throw new IllegalArgumentException("Must provide at least one slide."); - } - - this.slides = slides; - - if (!isUpdateToExistingSet(slides)) { - networkProgress.clear(); - compresssionProgress.clear(); - Stream.of(slides).forEach(s -> networkProgress.put(s.asAttachment(), 0f)); - } - - for (Slide slide : slides) { - if (slide.asAttachment().getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE) { - networkProgress.put(slide.asAttachment(), 1f); - } - } - - switch (getTransferState(slides)) { - case AttachmentTable.TRANSFER_PROGRESS_STARTED: - showProgressSpinner(calculateProgress(networkProgress, compresssionProgress)); - break; - case AttachmentTable.TRANSFER_PROGRESS_PENDING: - case AttachmentTable.TRANSFER_PROGRESS_FAILED: - String downloadText = getDownloadText(this.slides); - if (!Objects.equals(downloadText, downloadDetailsText.getText().toString())) { - downloadDetailsText.setText(getDownloadText(this.slides)); - } - - display(downloadDetails); - break; - default: - display(null); - break; - } - } - - public void showProgressSpinner() { - showProgressSpinner(calculateProgress(networkProgress, compresssionProgress)); - } - - public void showProgressSpinner(float progress) { - if (progress == 0) { - progressWheel.spin(); - } else { - progressWheel.setInstantProgress(progress); - } - - display(progressWheel); - } - - public void setDownloadClickListener(final @Nullable OnClickListener listener) { - downloadDetails.setOnClickListener(listener); - } - - public void setProgressWheelClickListener(final @Nullable OnClickListener listener) { - progressWheel.setOnClickListener(listener); - } - - public void clear() { - clearAnimation(); - setVisibility(GONE); - if (current != null) { - current.clearAnimation(); - current.setVisibility(GONE); - } - current = null; - slides = null; - } - - public void setShowDownloadText(boolean showDownloadText) { - downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE); - forceLayout(); - } - - private boolean isUpdateToExistingSet(@NonNull List slides) { - if (slides.size() != networkProgress.size()) { - return false; - } - - for (Slide slide : slides) { - if (!networkProgress.containsKey(slide.asAttachment())) { - return false; - } - } - - return true; - } - - static int getTransferState(@NonNull List slides) { - int transferState = AttachmentTable.TRANSFER_PROGRESS_DONE; - boolean allFailed = true; - - for (Slide slide : slides) { - if (slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) { - allFailed = false; - if (slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { - transferState = slide.getTransferState(); - } else { - transferState = Math.max(transferState, slide.getTransferState()); - } - } - } - return allFailed ? AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE : transferState; - } - - private String getDownloadText(@NonNull List slides) { - if (slides.size() == 1) { - return slides.get(0).getContentDescription(getContext()); - } else { - int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE ? count + 1 : count); - return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount); - } - } - - private void display(@Nullable final View view) { - if (current == view) { - return; - } - - if (current != null) { - current.setVisibility(GONE); - } - - if (view != null) { - view.setVisibility(VISIBLE); - setVisibility(VISIBLE); - } else { - setVisibility(GONE); - } - - current = view; - } - - private static float calculateProgress(@NonNull Map uploadDownloadProgress, Map compresssionProgress) { - float totalDownloadProgress = 0; - float totalCompressionProgress = 0; - - for (float progress : uploadDownloadProgress.values()) { - totalDownloadProgress += progress; - } - - for (float progress : compresssionProgress.values()) { - totalCompressionProgress += progress; - } - - float weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress; - float weightedTotal = UPLOAD_TASK_WEIGHT * uploadDownloadProgress.size() + COMPRESSION_TASK_WEIGHT * compresssionProgress.size(); - - return weightedProgress / weightedTotal; - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventAsync(final PartProgressEvent event) { - final Attachment attachment = event.attachment; - if (networkProgress.containsKey(attachment)) { - float proportionCompleted = ((float) event.progress) / event.total; - - if (event.type == PartProgressEvent.Type.COMPRESSION) { - compresssionProgress.put(attachment, proportionCompleted); - } else { - networkProgress.put(attachment, proportionCompleted); - } - - progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt new file mode 100644 index 0000000000..7d2e10d9c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt @@ -0,0 +1,629 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.components.transfercontrols + +import android.animation.LayoutTransition +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.format.Formatter +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.databinding.TransferControlsViewBinding +import org.thoughtcrime.securesms.events.PartProgressEvent +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.rx.RxStore +import org.thoughtcrime.securesms.util.visible + +class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { + private val binding: TransferControlsViewBinding + private val store = RxStore(TransferControlViewState()) + private val disposables = CompositeDisposable().apply { + add(store) + } + + private var previousState = TransferControlViewState() + + init { + binding = TransferControlsViewBinding.inflate(LayoutInflater.from(context), this) + visibility = GONE + isLongClickable = false + layoutTransition = LayoutTransition() + disposables += store.stateFlowable.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { + applyState(it) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + EventBus.getDefault().unregister(this) + disposables.clear() + } + + private fun applyState(currentState: TransferControlViewState) { + when (deriveMode(currentState)) { + Mode.PENDING_GALLERY -> displayPendingGallery(currentState) + Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> displayPendingGalleryWithPlayable(currentState) + Mode.PENDING_SINGLE_ITEM -> displayPendingSingleItem(currentState) + Mode.PENDING_VIDEO_PLAYABLE -> displayPendingPlayableVideo(currentState) + Mode.DOWNLOADING_GALLERY -> displayDownloadingGallery(currentState) + Mode.DOWNLOADING_SINGLE_ITEM -> displayDownloadingSingleItem(currentState) + Mode.DOWNLOADING_VIDEO_PLAYABLE -> displayDownloadingPlayableVideo(currentState) + Mode.UPLOADING_GALLERY -> displayUploadingGallery(currentState) + Mode.UPLOADING_SINGLE_ITEM -> displayUploadingSingleItem(currentState) + Mode.RETRY_DOWNLOADING -> displayRetry(currentState, false) + Mode.RETRY_UPLOADING -> displayRetry(currentState, true) + Mode.GONE -> displayChildrenAsGone(currentState, previousState) + } + + previousState = currentState + } + + private fun deriveMode(currentState: TransferControlViewState): Mode { + if (currentState.slides.isEmpty()) { + return Mode.GONE + } + + if (currentState.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) { + return Mode.GONE + } + + if (currentState.isVisible) { + if (currentState.slides.size == 1) { + val slide = currentState.slides.first() + if (slide.hasVideo()) { + if (currentState.isOutgoing) { + return if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED) { + Mode.UPLOADING_SINGLE_ITEM + } else { + Mode.RETRY_UPLOADING + } + } else { + return if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED) { + if (currentState.playableWhileDownloading) { + Mode.DOWNLOADING_VIDEO_PLAYABLE + } else { + Mode.DOWNLOADING_SINGLE_ITEM + } + } else if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_FAILED) { + Mode.RETRY_DOWNLOADING + } else { + if (currentState.playableWhileDownloading) { + Mode.PENDING_VIDEO_PLAYABLE + } else { + Mode.PENDING_SINGLE_ITEM + } + } + } + } else { + return if (currentState.isOutgoing) { + if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED) { + Mode.UPLOADING_SINGLE_ITEM + } else { + Mode.RETRY_UPLOADING + } + } else { + return when (slide.transferState) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> { + Mode.DOWNLOADING_SINGLE_ITEM + } + + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { + Mode.RETRY_DOWNLOADING + } + + else -> { + Mode.PENDING_SINGLE_ITEM + } + } + } + } + } else { + when (getTransferState(currentState.slides)) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> { + return if (currentState.isOutgoing) { + Mode.UPLOADING_GALLERY + } else { + Mode.DOWNLOADING_GALLERY + } + } + + AttachmentTable.TRANSFER_PROGRESS_PENDING -> { + return if (containsPlayableSlides(currentState.slides)) { + Mode.PENDING_GALLERY_CONTAINS_PLAYABLE + } else { + Mode.PENDING_GALLERY + } + } + + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { + return if (currentState.isOutgoing) { + Mode.RETRY_UPLOADING + } else { + Mode.RETRY_DOWNLOADING + } + } + + AttachmentTable.TRANSFER_PROGRESS_DONE -> { + return Mode.GONE + } + } + } + } else { + return Mode.GONE + } + + Log.i(TAG, "Hit default mode case, this should not happen.") + return Mode.GONE + } + + private fun displayPendingGallery(currentState: TransferControlViewState) { + binding.primaryProgressView.startClickListener = currentState.downloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView, binding.primaryDetailsText), listOf(binding.secondaryProgressView, binding.playVideoButton)) + binding.primaryProgressView.setStopped(false) + showAllViews( + playVideoButton = false, + secondaryProgressView = false, + secondaryDetailsText = currentState.showSecondaryText + ) + + val remainingSlides = currentState.slides.filterNot { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE } + val downloadCount = remainingSlides.size + binding.primaryDetailsText.text = context.resources.getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount) + val byteCount = remainingSlides.sumOf { it.asAttachment().size } + binding.secondaryDetailsText.text = Formatter.formatShortFileSize(context, byteCount) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayPendingGalleryWithPlayable(currentState: TransferControlViewState) { + binding.secondaryProgressView.startClickListener = currentState.downloadClickedListener + super.setClickable(false) + binding.secondaryProgressView.isClickable = currentState.showSecondaryText + binding.secondaryProgressView.isFocusable = currentState.showSecondaryText + binding.primaryProgressView.isClickable = false + binding.primaryProgressView.isFocusable = false + showAllViews( + playVideoButton = false, + primaryProgressView = false, + primaryDetailsText = false, + secondaryProgressView = currentState.showSecondaryText, + secondaryDetailsText = currentState.showSecondaryText + ) + + binding.secondaryProgressView.setStopped(false) + + val byteCount = currentState.slides.sumOf { it.asAttachment().size } + binding.secondaryDetailsText.text = Formatter.formatShortFileSize(context, byteCount) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayPendingSingleItem(currentState: TransferControlViewState) { + binding.primaryProgressView.startClickListener = currentState.downloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton)) + binding.primaryProgressView.setStopped(false) + showAllViews( + playVideoButton = false, + primaryDetailsText = false, + secondaryProgressView = false, + secondaryDetailsText = currentState.showSecondaryText + ) + val byteCount = currentState.slides.sumOf { it.asAttachment().size } + binding.secondaryDetailsText.text = Formatter.formatShortFileSize(context, byteCount) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayPendingPlayableVideo(currentState: TransferControlViewState) { + binding.secondaryProgressView.startClickListener = currentState.downloadClickedListener + binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener) + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView, binding.playVideoButton), listOf(binding.primaryProgressView)) + binding.secondaryProgressView.setStopped(false) + showAllViews( + primaryProgressView = false, + primaryDetailsText = false, + secondaryDetailsText = currentState.showSecondaryText, + secondaryProgressView = currentState.showSecondaryText + ) + val byteCount = currentState.slides.sumOf { it.asAttachment().size } + binding.secondaryDetailsText.text = Formatter.formatShortFileSize(context, byteCount) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayDownloadingGallery(currentState: TransferControlViewState) { + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton)) + showAllViews( + playVideoButton = false, + primaryProgressView = false, + primaryDetailsText = false, + secondaryDetailsText = currentState.showSecondaryText + ) + + val progress = calculateProgress(currentState) + if (progress == 0f) { + binding.secondaryProgressView.setUploading(progress) + } else { + binding.secondaryProgressView.cancelClickListener = currentState.cancelDownloadClickedListener + binding.secondaryProgressView.setDownloading(progress) + } + + binding.secondaryDetailsText.text = deriveSecondaryDetailsText(currentState) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayDownloadingSingleItem(currentState: TransferControlViewState) { + binding.primaryProgressView.cancelClickListener = currentState.cancelDownloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton)) + showAllViews( + playVideoButton = false, + primaryDetailsText = false, + secondaryProgressView = false, + secondaryDetailsText = currentState.showSecondaryText + ) + + val progress = calculateProgress(currentState) + if (progress == 0f) { + binding.primaryProgressView.setUploading(progress) + } else { + binding.primaryProgressView.setDownloading(progress) + } + + binding.secondaryDetailsText.text = deriveSecondaryDetailsText(currentState) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayDownloadingPlayableVideo(currentState: TransferControlViewState) { + binding.secondaryProgressView.cancelClickListener = currentState.cancelDownloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView, binding.playVideoButton), listOf(binding.primaryProgressView)) + showAllViews( + primaryDetailsText = false, + secondaryProgressView = currentState.showSecondaryText, + secondaryDetailsText = currentState.showSecondaryText + ) + + binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener) + + val progress = calculateProgress(currentState) + if (progress == 0f) { + binding.secondaryProgressView.setUploading(progress) + } else { + binding.secondaryProgressView.setDownloading(progress) + } + binding.secondaryDetailsText.text = deriveSecondaryDetailsText(currentState) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayUploadingSingleItem(currentState: TransferControlViewState) { + binding.secondaryProgressView.startClickListener = currentState.downloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton)) + showAllViews( + playVideoButton = false, + primaryProgressView = false, + primaryDetailsText = false, + secondaryDetailsText = currentState.showSecondaryText + ) + + val progress = calculateProgress(currentState) + binding.secondaryProgressView.setUploading(progress) + + binding.secondaryDetailsText.text = deriveSecondaryDetailsText(currentState) + binding.secondaryDetailsText.invalidate() + requestLayout() + } + + private fun displayUploadingGallery(currentState: TransferControlViewState) { + binding.secondaryProgressView.startClickListener = currentState.downloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton)) + showAllViews( + playVideoButton = false, + primaryProgressView = false, + primaryDetailsText = false + ) + + val progress = calculateProgress(currentState) + binding.secondaryProgressView.setUploading(progress) + + binding.secondaryDetailsText.text = deriveSecondaryDetailsText(currentState) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayRetry(currentState: TransferControlViewState, isUploading: Boolean) { + binding.secondaryProgressView.startClickListener = currentState.downloadClickedListener + applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton)) + showAllViews( + playVideoButton = false, + primaryProgressView = false, + primaryDetailsText = false, + secondaryDetailsText = currentState.showSecondaryText + ) + + binding.secondaryProgressView.setStopped(isUploading) + binding.secondaryDetailsText.text = resources.getString(R.string.NetworkFailure__retry) + binding.secondaryDetailsText.invalidate() + binding.secondaryDetailsText.forceLayout() + requestLayout() + } + + private fun displayChildrenAsGone(currentState: TransferControlViewState, prevState: TransferControlViewState) { + children.forEach { + it.visible = false + } + } + + /** + * Shows all views by defaults, but allows individual views to be overridden to not be shown. + * + * @param root + * @param playVideoButton + * @param primaryProgressView + * @param primaryDetailsText + * @param secondaryProgressView + * @param secondaryDetailsText + */ + private fun showAllViews( + root: Boolean = true, + playVideoButton: Boolean = true, + primaryProgressView: Boolean = true, + primaryDetailsText: Boolean = true, + secondaryProgressView: Boolean = true, + secondaryDetailsText: Boolean = true + ) { + this.visible = root + binding.playVideoButton.visible = playVideoButton + binding.primaryProgressView.visibility = if (primaryProgressView) View.VISIBLE else View.INVISIBLE + binding.primaryDetailsText.visible = primaryDetailsText + binding.primaryBackground.visible = primaryProgressView || primaryDetailsText || playVideoButton + binding.secondaryProgressView.visible = secondaryProgressView + binding.secondaryDetailsText.visible = secondaryDetailsText + binding.secondaryBackground.visible = secondaryProgressView || secondaryDetailsText + val textPadding = if (secondaryProgressView) 0 else context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin) + ViewUtil.setPaddingStart(binding.secondaryDetailsText, textPadding) + if (ViewUtil.isLtr(binding.secondaryDetailsText)) { + (binding.secondaryDetailsText.layoutParams as MarginLayoutParams).leftMargin = textPadding + } else { + (binding.secondaryDetailsText.layoutParams as MarginLayoutParams).rightMargin = textPadding + } + } + + private fun applyFocusableAndClickable(currentState: TransferControlViewState, activeViews: List, inactiveViews: List) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val focusIntDef = if (currentState.isFocusable) View.FOCUSABLE else View.NOT_FOCUSABLE + activeViews.forEach { it.focusable = focusIntDef } + inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE } + } + activeViews.forEach { it.isClickable = currentState.isClickable } + inactiveViews.forEach { it.isClickable = false } + } + + override fun setFocusable(focusable: Boolean) { + super.setFocusable(false) + store.update { it.copy(isFocusable = focusable) } + } + + override fun setClickable(clickable: Boolean) { + super.setClickable(false) + store.update { it.copy(isClickable = clickable) } + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEventAsync(event: PartProgressEvent) { + val attachment = event.attachment + store.update { + if (!it.networkProgress.containsKey(attachment)) { + return@update it + } + + if (event.type == PartProgressEvent.Type.COMPRESSION) { + val mutableMap = it.compressionProgress.toMutableMap() + mutableMap[attachment] = Progress.fromEvent(event) + return@update it.copy(compressionProgress = mutableMap.toMap()) + } else { + val mutableMap = it.networkProgress.toMutableMap() + mutableMap[attachment] = Progress.fromEvent(event) + return@update it.copy(networkProgress = mutableMap.toMap()) + } + } + } + + fun setSlides(slides: List) { + require(slides.isNotEmpty()) { "Must provide at least one slide." } + store.update { state -> + val isNewSlideSet = !isUpdateToExistingSet(state, slides) + val networkProgress: MutableMap = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap() + if (isNewSlideSet) { + slides.forEach { networkProgress[it.asAttachment()] = Progress(0L, it.fileSize) } + } + val compressionProgress: MutableMap = if (isNewSlideSet) HashMap() else state.compressionProgress.toMutableMap() + var allStreamableOrDone = true + for (slide in slides) { + val attachment = slide.asAttachment() + if (attachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { + networkProgress[attachment] = Progress(1L, attachment.size) + } else if (!MediaUtil.isInstantVideoSupported(slide)) { + allStreamableOrDone = false + } + } + val playableWhileDownloading = allStreamableOrDone + val isOutgoing = slides.any { it.asAttachment().uploadTimestamp == 0L } + + state.copy( + slides = slides, + networkProgress = networkProgress, + compressionProgress = compressionProgress, + playableWhileDownloading = playableWhileDownloading, + isOutgoing = isOutgoing + ) + } + } + + private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List): Boolean { + if (slides.size != currentState.networkProgress.size) { + return false + } + for (slide in slides) { + if (!currentState.networkProgress.containsKey(slide.asAttachment())) { + return false + } + } + return true + } + + fun setDownloadClickListener(listener: OnClickListener) { + store.update { + it.copy(downloadClickedListener = listener) + } + } + + fun setCancelClickListener(listener: OnClickListener) { + store.update { + it.copy(cancelDownloadClickedListener = listener) + } + } + + fun setInstantPlaybackClickListener(listener: OnClickListener) { + store.update { + it.copy(instantPlaybackClickListener = listener) + } + } + + fun clear() { + clearAnimation() + visibility = GONE + store.update { TransferControlViewState() } + } + + fun setShowSecondaryText(showSecondaryText: Boolean) { + store.update { + it.copy(showSecondaryText = showSecondaryText) + } + } + + fun setVisible(isVisible: Boolean) { + store.update { + it.copy(isVisible = isVisible) + } + } + + private fun isCompressing(state: TransferControlViewState): Boolean { + // We never get a completion event so it never actually reaches 100% + return state.compressionProgress.sumTotal() > 0 && state.compressionProgress.values.map { it.completed.toFloat() / it.total }.sum() < 0.99f + } + + private fun calculateProgress(state: TransferControlViewState): Float { + val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.toFloat() / it.total }.sum() + val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.toFloat() / it.total }.sum() + val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress + val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat() + return weightedProgress / weightedTotal + } + + @SuppressLint("SetTextI18n") + private fun deriveSecondaryDetailsText(currentState: TransferControlViewState): String { + return if (isCompressing(currentState)) { + return context.getString(R.string.TransferControlView__processing) + } else { + val progressText = Formatter.formatShortFileSize(context, currentState.networkProgress.sumCompleted()) + val totalText = Formatter.formatShortFileSize(context, currentState.networkProgress.sumTotal()) + "$progressText/$totalText" + } + } + + companion object { + private const val TAG = "TransferControlView" + private const val UPLOAD_TASK_WEIGHT = 1 + + /** + * A weighting compared to [.UPLOAD_TASK_WEIGHT] + */ + private const val COMPRESSION_TASK_WEIGHT = 3 + + @JvmStatic + fun getTransferState(slides: List): Int { + var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE + var allFailed = true + for (slide in slides) { + if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) { + allFailed = false + transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { + slide.transferState + } else { + transferState.coerceAtLeast(slide.transferState) + } + } + } + return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState + } + + @JvmStatic + fun containsPlayableSlides(slides: List): Boolean { + return slides.any { MediaUtil.isInstantVideoSupported(it) } + } + } + + data class Progress(val completed: Long, val total: Long) { + companion object { + fun fromEvent(event: PartProgressEvent): Progress { + return Progress(event.progress, event.total) + } + } + } + + private fun Map.sumCompleted(): Long { + return this.values.sumOf { it.completed } + } + + private fun Map.sumTotal(): Long { + return this.values.sumOf { it.total } + } + + enum class Mode { + PENDING_GALLERY, + PENDING_GALLERY_CONTAINS_PLAYABLE, + PENDING_SINGLE_ITEM, + PENDING_VIDEO_PLAYABLE, + DOWNLOADING_GALLERY, + DOWNLOADING_SINGLE_ITEM, + DOWNLOADING_VIDEO_PLAYABLE, + UPLOADING_GALLERY, + UPLOADING_SINGLE_ITEM, + RETRY_DOWNLOADING, + RETRY_UPLOADING, + GONE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt new file mode 100644 index 0000000000..2a1d41b6ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.transfercontrols + +import android.view.View +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.mms.Slide + +data class TransferControlViewState( + val isVisible: Boolean = true, + val isFocusable: Boolean = true, + val isClickable: Boolean = true, + val slides: List = emptyList(), + val downloadClickedListener: View.OnClickListener? = null, + val cancelDownloadClickedListener: View.OnClickListener? = null, + val instantPlaybackClickListener: View.OnClickListener? = null, + val showSecondaryText: Boolean = true, + val networkProgress: Map = HashMap(), + val compressionProgress: Map = HashMap(), + val playableWhileDownloading: Boolean = false, + val isOutgoing: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt new file mode 100644 index 0000000000..ed9983db25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.transfercontrols + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.graphics.withTranslation +import org.signal.core.util.dp +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import kotlin.math.roundToInt + +/** + * This displays a circular progress around an icon. The icon is either an upload arrow, a download arrow, or a rectangular stop button. + */ +class TransferProgressView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : View(context, attrs, defStyleAttr, defStyleRes) { + companion object { + const val TAG = "TransferProgressView" + private const val PROGRESS_ARC_STROKE_WIDTH = 1.5f + private const val ICON_INSET_PERCENT = 0.2f + } + + private val progressRect = RectF() + private val stopIconRect = RectF() + private val progressPaint = progressPaint() + private val stopIconPaint = stopIconPaint() + private val trackPaint = trackPaint() + + private var progressPercent = 0f + private var currentState = State.UNINITIALIZED + + private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_down_24) + private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_up_16) + + var startClickListener: OnClickListener? = null + var cancelClickListener: OnClickListener? = null + + init { + val tint = ContextCompat.getColor(context, R.color.signal_colorOnCustom) + val filter = PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_ATOP) + downloadDrawable?.colorFilter = filter + uploadDrawable?.colorFilter = filter + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + when (currentState) { + State.IN_PROGRESS_CANCELABLE, State.IN_PROGRESS_NON_CANCELABLE -> drawProgress(canvas, progressPercent) + State.READY_TO_UPLOAD -> sizeAndDrawDrawable(canvas, uploadDrawable) + State.READY_TO_DOWNLOAD -> sizeAndDrawDrawable(canvas, downloadDrawable) + State.UNINITIALIZED -> Unit + } + } + + fun setDownloading(progress: Float) { + currentState = State.IN_PROGRESS_CANCELABLE + if (cancelClickListener == null) { + Log.i(TAG, "Illegal click listener attached.") + } else { + setOnClickListener(cancelClickListener) + } + progressPercent = progress + invalidate() + } + + fun setUploading(progress: Float) { + currentState = State.IN_PROGRESS_NON_CANCELABLE + setOnClickListener { Log.d(TAG, "Not allowed to click an upload.") } + progressPercent = progress + invalidate() + } + + fun setStopped(isUpload: Boolean) { + val newState = if (isUpload) State.READY_TO_UPLOAD else State.READY_TO_DOWNLOAD + currentState = newState + if (startClickListener == null) { + Log.i(TAG, "Illegal click listener attached.") + } else { + setOnClickListener(startClickListener) + } + progressPercent = 0f + invalidate() + } + + private fun drawProgress(canvas: Canvas, progressPercent: Float) { + val miniIcon = height < 32.dp + val stopIconCornerRadius = if (miniIcon) 1f.dp else 4f.dp + val iconSize: Float = if (miniIcon) 5.5f.dp else 16f.dp + stopIconRect.set(0f, 0f, iconSize, iconSize) + + canvas.withTranslation(width / 2 - (iconSize / 2), height / 2 - (iconSize / 2)) { + drawRoundRect(stopIconRect, stopIconCornerRadius, stopIconCornerRadius, stopIconPaint) + } + + val widthDp = PROGRESS_ARC_STROKE_WIDTH.dp + val inset = 2.dp + progressRect.top = widthDp + inset + progressRect.left = widthDp + inset + progressRect.right = (width - widthDp) - inset + progressRect.bottom = (height - widthDp) - inset + + canvas.drawArc(progressRect, 0f, 360f, false, trackPaint) + canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint) + } + + private fun stopIconPaint(): Paint { + val stopIconPaint = Paint() + stopIconPaint.color = ContextCompat.getColor(context, R.color.signal_colorOnCustom) + stopIconPaint.isAntiAlias = true + stopIconPaint.style = Paint.Style.FILL + return stopIconPaint + } + + private fun trackPaint(): Paint { + val trackPaint = Paint() + trackPaint.color = ContextCompat.getColor(context, R.color.signal_colorTransparent2) + trackPaint.isAntiAlias = true + trackPaint.style = Paint.Style.STROKE + trackPaint.strokeWidth = PROGRESS_ARC_STROKE_WIDTH.dp + return trackPaint + } + + private fun progressPaint(): Paint { + val progressPaint = Paint() + progressPaint.color = ContextCompat.getColor(context, R.color.signal_colorOnCustom) + progressPaint.isAntiAlias = true + progressPaint.style = Paint.Style.STROKE + progressPaint.strokeWidth = PROGRESS_ARC_STROKE_WIDTH.dp + return progressPaint + } + + private fun sizeAndDrawDrawable(canvas: Canvas, drawable: Drawable?) { + if (drawable == null) { + Log.w(TAG, "Could not load icon for $currentState") + return + } + + drawable.setBounds( + (width * ICON_INSET_PERCENT).roundToInt(), + (height * ICON_INSET_PERCENT).roundToInt(), + (width * (1 - ICON_INSET_PERCENT)).roundToInt(), + (height * (1 - ICON_INSET_PERCENT)).roundToInt() + ) + + drawable.draw(canvas) + } + + private enum class State { + IN_PROGRESS_CANCELABLE, + IN_PROGRESS_NON_CANCELABLE, + READY_TO_UPLOAD, + READY_TO_DOWNLOAD, + UNINITIALIZED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index b048453ea7..10835aefdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -63,12 +63,16 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.collect.Sets; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.DimensionUnit; import org.signal.core.util.StringUtil; import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallLinkRootKey; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.badges.gifts.GiftMessageView; @@ -106,8 +110,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; +import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; import org.thoughtcrime.securesms.jobs.MmsDownloadJob; import org.thoughtcrime.securesms.jobs.MmsSendJob; @@ -197,42 +203,42 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private Optional previousMessage; private ConversationItemDisplayMode displayMode; - protected ConversationItemBodyBubble bodyBubble; - protected View reply; - protected View replyIcon; - @Nullable protected ViewGroup contactPhotoHolder; - @Nullable private QuoteView quoteView; - private EmojiTextView bodyText; - private ConversationItemFooter footer; - @Nullable private ConversationItemFooter stickerFooter; - @Nullable private TextView groupSender; - @Nullable private View groupSenderHolder; - private AvatarImageView contactPhoto; - private AlertView alertView; - protected ReactionsConversationView reactionsView; - protected BadgeImageView badgeImageView; - private View storyReactionLabelWrapper; - private TextView storyReactionLabel; - protected View quotedIndicator; - protected View scheduledIndicator; + private ConversationItemBodyBubble bodyBubble; + private View reply; + private View replyIcon; + @Nullable private ViewGroup contactPhotoHolder; + @Nullable private QuoteView quoteView; + private EmojiTextView bodyText; + private ConversationItemFooter footer; + @Nullable private ConversationItemFooter stickerFooter; + @Nullable private TextView groupSender; + @Nullable private View groupSenderHolder; + private AvatarImageView contactPhoto; + private AlertView alertView; + private ReactionsConversationView reactionsView; + private BadgeImageView badgeImageView; + private View storyReactionLabelWrapper; + private TextView storyReactionLabel; + private View quotedIndicator; + private View scheduledIndicator; - private @NonNull Set batchSelected = new HashSet<>(); - private @NonNull Outliner outliner = new Outliner(); - private @NonNull Outliner pulseOutliner = new Outliner(); - private @NonNull List outliners = new ArrayList<>(2); - private LiveRecipient conversationRecipient; - private NullableStub mediaThumbnailStub; - private Stub audioViewStub; - private Stub documentViewStub; - private Stub sharedContactStub; - private Stub linkPreviewStub; - private Stub stickerStub; - private Stub revealableStub; - private Stub joinCallLinkStub; - private Stub