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 7973ef911a..13980699a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java @@ -172,7 +172,11 @@ public abstract class Attachment { @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..5b64fa17a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -12,6 +12,7 @@ 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; @@ -54,7 +55,7 @@ public class AlbumThumbnailView extends FrameLayout { 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)); + transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); } public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List slides, boolean showControls) { @@ -64,12 +65,13 @@ 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); } }); + transferControls.get().setSlides(slides); + transferControls.setVisibility(VISIBLE); } else { if (transferControls.resolved()) { transferControls.get().setVisibility(GONE); @@ -85,6 +87,7 @@ public class AlbumThumbnailView extends FrameLayout { showSlides(glideRequests, slides); applyCorners(); + forceLayout(); } public void setCellBackgroundColor(@ColorInt int color) { @@ -117,22 +120,25 @@ public class AlbumThumbnailView extends FrameLayout { private void inflateLayout(int sizeClass) { albumCellContainer.removeAllViews(); - switch (sizeClass) { - case 2: - inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer); - break; - case 3: - inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer); - break; - case 4: - inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer); - break; - case 5: - inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer); - break; - default: - inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer); - break; + int resId = switch (sizeClass) { + case 2 -> R.layout.album_thumbnail_2; + case 3 -> R.layout.album_thumbnail_3; + case 4 -> R.layout.album_thumbnail_4; + case 5 -> R.layout.album_thumbnail_5; + default -> R.layout.album_thumbnail_many; + }; + + inflate(getContext(), resId, albumCellContainer); + if (transferControls.resolved()) { + int size = switch (sizeClass) { + case 2 -> R.dimen.album_2_total_height; + case 3 -> R.dimen.album_3_total_height; + case 4 -> R.dimen.album_4_total_height; + default -> R.dimen.album_5_total_height; + }; + final ViewGroup.LayoutParams params = transferControls.get().getLayoutParams(); + params.height = getContext().getResources().getDimensionPixelSize(size); + transferControls.get().setLayoutParams(params); } } 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..21180bfe78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.kt @@ -255,9 +255,17 @@ 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) + ) + + state.applyState(thumbnail, album) + } + + fun setCancelDownloadClickListener(listener: SlidesClickedListener?) { + state = state.copy( + thumbnailViewState = state.thumbnailViewState.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..42c5546a76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnailState.kt @@ -31,7 +31,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 +59,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) } 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..02d5a400b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -31,6 +31,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 +42,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; @@ -80,12 +80,13 @@ public class ThumbnailView extends FrameLayout { private final CornerMask cornerMask; - private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState(); + 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 SlideClickListener thumbnailClickListener = null; + private SlidesClickedListener downloadClickListener = null; + private SlidesClickedListener cancelDownloadClickListener = null; + private SlideClickListener playVideoClickListener = null; + private Slide slide = null; public ThumbnailView(Context context) { @@ -367,10 +368,11 @@ public class ThumbnailView extends FrameLayout { } transferControlsState = transferControlsState.withSlide(slide) - .withDownloadClickListener(new DownloadClickDispatcher()); + .withDownloadClickListener(new DownloadClickDispatcher()) + .withCancelDownloadClickListener(new CancelClickDispatcher()); - if (FeatureFlags.instantVideoPlayback()) { - transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher()); + if (MediaUtil.isInstantVideoSupported(slide)) { + transferControlsState = transferControlsState.withInstantPlaybackClickListener(new ProgressWheelClickDispatcher()); } transferControlsState.applyState(transferControlViewStub); @@ -525,8 +527,12 @@ 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; } public void clear(GlideRequests glideRequests) { @@ -568,9 +574,9 @@ public class ThumbnailView extends FrameLayout { 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())); + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE) + .transition(withCrossFade())); boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23; @@ -625,7 +631,7 @@ public class ThumbnailView extends FrameLayout { if (Util.equals(slide, other)) { if (slide != null && other != null) { - byte[] digestLeft = slide.asAttachment().getDigest(); + byte[] digestLeft = slide.asAttachment().getDigest(); byte[] digestRight = other.asAttachment().getDigest(); return Arrays.equals(digestLeft, digestRight); @@ -670,14 +676,26 @@ public class ThumbnailView extends FrameLayout { } } + private class CancelClickDispatcher implements View.OnClickListener { + @Override + public void onClick(View view) { + Log.i(TAG, "onClick() for cancel button"); + if (cancelDownloadClickListener != null && slide != null) { + cancelDownloadClickListener.onClick(view, Collections.singletonList(slide)); + } else { + Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelDownloadClickListener); + } + } + } + private class ProgressWheelClickDispatcher 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 instant video playback"); + if (playVideoClickListener != null && slide != null) { + playVideoClickListener.onClick(view, slide); } else { - Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener); + Log.w(TAG, "Received an instant video click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + 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 index c8d60ea185..b3577a810c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailViewTransferControlsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailViewTransferControlsState.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components import android.view.View.OnClickListener +import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.util.views.Stub @@ -12,7 +13,8 @@ data class ThumbnailViewTransferControlsState( val isClickable: Boolean = true, val slide: Slide? = null, val downloadClickedListener: OnClickListener? = null, - val progressWheelClickedListener: OnClickListener? = null, + val cancelDownloadClickedListener: OnClickListener? = null, + val instantPlaybackClickListener: OnClickListener? = null, val showDownloadText: Boolean = true ) { @@ -20,7 +22,8 @@ data class ThumbnailViewTransferControlsState( 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 withCancelDownloadClickListener(cancelClickListener: OnClickListener): ThumbnailViewTransferControlsState = copy(cancelDownloadClickedListener = cancelClickListener) + fun withInstantPlaybackClickListener(instantPlaybackClickListener: OnClickListener): ThumbnailViewTransferControlsState = copy(instantPlaybackClickListener = instantPlaybackClickListener) fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText) fun applyState(transferControlView: Stub) { @@ -31,8 +34,9 @@ data class ThumbnailViewTransferControlsState( transferControlView.get().setSlide(slide) } transferControlView.get().setDownloadClickListener(downloadClickedListener) - transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener) transferControlView.get().setShowDownloadText(showDownloadText) + transferControlView.get().setCancelClickListener(cancelDownloadClickedListener) + transferControlView.get().setInstantPlaybackClickListener(instantPlaybackClickListener) } } } 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..f6814eb542 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt @@ -0,0 +1,289 @@ +/* + * 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.text.format.Formatter +import android.util.AttributeSet +import android.view.View +import android.widget.Space +import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageView +import androidx.constraintlayout.widget.ConstraintLayout +import com.annimon.stream.Stream +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 org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.ThrottledDebouncer +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.visible + +class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { + private var slides: List = emptyList() + private var current: MutableSet = HashSet() + private var playableWhileDownloading = false + private var showDownloadText = true + private val downloadDetails: View + private val downloadDetailsText: TextView + private val primaryDetailsText: TextView + private val secondaryViewSpace: Space + private val playVideoButton: AppCompatImageView + private val primaryProgressView: TransferProgressView + private val secondaryProgressView: TransferProgressView + private val networkProgress: MutableMap + private val compressionProgress: MutableMap + private val debouncer: ThrottledDebouncer = ThrottledDebouncer(8) // frame time for 120 Hz + + init { + inflate(context, R.layout.transfer_controls_view, this) + isLongClickable = false + visibility = GONE + layoutTransition = LayoutTransition() + networkProgress = HashMap() + compressionProgress = HashMap() + primaryProgressView = findViewById(R.id.primary_progress_view) + secondaryProgressView = findViewById(R.id.secondary_progress_view) + playVideoButton = findViewById(R.id.play_video_button) + downloadDetails = findViewById(R.id.secondary_background) + downloadDetailsText = findViewById(R.id.download_details_text) + secondaryViewSpace = findViewById(R.id.secondary_view_space) + primaryDetailsText = findViewById(R.id.primary_details_text) + } + + override fun setFocusable(focusable: Boolean) { + super.setFocusable(focusable) + progressView.isFocusable = focusable + if (playVideoButton.visibility == VISIBLE) { + playVideoButton.isFocusable = focusable + } + } + + override fun setClickable(clickable: Boolean) { + super.setClickable(clickable) + secondaryProgressView.isClickable = secondaryProgressView.visible && clickable + primaryProgressView.isClickable = primaryProgressView.visible && clickable + playVideoButton.isClickable = playVideoButton.visible && clickable + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + EventBus.getDefault().unregister(this) + } + + fun setSlide(slides: Slide) { + setSlides(listOf(slides)) + } + + fun setSlides(slides: List) { + require(slides.isNotEmpty()) { "Must provide at least one slide." } + this.slides = slides + if (!isUpdateToExistingSet(slides)) { + networkProgress.clear() + compressionProgress.clear() + slides.forEach { networkProgress[it.asAttachment()] = Progress(0L, it.fileSize) } + } + 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 + } + } + playableWhileDownloading = allStreamableOrDone + setPlayableWhileDownloading(playableWhileDownloading) + val uploading = slides.any { it.asAttachment().uploadTimestamp == 0L } + when (getTransferState(slides)) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> showProgressSpinner(calculateProgress(), uploading) + AttachmentTable.TRANSFER_PROGRESS_PENDING -> { + updateDownloadText() + progressView.setStopped(false) + this.visible = true + } + + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { + downloadDetailsText.setText(R.string.NetworkFailure__retry) + progressView.setStopped(false) + this.visible = true + } + + else -> this.visible = false + } + } + + private val progressView: TransferProgressView + get() = if (playableWhileDownloading) { + secondaryProgressView + } else { + primaryProgressView + } + + @JvmOverloads + fun showProgressSpinner(progress: Float = calculateProgress(), uploading: Boolean = false) { + if (uploading || progress == 0f) { + progressView.setUploading(progress) + } else { + progressView.setDownloading(progress) + } + } + + fun setDownloadClickListener(listener: OnClickListener?) { + primaryProgressView.startClickListener = listener + secondaryProgressView.startClickListener = listener + } + + fun setCancelClickListener(listener: OnClickListener?) { + primaryProgressView.cancelClickListener = listener + secondaryProgressView.cancelClickListener = listener + } + + fun setInstantPlaybackClickListener(onPlayClickedListener: OnClickListener?) { + playVideoButton.setOnClickListener(onPlayClickedListener) + } + + fun clear() { + clearAnimation() + visibility = GONE + if (current.isNotEmpty()) { + for (v in current) { + v.clearAnimation() + v.visibility = GONE + } + } + current.clear() + slides = emptyList() + } + + fun setShowDownloadText(showDownloadText: Boolean) { + this.showDownloadText = showDownloadText + updateDownloadText() + } + + private fun isUpdateToExistingSet(slides: List): Boolean { + if (slides.size != networkProgress.size) { + return false + } + for (slide in slides) { + if (!networkProgress.containsKey(slide.asAttachment())) { + return false + } + } + return true + } + + private fun updateDownloadText() { + val byteCount = slides.sumOf { it.asAttachment().size } + downloadDetailsText.text = Formatter.formatShortFileSize(context, byteCount) + downloadDetailsText.invalidate() + + if (slides.size > 1) { + val downloadCount = Stream.of(slides).reduce(0) { count: Int, slide: Slide -> if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) count + 1 else count } + primaryDetailsText.text = context.resources.getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount) + primaryDetailsText.visible = showDownloadText + } else { + primaryDetailsText.text = "" + primaryDetailsText.visible = false + } + } + + @SuppressLint("SetTextI18n") + private fun updateDownloadProgressText(isCompression: Boolean) { + val context = context + val progress = if (isCompression) compressionProgress.values.sumOf { it.completed } else networkProgress.values.sumOf { it.completed } + val total = if (isCompression) compressionProgress.values.sumOf { it.total } else networkProgress.values.sumOf { it.total } + val progressText = Formatter.formatShortFileSize(context, progress) + val totalText = Formatter.formatShortFileSize(context, total) + downloadDetailsText.text = "$progressText/$totalText" + downloadDetailsText.invalidate() + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEventAsync(event: PartProgressEvent) { + val attachment = event.attachment + if (networkProgress.containsKey(attachment)) { + val proportionCompleted = event.progress.toFloat() / event.total + if (event.type == PartProgressEvent.Type.COMPRESSION) { + compressionProgress[attachment] = Progress.fromEvent(event) + } else { + networkProgress[attachment] = Progress.fromEvent(event) + } + debouncer.publish { + val progress = calculateProgress() + if (attachment.uploadTimestamp == 0L) { + progressView.setUploading(progress) + } else { + progressView.setDownloading(progress) + } + updateDownloadProgressText(event.type == PartProgressEvent.Type.COMPRESSION) + } + } + } + + private fun setPlayableWhileDownloading(playableWhileDownloading: Boolean) { + playVideoButton.visible = playableWhileDownloading + secondaryProgressView.visible = playableWhileDownloading + secondaryViewSpace.visible = !playableWhileDownloading // exists because constraint layout was being very weird about margins and this was the only way + primaryProgressView.visibility = if (playableWhileDownloading) INVISIBLE else VISIBLE + val textPadding = if (playableWhileDownloading) 0 else context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin) + ViewUtil.setPaddingStart(downloadDetailsText, textPadding) + } + + private fun calculateProgress(): Float { + val totalDownloadProgress: Float = networkProgress.values.map { it.completed.toFloat() / it.total }.sum() + val totalCompressionProgress: Float = compressionProgress.values.map { it.completed.toFloat() / it.total }.sum() + val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress + val weightedTotal = (UPLOAD_TASK_WEIGHT * networkProgress.size + COMPRESSION_TASK_WEIGHT * compressionProgress.size).toFloat() + return weightedProgress / weightedTotal + } + + 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 + } + } + + data class Progress(val completed: Long, val total: Long) { + companion object { + fun fromEvent(event: PartProgressEvent): Progress { + return Progress(event.progress, event.total) + } + } + } +} 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..a20a2d77e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt @@ -0,0 +1,169 @@ +/* + * 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 + +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 = 2f + 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.READY_TO_DOWNLOAD + + 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 requestLayout() { + super.requestLayout() + Log.d(TAG, "Requesting new layout.", Exception()) + } + + 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) + } + } + + fun setDownloading(progress: Float) { + if (currentState != State.IN_PROGRESS_CANCELABLE) { + currentState = State.IN_PROGRESS_CANCELABLE + setOnClickListener(cancelClickListener) + } + progressPercent = progress + } + + fun setUploading(progress: Float) { + if (currentState != State.IN_PROGRESS_NON_CANCELABLE) { + currentState = State.IN_PROGRESS_NON_CANCELABLE + setOnClickListener(null) + } + progressPercent = progress + } + + fun setStopped(isUpload: Boolean) { + val newState = if (isUpload) State.READY_TO_UPLOAD else State.READY_TO_DOWNLOAD + if (currentState != newState) { + currentState = newState + setOnClickListener(startClickListener) + } + progressPercent = 0f + } + + private fun drawProgress(canvas: Canvas, progressPercent: Float) { + if (currentState == State.IN_PROGRESS_CANCELABLE) { + val miniIcon = height < 32.dp + val stopIconCornerRadius = if (miniIcon) 1f.dp else 4f.dp + val iconSize: Float = if (miniIcon) 6.6f.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 + } +} 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 bde2d8016c..6f30cf1de9 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; @@ -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(); @@ -1172,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); @@ -1312,7 +1320,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo false); mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener()); mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener); - mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener); + mediaThumbnailStub.require().setCancelDownloadClickListener(attachmentCancelClickListener); + mediaThumbnailStub.require().setPlayVideoClickListener(playVideoClickListener); mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener); mediaThumbnailStub.require().setOnClickListener(passthroughClickListener); mediaThumbnailStub.require().showShade(messageRecord.isDisplayBodyEmpty(getContext()) && !hasExtraText(messageRecord)); @@ -2443,17 +2452,83 @@ 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 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); - } else { - Log.d(TAG, "Non-eligible slide clicked: " + "\tisIncremental: " + isIncremental + "\tcontentTypeSupported: " + contentTypeSupported); + 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."); + } + } + + 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); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java index 62074097c8..be483272dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java @@ -19,9 +19,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 +33,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; @@ -69,11 +70,11 @@ public final class AttachmentDownloadJob extends BaseJob { 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); @@ -109,8 +110,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 +131,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 +196,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); @@ -298,8 +308,13 @@ public final class AttachmentDownloadJob extends BaseJob { } } + public static String constructQueueString(AttachmentId attachmentId) { + return "AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId(); + } + @VisibleForTesting static class InvalidPartException extends Exception { InvalidPartException(String s) {super(s);} + InvalidPartException(Exception e) {super(e);} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java index 7fc444c323..14d1b6348f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java @@ -213,10 +213,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())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 80b1992637..b451c95cec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -200,7 +200,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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index 23688d752d..31137f7b0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -468,6 +468,15 @@ public class MediaUtil { return mediaMetadataRetriever.getFrameAtTime(timeUs); } + public static boolean isInstantVideoSupported(Slide slide) { + if (!FeatureFlags.instantVideoPlayback()) { + return false; + } + final boolean isIncremental = slide.asAttachment().getIncrementalDigest() != null; + final boolean contentTypeSupported = isVideoType(slide.getContentType()); + return isIncremental && contentTypeSupported; + } + public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) { final String[] sections = mimeType.split("/", 2); return sections.length > 1 ? sections[0] : null; diff --git a/app/src/main/res/drawable/transfer_controls_background.xml b/app/src/main/res/drawable/transfer_controls_background.xml index 1ba1aa2ece..484118b2d3 100644 --- a/app/src/main/res/drawable/transfer_controls_background.xml +++ b/app/src/main/res/drawable/transfer_controls_background.xml @@ -4,6 +4,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/transfer_controls_play_background.xml b/app/src/main/res/drawable/transfer_controls_play_background.xml new file mode 100644 index 0000000000..0d8a28c973 --- /dev/null +++ b/app/src/main/res/drawable/transfer_controls_play_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/transfer_controls_stop_icon.xml b/app/src/main/res/drawable/transfer_controls_stop_icon.xml new file mode 100644 index 0000000000..cd6dae63d3 --- /dev/null +++ b/app/src/main/res/drawable/transfer_controls_stop_icon.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_view.xml b/app/src/main/res/layout/album_thumbnail_view.xml index 147bb5850d..eb14c5852a 100644 --- a/app/src/main/res/layout/album_thumbnail_view.xml +++ b/app/src/main/res/layout/album_thumbnail_view.xml @@ -15,8 +15,8 @@ diff --git a/app/src/main/res/layout/thumbnail_view.xml b/app/src/main/res/layout/thumbnail_view.xml index 0dde744ed1..68c093dc97 100644 --- a/app/src/main/res/layout/thumbnail_view.xml +++ b/app/src/main/res/layout/thumbnail_view.xml @@ -71,9 +71,10 @@ diff --git a/app/src/main/res/layout/transfer_controls_stub.xml b/app/src/main/res/layout/transfer_controls_stub.xml index de0156a813..b5c7ecdf2f 100644 --- a/app/src/main/res/layout/transfer_controls_stub.xml +++ b/app/src/main/res/layout/transfer_controls_stub.xml @@ -1,11 +1,11 @@ - diff --git a/app/src/main/res/layout/transfer_controls_view.xml b/app/src/main/res/layout/transfer_controls_view.xml index ed8d4a3f95..2fd7053799 100644 --- a/app/src/main/res/layout/transfer_controls_view.xml +++ b/app/src/main/res/layout/transfer_controls_view.xml @@ -1,56 +1,135 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:parentTag="org.thoughtcrime.securesms.components.transfercontrols.TransferControlView" + tools:viewBindingIgnore="true"> - + - + + + + + + + + + + + + + + + + + + - - - - - - - + app:layout_constraintGuide_begin="8dp" /> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 79d93ecd4c..b980ce74e9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -153,6 +153,7 @@ 64dp 32dp 24dp + 4dp 80dp 70dp diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java index c7c3692676..665e1820ce 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java @@ -176,5 +176,6 @@ public abstract class SignalServiceAttachment { * @param progress The amount that has been transmitted/received in bytes thus far */ public void onAttachmentProgress(long total, long progress); + boolean shouldCancel(); } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index a739e8972d..f14bcb0a30 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -1445,7 +1445,7 @@ public class PushServiceSocket { } } - private void downloadFromCdn(File destination, int cdnNumber, String path, long maxSizeBytes, ProgressListener listener) + private void downloadFromCdn(File destination, int cdnNumber, String path, long maxSizeBytes, ProgressListener listener) throws IOException, MissingConfigurationException { try (FileOutputStream outputStream = new FileOutputStream(destination, true)) { @@ -1502,6 +1502,9 @@ public class PushServiceSocket { if (listener != null) { listener.onAttachmentProgress(body.contentLength() + offset, totalRead); + if (listener.shouldCancel()) { + call.cancel(); + } } } } else if (response.code() == 416) { diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBodyTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBodyTest.java index edd5aa3064..edd2b34c56 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBodyTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBodyTest.java @@ -3,6 +3,7 @@ package org.whispersystems.signalservice.internal.push.http; import org.junit.Test; import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream; import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.internal.util.Util; import java.io.ByteArrayInputStream; @@ -70,6 +71,15 @@ public class DigestingRequestBodyTest { } private DigestingRequestBody getBody(long contentStart) { - return new DigestingRequestBody(new ByteArrayInputStream(input), outputStreamFactory, "application/octet", CONTENT_LENGTH, (a, b) -> {}, () -> false, contentStart); + return new DigestingRequestBody(new ByteArrayInputStream(input), outputStreamFactory, "application/octet", CONTENT_LENGTH, new SignalServiceAttachment.ProgressListener() { + @Override + public void onAttachmentProgress(long total, long progress) { + // no-op + } + + @Override public boolean shouldCancel() { + return false; + } + }, () -> false, contentStart); } } \ No newline at end of file