New attachment download UI.

This commit is contained in:
Nicholas
2023-10-05 19:07:37 -04:00
committed by Nicholas Tinsley
parent 1f41b9e481
commit 82956c4149
31 changed files with 1487 additions and 590 deletions

View File

@@ -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

View File

@@ -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<TransferControlView> transferControls;
private final ViewGroup albumCellContainer;
private final Stub<TransferControlView> 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<Slide> 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<Slide> 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) {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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);
}

View File

@@ -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<TransferControlView> transferControlViewStub;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private SlideClickListener progressWheelClickListener = null;
private Slide slide = null;
private final Stub<TransferControlView> 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<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Drawable> 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<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Drawable> 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<Bitmap> buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Bitmap> 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<Drawable> {
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);
}
}
}

View File

@@ -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<TransferControlView>) {
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)
}
}
}

View File

@@ -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<Slide> slides;
@Nullable private View current;
private final ProgressWheel progressWheel;
private final View downloadDetails;
private final TextView downloadDetailsText;
private final Map<Attachment, Float> networkProgress;
private final Map<Attachment, Float> 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<Slide> 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<Slide> 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<Slide> 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<Slide> 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<Attachment, Float> uploadDownloadProgress, Map<Attachment, Float> 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));
}
}
}

View File

@@ -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<View>, inactiveViews: List<View>) {
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<Slide>) {
require(slides.isNotEmpty()) { "Must provide at least one slide." }
store.update { state ->
val isNewSlideSet = !isUpdateToExistingSet(state, slides)
val networkProgress: MutableMap<Attachment, Progress> = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap()
if (isNewSlideSet) {
slides.forEach { networkProgress[it.asAttachment()] = Progress(0L, it.fileSize) }
}
val compressionProgress: MutableMap<Attachment, Progress> = 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<Slide>): 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<Slide>): 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<Slide>): 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<Attachment, Progress>.sumCompleted(): Long {
return this.values.sumOf { it.completed }
}
private fun Map<Attachment, Progress>.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
}
}

View File

@@ -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<Slide> = emptyList(),
val downloadClickedListener: View.OnClickListener? = null,
val cancelDownloadClickedListener: View.OnClickListener? = null,
val instantPlaybackClickListener: View.OnClickListener? = null,
val showSecondaryText: Boolean = true,
val networkProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val compressionProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val playableWhileDownloading: Boolean = false,
val isOutgoing: Boolean = false
)

View File

@@ -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
}
}

View File

@@ -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<MessageRecord> 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<MultiselectPart> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
private @NonNull Outliner pulseOutliner = new Outliner();
private @NonNull List<Outliner> outliners = new ArrayList<>(2);
private LiveRecipient conversationRecipient;
private NullableStub<ConversationItemThumbnail> mediaThumbnailStub;
private Stub<AudioView> audioViewStub;
private Stub<DocumentView> documentViewStub;
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private Stub<CallLinkJoinButton> joinCallLinkStub;
private Stub<Button> callToActionStub;
private Stub<GiftMessageView> giftViewStub;
private Stub<PaymentMessageView> paymentViewStub;
private @Nullable EventListener eventListener;
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
private final @NonNull Outliner outliner = new Outliner();
private final @NonNull Outliner pulseOutliner = new Outliner();
private final @NonNull List<Outliner> outliners = new ArrayList<>(2);
private LiveRecipient conversationRecipient;
private NullableStub<ConversationItemThumbnail> mediaThumbnailStub;
private Stub<AudioView> audioViewStub;
private Stub<DocumentView> documentViewStub;
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private Stub<CallLinkJoinButton> joinCallLinkStub;
private Stub<Button> callToActionStub;
private Stub<GiftMessageView> giftViewStub;
private Stub<PaymentMessageView> paymentViewStub;
private @Nullable EventListener eventListener;
private int defaultBubbleColor;
private int defaultBubbleColorForWallpaper;
@@ -241,7 +247,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final ProgressWheelClickListener progressWheelClickListener = new ProgressWheelClickListener();
private final PlayVideoClickListener playVideoClickListener = new PlayVideoClickListener();
private final AttachmentCancelClickListener attachmentCancelClickListener = new AttachmentCancelClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
@@ -256,14 +263,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final Context context;
private MediaItem mediaItem;
private boolean canPlayContent;
private Projection.Corners bodyBubbleCorners;
private Colorizer colorizer;
private boolean hasWallpaper;
private float lastYDownRelativeToThis;
private ProjectionList colorizerProjections = new ProjectionList(3);
private boolean isBound = false;
private MediaItem mediaItem;
private boolean canPlayContent;
private Projection.Corners bodyBubbleCorners;
private Colorizer colorizer;
private boolean hasWallpaper;
private float lastYDownRelativeToThis;
private final ProjectionList colorizerProjections = new ProjectionList(3);
private boolean isBound = false;
private final Runnable shrinkBubble = new Runnable() {
@Override
@@ -709,6 +716,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyBubble.setVideoPlayerProjection(null);
bodyBubble.setQuoteViewProjection(null);
playVideoClickListener.cleanup();
glideRequests = null;
}
@@ -1170,7 +1179,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
mediaThumbnailStub.require().setCancelDownloadClickListener(attachmentCancelClickListener);
mediaThumbnailStub.require().setPlayVideoClickListener(playVideoClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false);
@@ -1304,17 +1314,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
: R.dimen.media_bubble_min_width_with_content));
mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(isContentCondensed() ? R.dimen.media_bubble_max_height_condensed
: R.dimen.media_bubble_max_height));
mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setCancelDownloadClickListener(attachmentCancelClickListener);
mediaThumbnailStub.require().setPlayVideoClickListener(playVideoClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.require().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.require().showShade(messageRecord.isDisplayBodyEmpty(getContext()) && !hasExtraText(messageRecord));
mediaThumbnailStub.require().setImageResource(glideRequests,
thumbnailSlides,
showControls,
false);
mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.require().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.require().showShade(messageRecord.isDisplayBodyEmpty(getContext()) && !hasExtraText(messageRecord));
if (!messageRecord.isOutgoing()) {
mediaThumbnailStub.require().setConversationColor(getDefaultBubbleColor(hasWallpaper));
} else {
@@ -2441,16 +2452,82 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class ProgressWheelClickListener implements SlideClickListener {
private class AttachmentCancelClickListener implements SlidesClickedListener {
@Override
public void onClick(View v, List<Slide> slides) {
Log.i(TAG, "onClick() for attachment cancellation");
final JobManager jobManager = ApplicationDependencies.getJobManager();
if (messageRecord.isMmsNotification()) {
Log.i(TAG, "Canceling MMS attachments download");
jobManager.cancel("mms-operation");
} else {
Log.i(TAG, "Canceling push attachment downloads for " + slides.size() + " items");
for (Slide slide : slides) {
final String queue = AttachmentDownloadJob.constructQueueString(((DatabaseAttachment) slide.asAttachment()).getAttachmentId());
jobManager.cancelAllInQueue(queue);
}
}
}
}
private class PlayVideoClickListener implements SlideClickListener {
private static final float MINIMUM_DOWNLOADED_THRESHOLD = 0.05f;
private View parentView;
private Slide activeSlide;
@Override
public void onClick(View v, Slide slide) {
final boolean isIncremental = slide.asAttachment().getIncrementalDigest() != null;
final boolean contentTypeSupported = MediaUtil.isVideoType(slide.getContentType());
if (FeatureFlags.instantVideoPlayback() && isIncremental && contentTypeSupported) {
launchMediaPreview(v, slide);
if (messageRecord.isOutgoing()) {
Log.d(TAG, "Video player button for outgoing slide clicked.");
return;
}
if (MediaUtil.isInstantVideoSupported(slide)) {
final DatabaseAttachment databaseAttachment = (DatabaseAttachment) slide.asAttachment();
if (databaseAttachment.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_STARTED) {
final AttachmentId attachmentId = databaseAttachment.getAttachmentId();
final JobManager jobManager = ApplicationDependencies.getJobManager();
final String queue = AttachmentDownloadJob.constructQueueString(attachmentId);
setup(v, slide);
jobManager.add(new AttachmentDownloadJob(messageRecord.getId(),
attachmentId,
true));
jobManager.addListener(queue, (job, jobState) -> {
if (jobState.isComplete()) {
cleanup();
}
});
} else {
launchMediaPreview(v, slide);
cleanup();
}
} else {
Log.d(TAG, "Non-eligible slide clicked: " + "\tisIncremental: " + isIncremental + "\tcontentTypeSupported: " + contentTypeSupported);
Log.d(TAG, "Non-eligible slide clicked.");
}
}
private void setup(View v, Slide slide) {
parentView = v;
activeSlide = slide;
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
}
private void cleanup() {
parentView = null;
activeSlide = null;
if (EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().unregister(this);
}
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(PartProgressEvent event) {
float progressPercent = ((float) event.progress) / event.total;
final View currentParentView = parentView;
final Slide currentActiveSlide = activeSlide;
if (progressPercent >= MINIMUM_DOWNLOADED_THRESHOLD && currentParentView != null && currentActiveSlide != null) {
cleanup();
launchMediaPreview(currentParentView, currentActiveSlide);
}
}
}
@@ -2592,7 +2669,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private class ClickListener implements View.OnClickListener {
private OnClickListener parent;
private final OnClickListener parent;
ClickListener(@Nullable OnClickListener parent) {
this.parent = parent;
@@ -2724,8 +2801,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (message > -1) builder.setMessage(message);
builder.setPositiveButton(R.string.yes, (dialogInterface, i) -> {
MessageTable db = messageRecord.isMms() ? SignalDatabase.messages()
: SignalDatabase.messages();
MessageTable db = SignalDatabase.messages();
db.markAsInsecure(messageRecord.getId());
db.markAsOutbox(messageRecord.getId());

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs;
import android.text.TextUtils;
@@ -19,9 +24,9 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobLogger;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.notifications.v2.ConversationId;
@@ -33,6 +38,7 @@ import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
@@ -62,18 +68,18 @@ public final class AttachmentDownloadJob extends BaseJob {
private static final String KEY_PAR_UNIQUE_ID = "part_unique_id";
private static final String KEY_MANUAL = "part_manual";
private long messageId;
private long partRowId;
private long partUniqueId;
private boolean manual;
private final long messageId;
private final long partRowId;
private final long partUniqueId;
private final boolean manual;
public AttachmentDownloadJob(long messageId, AttachmentId attachmentId, boolean manual) {
this(new Job.Parameters.Builder()
.setQueue("AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId())
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
.setQueue(constructQueueString(attachmentId))
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
messageId,
attachmentId,
manual);
@@ -102,6 +108,10 @@ public final class AttachmentDownloadJob extends BaseJob {
return KEY;
}
public static String constructQueueString(AttachmentId attachmentId) {
return "AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId();
}
@Override
public void onAdded() {
Log.i(TAG, "onAdded() messageId: " + messageId + " partRowId: " + partRowId + " partUniqueId: " + partUniqueId + " manual: " + manual);
@@ -109,8 +119,8 @@ public final class AttachmentDownloadJob extends BaseJob {
final AttachmentTable database = SignalDatabase.attachments();
final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId);
final DatabaseAttachment attachment = database.getAttachment(attachmentId);
final boolean pending = attachment != null && attachment.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE
&& attachment.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
final boolean pending = attachment != null && attachment.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE
&& attachment.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
if (pending && (manual || AttachmentUtil.isAutoDownloadPermitted(context, attachment))) {
Log.i(TAG, "onAdded() Marking attachment progress as 'started'");
@@ -130,8 +140,8 @@ public final class AttachmentDownloadJob extends BaseJob {
public void doWork() throws IOException, RetryLaterException {
Log.i(TAG, "onRun() messageId: " + messageId + " partRowId: " + partRowId + " partUniqueId: " + partUniqueId + " manual: " + manual);
final AttachmentTable database = SignalDatabase.attachments();
final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId);
final AttachmentTable database = SignalDatabase.attachments();
final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId);
final DatabaseAttachment attachment = database.getAttachment(attachmentId);
if (attachment == null) {
@@ -195,11 +205,20 @@ public final class AttachmentDownloadJob extends BaseJob {
}
SignalServiceMessageReceiver messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver();
SignalServiceAttachmentPointer pointer = createAttachmentPointer(attachment);
InputStream stream = messageReceiver.retrieveAttachment(pointer,
attachmentFile,
maxReceiveSize,
(total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress)));
InputStream stream = messageReceiver.retrieveAttachment(pointer,
attachmentFile,
maxReceiveSize,
new SignalServiceAttachment.ProgressListener() {
@Override
public void onAttachmentProgress(long total, long progress) {
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
}
@Override
public boolean shouldCancel() {
return isCanceled();
}
});
database.insertAttachmentsForPlaceholder(messageId, attachmentId, stream);
} catch (RangeException e) {
Log.w(TAG, "Range exception, file size " + attachmentFile.length(), e);
@@ -299,7 +318,8 @@ public final class AttachmentDownloadJob extends BaseJob {
}
}
@VisibleForTesting static class InvalidPartException extends Exception {
@VisibleForTesting
static class InvalidPartException extends Exception {
InvalidPartException(String s) {super(s);}
InvalidPartException(Exception e) {super(e);}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs;
import android.graphics.Bitmap;
@@ -213,10 +218,18 @@ public final class AttachmentUploadJob extends BaseJob {
.withCaption(attachment.getCaption())
.withCancelationSignal(this::isCanceled)
.withResumableUploadSpec(resumableUploadSpec)
.withListener((total, progress) -> {
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
if (notification != null) {
notification.setProgress(total, progress);
.withListener(new SignalServiceAttachment.ProgressListener() {
@Override
public void onAttachmentProgress(long total, long progress) {
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
if (notification != null) {
notification.setProgress(total, progress);
}
}
@Override
public boolean shouldCancel() {
return isCanceled();
}
});
if (MediaUtil.isImageType(attachment.getContentType())) {

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
@@ -200,7 +205,17 @@ public abstract class PushSendJob extends SendJob {
.withWidth(attachment.getWidth())
.withHeight(attachment.getHeight())
.withCaption(attachment.getCaption())
.withListener((total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress)))
.withListener(new SignalServiceAttachment.ProgressListener() {
@Override
public void onAttachmentProgress(long total, long progress) {
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
}
@Override
public boolean shouldCancel() {
return isCanceled();
}
})
.build();
} catch (IOException ioe) {
Log.w(TAG, "Couldn't open attachment", ioe);

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util;
import android.content.ContentResolver;
@@ -148,12 +153,10 @@ public class MediaUtil {
if (fileExtension == null) {
return mimeType;
}
switch (fileExtension.toLowerCase()) {
case "m4a":
return safeMimeTypeOverride(mimeType, AUDIO_MP4);
default:
return mimeType;
if (fileExtension.toLowerCase().equals("m4a")) {
return safeMimeTypeOverride(mimeType, AUDIO_MP4);
}
return mimeType;
}
public static @Nullable String getCorrectedMimeType(@Nullable String mimeType) {
@@ -401,11 +404,7 @@ public class MediaUtil {
} else if (uri.toString().startsWith("file://") &&
MediaUtil.isVideo(URLConnection.guessContentTypeFromName(uri.toString()))) {
return true;
} else if (PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri))) {
return true;
} else {
return false;
}
} else return PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri));
}
@WorkerThread
@@ -468,6 +467,17 @@ public class MediaUtil {
return mediaMetadataRetriever.getFrameAtTime(timeUs);
}
public static boolean isInstantVideoSupported(Slide slide) {
if (!FeatureFlags.instantVideoPlayback()) {
return false;
}
final Attachment attachment = slide.asAttachment();
final boolean isIncremental = attachment.getIncrementalDigest() != null;
final boolean hasIncrementalMacChunkSizeDefined = attachment.getIncrementalMacChunkSize() > 0;
final boolean contentTypeSupported = isVideoType(slide.getContentType());
return isIncremental && contentTypeSupported && hasIncrementalMacChunkSizeDefined;
}
public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) {
final String[] sections = mimeType.split("/", 2);
return sections.length > 1 ? sections[0] : null;
@@ -476,7 +486,7 @@ public class MediaUtil {
public static class ThumbnailData implements AutoCloseable {
@NonNull private final Bitmap bitmap;
private final float aspectRatio;
private final float aspectRatio;
public ThumbnailData(@NonNull Bitmap bitmap) {
this.bitmap = bitmap;