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 e5612c1cc9..6550832bef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -21,6 +21,7 @@ import com.bumptech.glide.RequestManager; import org.signal.core.ui.view.Stub; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; +import org.thoughtcrime.securesms.components.transfercontrols.TransferControls; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; @@ -263,7 +264,7 @@ public class AlbumThumbnailView extends FrameLayout { } private void showSlides(@NonNull RequestManager requestManager, @NonNull List slides) { - boolean showControls = TransferControlView.containsPlayableSlides(slides); + boolean showControls = TransferControls.containsPlayableSlides(slides); setSlide(requestManager, slides.get(0), R.id.album_cell_1, showControls); setSlide(requestManager, slides.get(1), R.id.album_cell_2, showControls); 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 614b4f4cd8..88c2e2dfa0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; +import org.thoughtcrime.securesms.components.transfercontrols.TransferControls; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.glide.targets.GlideBitmapListeningTarget; import org.thoughtcrime.securesms.glide.targets.GlideDrawableListeningTarget; @@ -384,7 +385,7 @@ public class ThumbnailView extends FrameLayout { } transferControlViewStub.get().setSlides(List.of(slide)); } - int transferState = TransferControlView.getTransferState(List.of(slide)); + int transferState = TransferControls.getTransferState(List.of(slide)); boolean isOffloadedImage = (transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType())) && AttachmentUtil.isRestoreOnOpenPermitted(getContext(), slide.asAttachment()); if (!showControls || 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 index 366b81deea..1ee66e5af4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlView.kt @@ -5,55 +5,70 @@ package org.thoughtcrime.securesms.components.transfercontrols import android.content.Context -import android.os.Build -import android.text.StaticLayout import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.children -import androidx.core.view.updateLayoutParams +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.ByteSize import org.signal.core.util.ThrottledDebouncer import org.signal.core.util.bytes import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.components.RecyclerViewParentTransitionController 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.visible import java.util.UUID -import kotlin.math.ceil -import kotlin.math.roundToInt -class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { - private val uuid = UUID.randomUUID().toString() - private val binding: TransferControlsViewBinding +/** + * Displays the start/cancel/progress controls that overlay an attachment thumbnail. + */ +class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AbstractComposeView(context, attrs, defStyleAttr) { + + companion object { + private val TAG = Log.tag(TransferControlView::class.java) + + /** Flip to true locally to trace a single view's render transitions and ignored progress events. */ + private const val VERBOSE_DEVELOPMENT_LOGGING = false + } private var state = TransferControlViewState() - private val progressUpdateDebouncer: ThrottledDebouncer = ThrottledDebouncer(100) - private var mode: Mode = Mode.GONE + /** Throttled observable flow of [state] */ + private var renderState by mutableStateOf(TransferControlsRenderState.Gone) + + private val progressUpdateDebouncer = ThrottledDebouncer(100) + + /** Per-instance id so a single recycled view can be isolated in logcat when [VERBOSE_DEVELOPMENT_LOGGING] is on. */ + private val viewId by lazy { UUID.randomUUID().toString().take(8) } init { - tag = uuid - binding = TransferControlsViewBinding.inflate(LayoutInflater.from(context), this) - visibility = GONE + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) isLongClickable = false - addOnAttachStateChangeListener(RecyclerViewParentTransitionController(child = this)) } + @Composable + override fun Content() { + SignalTheme { + TransferControls( + state = renderState, + onStartClick = { state.startTransferClickListener?.onClick(this) }, + onCancelClick = { state.cancelTransferClickedListener?.onClick(this) }, + onPlayClick = { state.instantPlaybackClickListener?.onClick(this) } + ) + } + } + override fun onAttachedToWindow() { super.onAttachedToWindow() if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this) @@ -64,466 +79,34 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att EventBus.getDefault().unregister(this) } - private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) { - val newState = stateFactory.invoke(state) - val oldMode = deriveMode(state) - val newMode = deriveMode(newState) - if ((newState != state || oldMode != newMode) && !(oldMode == Mode.GONE && newMode == Mode.GONE)) { - progressUpdateDebouncer.publish { - applyState(newState) - } - } - state = newState - } - fun isGone(): Boolean { - return mode == Mode.GONE + return TransferControls.deriveRenderState(state) is TransferControlsRenderState.Gone } - private fun applyState(currentState: TransferControlViewState) { - val mode = deriveMode(currentState) - verboseLog("New state applying, mode = $mode") + private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) { + val newState = stateFactory(state) - children.forEach { - it.clearAnimation() - } + val oldRender = TransferControls.deriveRenderState(state) + val newRender = TransferControls.deriveRenderState(newState) + state = newState - when (mode) { - 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() - } - this.mode = mode - } - - private fun deriveMode(currentState: TransferControlViewState): Mode { - if (currentState.slides.isEmpty()) { - verboseLog("Setting empty slide deck to GONE") - return Mode.GONE - } - - if (currentState.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) { - verboseLog("Setting slide deck that's finished to GONE\n\t${slidesAsListOfTimestamps(currentState.slides)}") - return Mode.GONE - } - - if (currentState.isVisible) { - if (currentState.slides.size == 1) { - val slide = currentState.slides.first() - if (slide.hasVideo()) { - if (currentState.isUpload) { - return when (slide.transferState) { - AttachmentTable.TRANSFER_PROGRESS_STARTED -> { - Mode.UPLOADING_SINGLE_ITEM - } - - AttachmentTable.TRANSFER_PROGRESS_PENDING -> { - Mode.PENDING_SINGLE_ITEM - } - - else -> { - Mode.RETRY_UPLOADING - } - } - } else { - return when (slide.transferState) { - AttachmentTable.TRANSFER_PROGRESS_STARTED -> { - if (currentState.playableWhileDownloading) { - Mode.DOWNLOADING_VIDEO_PLAYABLE - } else { - Mode.DOWNLOADING_SINGLE_ITEM - } - } - - AttachmentTable.TRANSFER_PROGRESS_FAILED -> { - Mode.RETRY_DOWNLOADING - } - - else -> { - if (currentState.playableWhileDownloading) { - Mode.PENDING_VIDEO_PLAYABLE - } else { - Mode.PENDING_SINGLE_ITEM - } - } - } - } - } else { - return if (currentState.isUpload) { - when (slide.transferState) { - AttachmentTable.TRANSFER_PROGRESS_FAILED -> { - Mode.RETRY_UPLOADING - } - - AttachmentTable.TRANSFER_PROGRESS_PENDING -> { - Mode.PENDING_SINGLE_ITEM - } - - else -> { - Mode.UPLOADING_SINGLE_ITEM - } - } - } 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.isUpload) { - 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.isUpload) { - Mode.RETRY_UPLOADING - } else { - Mode.RETRY_DOWNLOADING - } - } - - AttachmentTable.TRANSFER_PROGRESS_DONE -> { - verboseLog("[Case 2] Setting slide deck that's finished to GONE\t${slidesAsListOfTimestamps(currentState.slides)}") - return Mode.GONE - } + if (oldRender != newRender) { + verboseLog { "render $oldRender -> $newRender slides=[${slidesAsLogString(newState.slides)}]" } + progressUpdateDebouncer.publish { + renderState = newRender + if (newRender !is TransferControlsRenderState.Gone) { + visibility = VISIBLE } } - } else { - verboseLog("Setting slide deck to GONE because isVisible is false:\t${slidesAsListOfTimestamps(currentState.slides)}") - return Mode.GONE } - - Log.i(TAG, "[$uuid] Hit default mode case, this should not happen.") - return Mode.GONE - } - - private fun displayPendingGallery(currentState: TransferControlViewState) { - binding.primaryProgressView.startClickListener = currentState.startTransferClickListener - applyFocusableAndClickable( - currentState, - listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground), - listOf(binding.secondaryProgressView, binding.playVideoButton) - ) - binding.primaryProgressView.setStopped(false) - showAllViews( - playVideoButton = false, - secondaryProgressView = false, - secondaryDetailsText = currentState.showSecondaryText - ) - - binding.primaryDetailsText.setOnClickListener(currentState.startTransferClickListener) - binding.primaryBackground.setOnClickListener(currentState.startTransferClickListener) - - binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) { - ViewUtil.dpToPx(-PRIMARY_TEXT_OFFSET_DP).toFloat() - } else { - ViewUtil.dpToPx(PRIMARY_TEXT_OFFSET_DP).toFloat() - } - setSecondaryDetailsText(currentState) - } - - private fun displayPendingGalleryWithPlayable(currentState: TransferControlViewState) { - binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener - binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener) - binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener) - super.setClickable(false) - binding.secondaryProgressView.isClickable = currentState.showSecondaryText - binding.secondaryProgressView.isFocusable = currentState.showSecondaryText - binding.secondaryDetailsText.isClickable = currentState.showSecondaryText - binding.secondaryDetailsText.isFocusable = currentState.showSecondaryText - binding.secondaryBackground.isClickable = currentState.showSecondaryText - binding.secondaryBackground.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) - setSecondaryDetailsText(currentState) - binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) { - ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat() - } else { - ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat() - } - } - - private fun displayPendingSingleItem(currentState: TransferControlViewState) { - binding.primaryProgressView.startClickListener = currentState.startTransferClickListener - applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton)) - binding.primaryProgressView.setStopped(false) - showAllViews( - playVideoButton = false, - primaryDetailsText = false, - secondaryProgressView = false, - secondaryDetailsText = currentState.showSecondaryText - ) - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayPendingPlayableVideo(currentState: TransferControlViewState) { - binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener - binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener) - binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener) - binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener) - applyFocusableAndClickable( - currentState, - listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground, binding.playVideoButton), - listOf(binding.primaryProgressView) - ) - binding.secondaryProgressView.setStopped(false) - showAllViews( - primaryProgressView = false, - primaryDetailsText = false, - secondaryDetailsText = currentState.showSecondaryText, - secondaryProgressView = currentState.showSecondaryText - ) - setSecondaryDetailsText(currentState) - binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) { - ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat() - } else { - ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat() - } - } - - 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.setProgress(progress) - } else { - binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener - binding.secondaryProgressView.setProgress(progress) - } - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayDownloadingSingleItem(currentState: TransferControlViewState) { - binding.primaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener - 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.setProgress(progress) - } else { - binding.primaryProgressView.setProgress(progress) - } - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayDownloadingPlayableVideo(currentState: TransferControlViewState) { - binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener - 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.setProgress(progress) - } else { - binding.secondaryProgressView.setProgress(progress) - } - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayUploadingSingleItem(currentState: TransferControlViewState) { - binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener - 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.setProgress(progress) - - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayUploadingGallery(currentState: TransferControlViewState) { - binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener - applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton)) - showAllViews( - playVideoButton = false, - primaryProgressView = false, - primaryDetailsText = false - ) - - val progress = calculateProgress(currentState) - binding.secondaryProgressView.setProgress(progress) - - binding.secondaryDetailsText.translationX = 0f - setSecondaryDetailsText(currentState) - } - - private fun displayRetry(currentState: TransferControlViewState, isUploading: Boolean) { - if (currentState.startTransferClickListener == null) { - Log.w(TAG, "No click listener set for retry!") - } - - binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener - applyFocusableAndClickable( - currentState, - listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground), - listOf(binding.primaryProgressView, binding.playVideoButton) - ) - showAllViews( - playVideoButton = false, - primaryProgressView = false, - primaryDetailsText = false, - secondaryDetailsText = currentState.showSecondaryText - ) - binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener) - binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener) - binding.secondaryProgressView.setStopped(isUploading) - setSecondaryDetailsText(currentState) - binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) { - ViewUtil.dpToPx(-RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat() - } else { - ViewUtil.dpToPx(RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat() - } - } - - private fun displayChildrenAsGone() { - children.forEach { - if (it.visible && it.animation == null) { - ViewUtil.fadeOut(it, 250) - } - } - } - - /** - * 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) { - context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin) - } else { - context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_parent_to_textview_margin) - } - ViewUtil.setPaddingStart(binding.secondaryDetailsText, textPadding) - if (ViewUtil.isLtr(binding.secondaryDetailsText)) { - (binding.secondaryDetailsText.layoutParams as MarginLayoutParams).leftMargin = textPadding - } else { - (binding.secondaryDetailsText.layoutParams as MarginLayoutParams).rightMargin = textPadding - } - } - - private fun applyFocusableAndClickable(currentState: TransferControlViewState, activeViews: List, inactiveViews: List) { - if (Build.VERSION.SDK_INT >= 26) { - 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.setOnClickListener(null) - it.isClickable = false - } - } - - override fun setFocusable(focusable: Boolean) { - super.setFocusable(false) - verboseLog("setFocusable update: $focusable") - updateState { it.copy(isFocusable = focusable) } - } - - override fun setClickable(clickable: Boolean) { - super.setClickable(false) - verboseLog("setClickable update: $clickable") - updateState { it.copy(isClickable = clickable) } } @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) fun onEventAsync(event: PartProgressEvent) { val attachment = event.attachment updateState { - verboseLog("onEventAsync update") if (!it.networkProgress.containsKey(attachment)) { - verboseLog("onEventAsync update ignored") + verboseLog { "Ignoring progress event for an attachment not in this view's slide set (likely a recycled view). ts=${attachment.uploadTimestamp}" } return@updateState it } @@ -536,7 +119,6 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att } else if (updateEvent.completed < 0.bytes) { mutableMap.remove(attachment) } - verboseLog("onEventAsync compression update") return@updateState it.copy(compressionProgress = mutableMap.toMap()) } else { val mutableMap = it.networkProgress.toMutableMap() @@ -547,16 +129,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att } else if (updateEvent.completed < 0.bytes) { mutableMap.remove(attachment) } - verboseLog("onEventAsync network update") return@updateState it.copy(networkProgress = mutableMap.toMap()) } } } fun setSlides(slides: List) { - require(slides.isNotEmpty()) { "[$uuid] Must provide at least one slide." } + require(slides.isNotEmpty()) { "Must provide at least one slide." } updateState { state -> - verboseLog("State update for new slides: ${slidesAsListOfTimestamps(slides)}") val isNewSlideSet = !isUpdateToExistingSet(state, slides) val networkProgress: MutableMap = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap() if (isNewSlideSet) { @@ -577,25 +157,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att (it.asAttachment() as? DatabaseAttachment)?.hasData == true } - val result = state.copy( + state.copy( slides = slides, networkProgress = networkProgress, compressionProgress = compressionProgress, playableWhileDownloading = playableWhileDownloading, isUpload = isUpload ) - verboseLog("New state calculated and being returned for new slides: ${slidesAsListOfTimestamps(slides)}\n$result") - return@updateState result } - verboseLog("End of setSlides() for ${slidesAsListOfTimestamps(slides)}") - } - - private fun slidesAsListOfTimestamps(slides: List): String { - if (!VERBOSE_DEVELOPMENT_LOGGING) { - return "" - } - - return slides.map { it.asAttachment().uploadTimestamp }.joinToString() } private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List): Boolean { @@ -611,171 +180,52 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att } fun setTransferClickListener(listener: OnClickListener) { - verboseLog("transferClickListener update") - updateState { - it.copy( - startTransferClickListener = listener - ) - } + updateState { it.copy(startTransferClickListener = listener) } } fun setCancelClickListener(listener: OnClickListener) { - verboseLog("cancelClickListener update") - updateState { - it.copy( - cancelTransferClickedListener = listener - ) - } + updateState { it.copy(cancelTransferClickedListener = listener) } } fun setInstantPlaybackClickListener(listener: OnClickListener) { - verboseLog("instantPlaybackClickListener update") - updateState { - it.copy( - instantPlaybackClickListener = listener - ) - } + updateState { it.copy(instantPlaybackClickListener = listener) } } fun clear() { - clearAnimation() visibility = GONE updateState { TransferControlViewState() } } fun setShowSecondaryText(showSecondaryText: Boolean) { - verboseLog("showSecondaryText update: $showSecondaryText") - updateState { - it.copy( - showSecondaryText = showSecondaryText - ) - } + updateState { it.copy(showSecondaryText = showSecondaryText) } } fun setVisible(isVisible: Boolean) { - verboseLog("showSecondaryText update: $isVisible") - updateState { - it.copy( - isVisible = isVisible - ) - } + updateState { it.copy(isVisible = isVisible) } } - private fun isCompressing(state: TransferControlViewState): Boolean { - val total = state.compressionProgress.sumTotal() - return total > 0.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f + fun setAwaitingPrimaryResponse(awaiting: Boolean) { + updateState { it.copy(awaitingPrimaryResponse = awaiting) } } - private fun calculateProgress(state: TransferControlViewState): Float { - val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum() - val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(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 + override fun setFocusable(focusable: Boolean) { + super.setFocusable(false) + updateState { it.copy(isFocusable = focusable) } } - private fun setSecondaryDetailsText(currentState: TransferControlViewState) { - when (deriveMode(currentState)) { - Mode.PENDING_GALLERY -> { - binding.secondaryDetailsText.updateLayoutParams { - width = ViewGroup.LayoutParams.WRAP_CONTENT - } - 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 size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted() - binding.secondaryDetailsText.text = size.toUnitString() - } - - Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> { - binding.secondaryDetailsText.updateLayoutParams { - width = ViewGroup.LayoutParams.WRAP_CONTENT - } - val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted() - binding.secondaryDetailsText.text = size.toUnitString() - } - - Mode.PENDING_SINGLE_ITEM, Mode.PENDING_VIDEO_PLAYABLE -> { - binding.secondaryDetailsText.updateLayoutParams { - width = ViewGroup.LayoutParams.WRAP_CONTENT - } - val size: ByteSize = (currentState.slides.sumOf { it.asAttachment().size }).bytes - binding.secondaryDetailsText.text = size.toUnitString() - } - - Mode.DOWNLOADING_GALLERY, Mode.DOWNLOADING_SINGLE_ITEM, Mode.DOWNLOADING_VIDEO_PLAYABLE, Mode.UPLOADING_GALLERY, Mode.UPLOADING_SINGLE_ITEM -> { - if (currentState.isUpload && (currentState.networkProgress.sumCompleted() == 0.bytes || isCompressing(currentState))) { - binding.secondaryDetailsText.updateLayoutParams { - width = ViewGroup.LayoutParams.WRAP_CONTENT - } - binding.secondaryDetailsText.text = context.getString(R.string.TransferControlView__processing) - } else { - val progressMiB = currentState.networkProgress.sumCompleted().toUnitString() - val totalMiB = currentState.networkProgress.sumTotal().toUnitString() - val completedLabel = context.resources.getString(R.string.TransferControlView__download_progress_s_s, totalMiB, totalMiB) - val desiredWidth = StaticLayout.getDesiredWidth(completedLabel, binding.secondaryDetailsText.paint) - binding.secondaryDetailsText.text = context.resources.getString(R.string.TransferControlView__download_progress_s_s, progressMiB, totalMiB) - val roundedWidth = ceil(desiredWidth.toDouble()).roundToInt() + binding.secondaryDetailsText.compoundPaddingLeft + binding.secondaryDetailsText.compoundPaddingRight - binding.secondaryDetailsText.updateLayoutParams { - width = roundedWidth - } - } - } - - Mode.RETRY_DOWNLOADING, Mode.RETRY_UPLOADING -> { - binding.secondaryDetailsText.text = resources.getString(R.string.NetworkFailure__retry) - binding.secondaryDetailsText.updateLayoutParams { - width = ViewGroup.LayoutParams.WRAP_CONTENT - } - } - - Mode.GONE -> Unit - } + override fun setClickable(clickable: Boolean) { + super.setClickable(false) + updateState { it.copy(isClickable = clickable) } } - /** - * This is an extremely chatty logging mode for local development. Each view is assigned a UUID so that you can filter by view inside a conversation. - */ - private fun verboseLog(message: String) { + private inline fun verboseLog(message: () -> String) { if (VERBOSE_DEVELOPMENT_LOGGING) { - Log.d(TAG, "[$uuid] $message") + Log.d(TAG, "[$viewId] ${message()}") } } - companion object { - private const val TAG = "TransferControlView" - private const val VERBOSE_DEVELOPMENT_LOGGING = false - private const val UPLOAD_TASK_WEIGHT = 1 - private const val SECONDARY_TEXT_OFFSET_DP = 6 - private const val RETRY_SECONDARY_TEXT_OFFSET_DP = 6 - private const val PRIMARY_TEXT_OFFSET_DP = 4 - - /** - * A weighting compared to [UPLOAD_TASK_WEIGHT] - */ - private const val COMPRESSION_TASK_WEIGHT = 3 - - @JvmStatic - fun getTransferState(slides: List): Int { - var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE - var allFailed = true - for (slide in slides) { - if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) { - allFailed = false - transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { - slide.transferState - } else { - transferState.coerceAtLeast(slide.transferState) - } - } - } - return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState - } - - @JvmStatic - fun containsPlayableSlides(slides: List): Boolean { - return slides.any { MediaUtil.isInstantVideoSupported(it) } - } + private fun slidesAsLogString(slides: List): String { + return slides.joinToString { "ts=${it.asAttachment().uploadTimestamp},xfer=${it.transferState}" } } data class Progress(val completed: ByteSize, val total: ByteSize) { @@ -785,27 +235,4 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att } } } - - private fun Map.sumCompleted(): ByteSize { - return this.values.sumOf { it.completed.inWholeBytes }.bytes - } - - private fun Map.sumTotal(): ByteSize { - return this.values.sumOf { it.total.inWholeBytes }.bytes - } - - enum class Mode { - PENDING_GALLERY, - PENDING_GALLERY_CONTAINS_PLAYABLE, - PENDING_SINGLE_ITEM, - PENDING_VIDEO_PLAYABLE, - DOWNLOADING_GALLERY, - DOWNLOADING_SINGLE_ITEM, - DOWNLOADING_VIDEO_PLAYABLE, - UPLOADING_GALLERY, - UPLOADING_SINGLE_ITEM, - RETRY_DOWNLOADING, - RETRY_UPLOADING, - GONE - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt index 6cb7c6b719..c4310c26b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlViewState.kt @@ -21,5 +21,6 @@ data class TransferControlViewState( val networkProgress: Map = HashMap(), val compressionProgress: Map = HashMap(), val playableWhileDownloading: Boolean = false, - val isUpload: Boolean = false + val isUpload: Boolean = false, + val awaitingPrimaryResponse: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControls.kt new file mode 100644 index 0000000000..61a588acfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControls.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.transfercontrols + +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.util.MediaUtil + +/** + * Pure, Android-View-free logic for the transfer controls UI. + * + * [deriveRenderState] maps a [TransferControlViewState] to a [TransferControlsRenderState], which is a small, fully-resolved + * description of what should be drawn. It carries semantic data (counts, byte sizes) rather than formatted strings so that it + * can be unit tested on the JVM; string formatting happens in the composable. + */ +object TransferControls { + + /** + * Where the active transfer control (start button / progress indicator) is positioned. + * + * [CENTER] is the large, centered control used for single-item downloads. + * [CORNER] is the small control tucked in the corner, used for galleries, playable video, all uploads, and retries. + */ + enum class Placement { + CENTER, + CORNER + } + + sealed interface ProgressLabel { + /** Attachment processing taking place, like transcoding */ + data object Processing : ProgressLabel + + /** Uploading/downloading progress */ + data class Bytes(val completed: ByteSize, val total: ByteSize) : ProgressLabel + } + + fun deriveRenderState(state: TransferControlViewState): TransferControlsRenderState { + if (state.slides.isEmpty()) { + return TransferControlsRenderState.Gone + } + + if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) { + return TransferControlsRenderState.Gone + } + + if (!state.isVisible) { + return TransferControlsRenderState.Gone + } + + if (state.awaitingPrimaryResponse) { + return TransferControlsRenderState.InProgress( + isUpload = false, + placement = if (state.slides.size == 1) Placement.CENTER else Placement.CORNER, + progress = null, + showPlayButton = false, + cancelable = false, + label = null + ) + } + + return when (deriveMode(state)) { + Mode.PENDING_GALLERY -> TransferControlsRenderState.Pending( + isUpload = state.isUpload, + placement = Placement.CENTER, + showPlayButton = false, + itemCount = state.slides.count { it.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE }, + sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null + ) + + Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> TransferControlsRenderState.Pending( + isUpload = state.isUpload, + placement = Placement.CORNER, + showPlayButton = false, + sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null + ) + + Mode.PENDING_SINGLE_ITEM -> TransferControlsRenderState.Pending( + isUpload = state.isUpload, + placement = Placement.CENTER, + showPlayButton = false, + sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null + ) + + Mode.PENDING_VIDEO_PLAYABLE -> TransferControlsRenderState.Pending( + isUpload = state.isUpload, + placement = Placement.CORNER, + showPlayButton = true, + sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null + ) + + Mode.DOWNLOADING_GALLERY -> TransferControlsRenderState.InProgress( + isUpload = false, + placement = Placement.CORNER, + progress = calculateProgress(state), + showPlayButton = false, + cancelable = calculateProgress(state) != 0f, + label = progressLabel(state) + ) + + Mode.DOWNLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress( + isUpload = false, + placement = Placement.CENTER, + progress = calculateProgress(state), + showPlayButton = false, + cancelable = true, + label = progressLabel(state) + ) + + Mode.DOWNLOADING_VIDEO_PLAYABLE -> TransferControlsRenderState.InProgress( + isUpload = false, + placement = Placement.CORNER, + progress = calculateProgress(state), + showPlayButton = true, + cancelable = true, + label = progressLabel(state) + ) + + Mode.UPLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress( + isUpload = true, + placement = Placement.CORNER, + progress = calculateProgress(state), + showPlayButton = false, + cancelable = true, + label = progressLabel(state) + ) + + Mode.UPLOADING_GALLERY -> TransferControlsRenderState.InProgress( + isUpload = true, + placement = Placement.CORNER, + progress = calculateProgress(state), + showPlayButton = false, + cancelable = true, + // Note: the legacy view always showed this label for uploading galleries, regardless of showSecondaryText. + label = progressLabel(state) + ) + + Mode.RETRY_DOWNLOADING -> TransferControlsRenderState.Retry(isUpload = false) + Mode.RETRY_UPLOADING -> TransferControlsRenderState.Retry(isUpload = true) + Mode.GONE -> TransferControlsRenderState.Gone + } + } + + private fun progressLabel(state: TransferControlViewState): ProgressLabel { + return if (state.isUpload && (state.networkProgress.sumCompleted() == 0L.bytes || isCompressing(state))) { + ProgressLabel.Processing + } else if (state.isUpload) { + ProgressLabel.Bytes(state.networkProgress.sumCompleted(), state.networkProgress.sumTotal()) + } else { + val total = state.slides.sumOf { it.fileSize }.bytes + val completed = state.networkProgress.sumCompleted().let { if (it > total) total else it } + ProgressLabel.Bytes(completed, total) + } + } + + private fun isCompressing(state: TransferControlViewState): Boolean { + val total = state.compressionProgress.sumTotal() + return total > 0L.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f + } + + private fun calculateProgress(state: TransferControlViewState): Float { + val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum() + val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(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 + } + + /** + * Internal, view-free mirror of the legacy state machine. Kept verbatim from the original view to preserve behavior; the + * resulting [Mode] is mapped to a [TransferControlsRenderState] by [deriveRenderState]. + */ + private fun deriveMode(state: TransferControlViewState): Mode { + if (state.slides.isEmpty()) { + return Mode.GONE + } + + if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) { + return Mode.GONE + } + + if (state.isVisible) { + if (state.slides.size == 1) { + val slide = state.slides.first() + if (slide.hasVideo()) { + if (state.isUpload) { + return when (slide.transferState) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> Mode.UPLOADING_SINGLE_ITEM + AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM + else -> Mode.RETRY_UPLOADING + } + } else { + return when (slide.transferState) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> { + if (state.playableWhileDownloading) Mode.DOWNLOADING_VIDEO_PLAYABLE else Mode.DOWNLOADING_SINGLE_ITEM + } + + AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_DOWNLOADING + else -> { + if (state.playableWhileDownloading) Mode.PENDING_VIDEO_PLAYABLE else Mode.PENDING_SINGLE_ITEM + } + } + } + } else { + return if (state.isUpload) { + when (slide.transferState) { + AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_UPLOADING + AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM + else -> Mode.UPLOADING_SINGLE_ITEM + } + } else { + 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(state.slides)) { + AttachmentTable.TRANSFER_PROGRESS_STARTED -> { + return if (state.isUpload) Mode.UPLOADING_GALLERY else Mode.DOWNLOADING_GALLERY + } + + AttachmentTable.TRANSFER_PROGRESS_PENDING -> { + return if (containsPlayableSlides(state.slides)) Mode.PENDING_GALLERY_CONTAINS_PLAYABLE else Mode.PENDING_GALLERY + } + + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { + return if (state.isUpload) Mode.RETRY_UPLOADING else Mode.RETRY_DOWNLOADING + } + + AttachmentTable.TRANSFER_PROGRESS_DONE -> return Mode.GONE + } + } + } else { + return Mode.GONE + } + + return Mode.GONE + } + + @JvmStatic + fun getTransferState(slides: List): Int { + var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE + var allFailed = true + for (slide in slides) { + if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) { + allFailed = false + transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { + slide.transferState + } else { + transferState.coerceAtLeast(slide.transferState) + } + } + } + return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState + } + + @JvmStatic + fun containsPlayableSlides(slides: List): Boolean { + return slides.any { MediaUtil.isInstantVideoSupported(it) } + } + + private fun Map.sumCompleted(): ByteSize { + return this.values.sumOf { it.completed.inWholeBytes }.bytes + } + + private fun Map.sumTotal(): ByteSize { + return this.values.sumOf { it.total.inWholeBytes }.bytes + } + + private const val UPLOAD_TASK_WEIGHT = 1 + + /** + * A weighting compared to [UPLOAD_TASK_WEIGHT] + */ + private const val COMPRESSION_TASK_WEIGHT = 3 + + private 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 + } +} + +/** + * A fully-resolved description of what the transfer controls should display. Produced by [TransferControls.deriveRenderState]. + */ +sealed interface TransferControlsRenderState { + data object Gone : TransferControlsRenderState + + data class Pending( + val isUpload: Boolean, + val placement: TransferControls.Placement, + val showPlayButton: Boolean, + val itemCount: Int? = null, + val sizeBytes: ByteSize? = null + ) : TransferControlsRenderState + + data class InProgress( + val isUpload: Boolean, + val placement: TransferControls.Placement, + val progress: Float?, + val showPlayButton: Boolean, + val cancelable: Boolean, + val label: TransferControls.ProgressLabel? + ) : TransferControlsRenderState + + data class Retry( + val isUpload: Boolean + ) : TransferControlsRenderState +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsContent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsContent.kt new file mode 100644 index 0000000000..514539c09c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsContent.kt @@ -0,0 +1,415 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.transfercontrols + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.clickableContainer +import org.signal.core.util.bytes +import org.thoughtcrime.securesms.R +import org.signal.core.ui.R as CoreUiR + +private val CENTER_CONTROL_SIZE = 44.dp +private val NO_FONT_PADDING = PlatformTextStyle(includeFontPadding = false) + +/** + * Compose rendering of the attachment transfer controls (start/cancel/progress) that overlay a media thumbnail. + * + * This renders a [TransferControlsRenderState] produced by [TransferControls.deriveRenderState]. All state derivation lives in + * [TransferControls]; this function is purely presentational so the various visual states can be previewed and tested directly. + */ +@Composable +fun TransferControls( + state: TransferControlsRenderState, + modifier: Modifier = Modifier, + onStartClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onPlayClick: () -> Unit = {} +) { + Box(modifier = modifier.fillMaxSize()) { + when (state) { + is TransferControlsRenderState.Gone -> Unit + + is TransferControlsRenderState.Pending -> Content( + control = TransferProgressState.Ready( + icon = arrowIcon(state.isUpload), + startButtonContentDesc = startContentDescription(state.isUpload), + startButtonOnClickLabel = startContentDescription(state.isUpload), + onStartClick = onStartClick + ), + placement = state.placement, + showPlayButton = state.showPlayButton, + centerLabel = state.itemCount?.let { pluralStringResource(R.plurals.TransferControlView_n_items, it, it) }, + cornerText = state.sizeBytes?.toUnitString(), + onPlayClick = onPlayClick + ) + + is TransferControlsRenderState.Retry -> Content( + control = TransferProgressState.Ready( + icon = arrowIcon(state.isUpload), + startButtonContentDesc = startContentDescription(state.isUpload), + startButtonOnClickLabel = startContentDescription(state.isUpload), + onStartClick = onStartClick + ), + placement = TransferControls.Placement.CORNER, + showPlayButton = false, + centerLabel = null, + cornerText = stringResource(R.string.NetworkFailure__retry), + onPlayClick = onPlayClick + ) + + is TransferControlsRenderState.InProgress -> { + val cancelLabel = stringResource(android.R.string.cancel) + val label = state.label + val progressFormat = stringResource(R.string.TransferControlView__download_progress_s_s) + val cornerTextReserveWidthFor = (label as? TransferControls.ProgressLabel.Bytes)?.let { byteLabel -> + val unit = byteLabel.total.getLargestNonZeroSize() + val widestCompleted = byteLabel.total.toUnitString(unit, padDecimals = true, withUnit = false) + val totalText = byteLabel.total.toUnitString(unit) + progressFormat.format(widestCompleted, totalText) + } + + Content( + control = TransferProgressState.InProgress( + progress = state.progress, + cancelAction = if (state.cancelable) { + TransferProgressState.InProgress.CancelAction( + contentDesc = cancelLabel, + onClickLabel = cancelLabel, + onClick = onCancelClick + ) + } else { + null + } + ), + placement = state.placement, + showPlayButton = state.showPlayButton, + centerLabel = null, + cornerText = label?.let { progressLabelText(it) }, + cornerTextReserveWidthFor = cornerTextReserveWidthFor, + onPlayClick = onPlayClick + ) + } + } + } +} + +@Composable +private fun BoxScope.Content( + control: TransferProgressState, + placement: TransferControls.Placement, + showPlayButton: Boolean, + centerLabel: String?, + cornerText: String?, + onPlayClick: () -> Unit, + cornerTextReserveWidthFor: String? = null +) { + val controlInCenter = placement == TransferControls.Placement.CENTER + val controlInCorner = placement == TransferControls.Placement.CORNER + + if (controlInCenter || showPlayButton || centerLabel != null) { + Pill( + modifier = Modifier.align(Alignment.Center), + cornerRadius = 24.dp + ) { + if (controlInCenter) { + OnMediaIndicator(control, CENTER_CONTROL_SIZE) + } + + if (showPlayButton) { + PlayButton(onPlayClick) + } + + if (centerLabel != null) { + Text( + text = centerLabel, + style = MaterialTheme.typography.bodyLarge.copy(platformStyle = NO_FONT_PADDING), + color = colorResource(CoreUiR.color.signal_colorOnCustom), + maxLines = 1, + modifier = Modifier.padding(end = 12.dp) + ) + } + } + } + + if (controlInCorner || cornerText != null) { + Pill( + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp), + cornerRadius = 16.dp + ) { + if (controlInCorner) { + OnMediaIndicator(control, 32.dp) + } + + if (cornerText != null) { + if (!controlInCorner) { + Spacer(modifier = Modifier.width(8.dp)) + } + + CornerText( + text = cornerText, + reserveWidthFor = cornerTextReserveWidthFor + ) + } + } + } +} + +@Composable +private fun Pill( + modifier: Modifier = Modifier, + cornerRadius: Dp, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(cornerRadius)) + .background(colorResource(CoreUiR.color.signal_colorTransparentInverse4)), + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +/** + * Wraps [TransferProgressIndicator] in a color scheme override so it adopts the on-media ("OnCustom") palette rather than the + * default surface palette, matching the legacy view's appearance over thumbnails. + */ +@Composable +private fun OnMediaIndicator(state: TransferProgressState, size: Dp) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + onSurface = colorResource(CoreUiR.color.signal_colorOnCustom), + surfaceContainerHighest = colorResource(CoreUiR.color.signal_colorTransparent2) + ) + ) { + TransferProgressIndicator(state = state, size = size) + } +} + +@Composable +private fun PlayButton(onPlayClick: () -> Unit) { + val description = stringResource(R.string.ThumbnailView_Play_video_description) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.triangle_right), + contentDescription = description, + tint = colorResource(CoreUiR.color.signal_colorOnCustom), + modifier = Modifier + .size(CENTER_CONTROL_SIZE) + .clickableContainer( + contentDescription = description, + onClickLabel = description, + onClick = onPlayClick + ) + .padding(10.dp) + ) +} + +@Composable +private fun CornerText( + text: String, + reserveWidthFor: String? +) { + val reserving = reserveWidthFor != null + val style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Light, + platformStyle = NO_FONT_PADDING + ) + val effectiveStyle = if (reserving) style.copy(fontFeatureSettings = "tnum") else style + + val widthModifier = if (reserving) { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val reservedWidth = remember(reserveWidthFor, effectiveStyle, density) { + with(density) { measurer.measure(reserveWidthFor, effectiveStyle).size.width.toDp() } + } + Modifier.width(reservedWidth) + } else { + Modifier + } + + Text( + text = text, + style = effectiveStyle, + color = colorResource(CoreUiR.color.signal_colorOnCustom), + maxLines = 1, + textAlign = if (reserving) TextAlign.End else null, + modifier = Modifier + .padding(end = 8.dp, top = 8.dp, bottom = 8.dp) + .then(widthModifier) + ) +} + +@Composable +private fun arrowIcon(isUpload: Boolean): ImageVector { + return ImageVector.vectorResource(if (isUpload) R.drawable.symbol_arrow_up_24 else R.drawable.symbol_arrow_down_24) +} + +@Composable +private fun startContentDescription(isUpload: Boolean): String { + return stringResource(if (isUpload) R.string.TransferControlView__upload else R.string.TransferControlView__download) +} + +@Composable +private fun progressLabelText(label: TransferControls.ProgressLabel): String { + return when (label) { + is TransferControls.ProgressLabel.Processing -> stringResource(R.string.TransferControlView__processing) + is TransferControls.ProgressLabel.Bytes -> { + val unit = label.total.getLargestNonZeroSize() + stringResource( + R.string.TransferControlView__download_progress_s_s, + label.completed.toUnitString(unit, padDecimals = true, withUnit = false), + label.total.toUnitString(unit) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsPendingSinglePreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.Pending( + isUpload = false, + placement = TransferControls.Placement.CENTER, + showPlayButton = false, + sizeBytes = (2 * 1024 * 1024L).bytes + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsPendingGalleryPreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.Pending( + isUpload = false, + placement = TransferControls.Placement.CENTER, + showPlayButton = false, + itemCount = 3, + sizeBytes = (6 * 1024 * 1024L).bytes + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsPendingPlayableVideoPreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.Pending( + isUpload = false, + placement = TransferControls.Placement.CORNER, + showPlayButton = true, + sizeBytes = (12 * 1024 * 1024L).bytes + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsDownloadingSinglePreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.InProgress( + isUpload = false, + placement = TransferControls.Placement.CORNER, + progress = 0.45f, + showPlayButton = false, + cancelable = true, + label = TransferControls.ProgressLabel.Bytes((1024 * 1024L).bytes, (2 * 1024 * 1024L).bytes) + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsAwaitingPrimaryPreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.InProgress( + isUpload = false, + placement = TransferControls.Placement.CENTER, + progress = null, + showPlayButton = false, + cancelable = false, + label = null + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferControlsRetryPreview() { + Previews.Preview { + PreviewSurface { + TransferControls( + state = TransferControlsRenderState.Retry(isUpload = false) + ) + } + } +} + +@Composable +private fun PreviewSurface(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .size(150.dp) + .background(colorResource(CoreUiR.color.signal_colorTransparent2)) + ) { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt index 81fe04d697..eeeb4f0a99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt @@ -13,20 +13,32 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.clickableContainer +import org.thoughtcrime.securesms.R +import org.signal.core.ui.R as CoreUiR /** * A button that can be used to start, cancel, show progress, and show completion of a data transfer. @@ -34,10 +46,19 @@ import org.signal.core.ui.compose.clickableContainer @Composable fun TransferProgressIndicator( state: TransferProgressState, - modifier: Modifier = Modifier.size(48.dp) + modifier: Modifier = Modifier, + size: Dp = 48.dp ) { + // Internal paddings are tuned for a 48dp control; scale them with [size] so the icon/ring proportions are preserved at + // other sizes. At 48dp this is a no-op, so existing callers are unaffected. + val scale = size / 48.dp + val sizedModifier = modifier.size(size) + AnimatedContent( targetState = state, + // Key on the state type, not the value, so that progress updates within InProgress recompose in place instead of + // re-triggering the enter/exit transition on every tick (which would prevent the determinate fill from ever settling). + contentKey = { it::class }, transitionSpec = { val startDelay = 200 val enterTransition = fadeIn(tween(delayMillis = startDelay, durationMillis = 500)) + scaleIn(tween(delayMillis = startDelay, durationMillis = 400)) @@ -48,9 +69,9 @@ fun TransferProgressIndicator( } ) { targetState -> when (targetState) { - is TransferProgressState.Ready -> StartTransferButton(targetState, modifier) - is TransferProgressState.InProgress -> ProgressIndicator(targetState, modifier) - is TransferProgressState.Complete -> CompleteIcon(targetState, modifier) + is TransferProgressState.Ready -> StartTransferButton(targetState, sizedModifier, scale) + is TransferProgressState.InProgress -> ProgressIndicator(targetState, sizedModifier, scale) + is TransferProgressState.Complete -> CompleteIcon(targetState, sizedModifier, scale) } } } @@ -58,7 +79,8 @@ fun TransferProgressIndicator( @Composable private fun StartTransferButton( state: TransferProgressState.Ready, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scale: Float = 1f ) { Box( modifier = modifier @@ -74,7 +96,7 @@ private fun StartTransferButton( contentDescription = null, modifier = Modifier .matchParentSize() - .padding(12.dp) + .padding(12.dp * scale) ) } } @@ -82,7 +104,8 @@ private fun StartTransferButton( @Composable private fun ProgressIndicator( state: TransferProgressState.InProgress, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scale: Float = 1f ) { Box( modifier = modifier @@ -97,7 +120,7 @@ private fun ProgressIndicator( Modifier } ) - .padding(10.dp) + .padding(10.dp * scale) ) { state.icon?.let { icon -> Icon( @@ -106,7 +129,7 @@ private fun ProgressIndicator( contentDescription = null, modifier = Modifier .matchParentSize() - .padding(6.dp) + .padding(6.dp * scale) ) } @@ -141,19 +164,32 @@ private fun ProgressIndicator( modifier = indicatorModifier ) } + + // When cancelable, draw the filled "stop" square in the center of the ring (matches the legacy view's + // IN_PROGRESS_CANCELABLE state). Sized as a fraction of the control so it scales with center/corner placements. + if (state.cancelAction != null) { + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize(0.3f) + .clip(RoundedCornerShape(percent = 15)) + .background(MaterialTheme.colorScheme.onSurface) + ) + } } } @Composable private fun CompleteIcon( state: TransferProgressState.Complete, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scale: Float = 1f ) { Icon( imageVector = state.icon, tint = MaterialTheme.colorScheme.onSurface, contentDescription = state.iconContentDesc, - modifier = modifier.padding(12.dp) + modifier = modifier.padding(12.dp * scale) ) } @@ -183,3 +219,97 @@ sealed interface TransferProgressState { val iconContentDesc: String ) : TransferProgressState } + +@DayNightPreviews +@Composable +private fun TransferProgressIndicatorReadyPreview() { + Previews.Preview { + PreviewBackdrop { + TransferProgressIndicator( + state = TransferProgressState.Ready( + icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24), + startButtonContentDesc = "", + startButtonOnClickLabel = "", + onStartClick = {} + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferProgressIndicatorIndeterminatePreview() { + Previews.Preview { + PreviewBackdrop { + TransferProgressIndicator( + state = TransferProgressState.InProgress( + icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24), + progress = null, + cancelAction = null + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferProgressIndicatorDeterminatePreview() { + Previews.Preview { + PreviewBackdrop { + TransferProgressIndicator( + state = TransferProgressState.InProgress( + icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24), + progress = 0.4f, + cancelAction = null + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferProgressIndicatorCancelablePreview() { + Previews.Preview { + PreviewBackdrop { + TransferProgressIndicator( + state = TransferProgressState.InProgress( + progress = 0.4f, + cancelAction = TransferProgressState.InProgress.CancelAction( + contentDesc = "", + onClickLabel = "", + onClick = {} + ) + ) + ) + } + } +} + +@DayNightPreviews +@Composable +private fun TransferProgressIndicatorCompletePreview() { + Previews.Preview { + PreviewBackdrop { + TransferProgressIndicator( + state = TransferProgressState.Complete( + icon = ImageVector.vectorResource(R.drawable.symbol_check_white_24), + iconContentDesc = "" + ) + ) + } + } +} + +@Composable +private fun PreviewBackdrop(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .size(96.dp) + .background(colorResource(CoreUiR.color.signal_colorTransparent2)) + ) { + content() + } +} 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 deleted file mode 100644 index 3dcb36fbf3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.transfercontrols - -import android.content.Context -import android.content.res.Resources -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.annotation.Discouraged -import androidx.core.content.ContextCompat -import androidx.core.graphics.withTranslation -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import kotlin.math.roundToInt -import org.signal.core.ui.R as CoreUiR - -/** - * This displays a circular progress around an icon. The icon is either an upload arrow, a download arrow, or a rectangular stop button. - */ -@Discouraged("Use TransferProgressIndicator instead.") -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_DP = 2f - private const val ICON_SIZE_DP = 24f - private const val STOP_CORNER_RADIUS_DP = 4f - private const val PROGRESS_BAR_INSET_DP = 2 - } - - private val iconColor: Int - private val progressColor: Int - private val trackColor: Int - private val stopIconPaint: Paint - private val progressPaint: Paint - private val trackPaint: Paint - private val progressArcStrokeWidth: Float - private val iconSize: Float - private val stopIconSize: Float - private val stopIconCornerRadius: Float - - private val progressRect = RectF() - private val stopIconRect = RectF() - private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_down_24) - private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_up_24) - - private var progressPercent = 0f - private var currentState = State.UNINITIALIZED - - var startClickListener: OnClickListener? = null - var cancelClickListener: OnClickListener? = null - - init { - val displayDensity = Resources.getSystem().displayMetrics.density - val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TransferProgressView, 0, 0) - val signalCustomColor = ContextCompat.getColor(context, CoreUiR.color.signal_colorOnCustom) - val signalTransparent2 = ContextCompat.getColor(context, CoreUiR.color.signal_colorTransparent2) - - iconColor = typedArray.getColor(R.styleable.TransferProgressView_transferIconColor, signalCustomColor) - progressColor = typedArray.getColor(R.styleable.TransferProgressView_progressColor, signalCustomColor) - trackColor = typedArray.getColor(R.styleable.TransferProgressView_trackColor, signalTransparent2) - progressArcStrokeWidth = typedArray.getDimension(R.styleable.TransferProgressView_progressArcWidth, PROGRESS_ARC_STROKE_WIDTH_DP * displayDensity) - iconSize = typedArray.getDimension(R.styleable.TransferProgressView_iconSize, ICON_SIZE_DP * displayDensity) - stopIconSize = typedArray.getDimension(R.styleable.TransferProgressView_stopIconSize, ICON_SIZE_DP * displayDensity) - stopIconCornerRadius = typedArray.getDimension(R.styleable.TransferProgressView_stopIconCornerRadius, STOP_CORNER_RADIUS_DP * displayDensity) - - typedArray.recycle() - - progressPaint = progressPaint(progressColor) - stopIconPaint = stopIconPaint(iconColor) - trackPaint = trackPaint(trackColor) - - val filter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_ATOP) - downloadDrawable?.colorFilter = filter - uploadDrawable?.colorFilter = filter - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - when (currentState) { - State.IN_PROGRESS_CANCELABLE -> drawProgress(canvas, progressPercent, true) - State.IN_PROGRESS_NON_CANCELABLE -> drawProgress(canvas, progressPercent, false) - State.READY_TO_UPLOAD -> sizeAndDrawDrawable(canvas, uploadDrawable) - State.READY_TO_DOWNLOAD -> sizeAndDrawDrawable(canvas, downloadDrawable) - State.UNINITIALIZED -> Unit - } - } - - fun setProgress(progress: Float) { - currentState = State.IN_PROGRESS_CANCELABLE - if (cancelClickListener == null) { - Log.i(TAG, "Illegal click listener attached.") - } else { - setOnClickListener(cancelClickListener) - } - 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, showStopIcon: Boolean) { - if (showStopIcon) { - stopIconRect.set(0f, 0f, stopIconSize, stopIconSize) - - canvas.withTranslation(width / 2 - (stopIconSize / 2), height / 2 - (stopIconSize / 2)) { - drawRoundRect(stopIconRect, stopIconCornerRadius, stopIconCornerRadius, stopIconPaint) - } - } - - val trackWidthScaled = progressArcStrokeWidth - val inset: Float = PROGRESS_BAR_INSET_DP * Resources.getSystem().displayMetrics.density - progressRect.left = trackWidthScaled + inset - progressRect.top = trackWidthScaled + inset - progressRect.right = (width - trackWidthScaled) - inset - progressRect.bottom = (height - trackWidthScaled) - inset - - canvas.drawArc(progressRect, 0f, 360f, false, trackPaint) - canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint) - } - - private fun stopIconPaint(paintColor: Int): Paint { - val stopIconPaint = Paint() - stopIconPaint.color = paintColor - stopIconPaint.isAntiAlias = true - stopIconPaint.style = Paint.Style.FILL - return stopIconPaint - } - - private fun trackPaint(trackColor: Int): Paint { - val trackPaint = Paint() - trackPaint.color = trackColor - trackPaint.isAntiAlias = true - trackPaint.style = Paint.Style.STROKE - trackPaint.strokeWidth = progressArcStrokeWidth - return trackPaint - } - - private fun progressPaint(progressColor: Int): Paint { - val progressPaint = Paint() - progressPaint.color = progressColor - progressPaint.isAntiAlias = true - progressPaint.style = Paint.Style.STROKE - progressPaint.strokeWidth = progressArcStrokeWidth - return progressPaint - } - - private fun sizeAndDrawDrawable(canvas: Canvas, drawable: Drawable?) { - if (drawable == null) { - Log.w(TAG, "Could not load icon for $currentState") - return - } - - val centerX = width / 2f - val centerY = height / 2f - - // 0, 0 is the top left corner - // width, height is the bottom right - val halfIconSize = (iconSize / 2f) - val left = (centerX - halfIconSize).roundToInt().coerceAtLeast(0) - val top = (centerY - halfIconSize).roundToInt().coerceAtLeast(0) - val right = (centerX + halfIconSize).roundToInt().coerceAtMost(width) - val bottom = (centerY + halfIconSize).roundToInt().coerceAtMost(height) - - drawable.setBounds(left, top, right, bottom) - drawable.draw(canvas) - } - - private enum class State { - IN_PROGRESS_CANCELABLE, - IN_PROGRESS_NON_CANCELABLE, - READY_TO_UPLOAD, - READY_TO_DOWNLOAD, - UNINITIALIZED - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 026bda0d5b..7d772c6bbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -42,7 +42,7 @@ import org.signal.archive.proto.GroupChangeChatUpdate; import org.signal.archive.proto.GroupCreationUpdate; import org.thoughtcrime.securesms.components.emoji.EmojiProvider; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; -import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; +import org.thoughtcrime.securesms.components.transfercontrols.TransferControls; import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.database.MessageTypes; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; @@ -963,7 +963,7 @@ public abstract class MessageRecord extends DisplayRecord { if (slides.isEmpty()) { return false; } - return TransferControlView.getTransferState(slides) == expectedTransferState; + return TransferControls.getTransferState(slides) == expectedTransferState; } return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java index f8dde73be7..a2738f2639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java @@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.DatabaseObserver; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 8591c96c31..98c9729c4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -24,7 +24,6 @@ import com.bumptech.glide.RequestManager; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.conversation.colors.Colorizable; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java index 79d0a1ac91..91c387e780 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java @@ -10,7 +10,6 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.FromTextView; -import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; diff --git a/app/src/main/res/layout/transfer_controls_view.xml b/app/src/main/res/layout/transfer_controls_view.xml deleted file mode 100644 index ff4373c09f..0000000000 --- a/app/src/main/res/layout/transfer_controls_view.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8cf40eba6..ee6914b797 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3525,6 +3525,10 @@ Processing… %1$s/%2$s + + Download + + Upload diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt new file mode 100644 index 0000000000..bdbdd28ccc --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.transfercontrols + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.signal.core.util.bytes +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.util.MediaUtil + +class TransferControlsTest { + + @Before + fun setUp() { + mockkStatic(MediaUtil::class) + every { MediaUtil.isInstantVideoSupported(any()) } returns false + } + + @After + fun tearDown() { + unmockkStatic(MediaUtil::class) + } + + @Test + fun `empty slides is Gone`() { + assertEquals(TransferControlsRenderState.Gone, TransferControls.deriveRenderState(stateOf(emptyList()))) + } + + @Test + fun `all done is Gone`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_DONE), slide(AttachmentTable.TRANSFER_PROGRESS_DONE))) + assertEquals(TransferControlsRenderState.Gone, TransferControls.deriveRenderState(state)) + } + + @Test + fun `not visible is Gone`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING)), isVisible = false) + assertEquals(TransferControlsRenderState.Gone, TransferControls.deriveRenderState(state)) + } + + @Test + fun `awaiting primary single item is centered indeterminate non-cancelable`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_NEEDS_RESTORE)), awaitingPrimaryResponse = true) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertNull(render.progress) + assertEquals(TransferControls.Placement.CENTER, render.placement) + assertFalse(render.cancelable) + assertFalse(render.isUpload) + } + + @Test + fun `awaiting primary gallery is corner indeterminate`() { + val state = stateOf( + listOf(slide(AttachmentTable.TRANSFER_NEEDS_RESTORE), slide(AttachmentTable.TRANSFER_NEEDS_RESTORE)), + awaitingPrimaryResponse = true + ) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertNull(render.progress) + assertEquals(TransferControls.Placement.CORNER, render.placement) + } + + @Test + fun `awaiting primary still Gone when not visible`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_NEEDS_RESTORE)), awaitingPrimaryResponse = true, isVisible = false) + assertEquals(TransferControlsRenderState.Gone, TransferControls.deriveRenderState(state)) + } + + @Test + fun `single image pending download is centered pending`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING))) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertEquals(TransferControls.Placement.CENTER, render.placement) + assertFalse(render.showPlayButton) + assertNull(render.itemCount) + } + + @Test + fun `single image downloading is centered in-progress cancelable`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED))) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.Placement.CENTER, render.placement) + assertTrue(render.cancelable) + assertFalse(render.isUpload) + } + + @Test + fun `single image failed download is retry download`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_FAILED))) + assertEquals(TransferControlsRenderState.Retry(isUpload = false), TransferControls.deriveRenderState(state)) + } + + @Test + fun `single image uploading is corner in-progress upload`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED)), isUpload = true) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertTrue(render.isUpload) + } + + @Test + fun `single image failed upload is retry upload`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_FAILED)), isUpload = true) + assertEquals(TransferControlsRenderState.Retry(isUpload = true), TransferControls.deriveRenderState(state)) + } + + @Test + fun `playable video pending download shows play button in corner`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING, hasVideo = true)), playableWhileDownloading = true) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertTrue(render.showPlayButton) + } + + @Test + fun `playable video downloading shows play button in corner`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, hasVideo = true)), playableWhileDownloading = true) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertTrue(render.showPlayButton) + } + + @Test + fun `gallery downloading is corner in-progress`() { + val state = stateOf( + listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED), slide(AttachmentTable.TRANSFER_PROGRESS_STARTED)), + networkProgress = mapOf() + ).let { base -> + // give it some completed progress so it is cancelable + val map = base.slides.associate { it.asAttachment() to TransferControlView.Progress(512L.bytes, 1024L.bytes) } + base.copy(networkProgress = map) + } + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertTrue(render.cancelable) + } + + @Test + fun `gallery downloading at zero progress is not cancelable`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED), slide(AttachmentTable.TRANSFER_PROGRESS_STARTED))) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertFalse(render.cancelable) + } + + @Test + fun `gallery pending non-playable is centered with item count`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING), slide(AttachmentTable.TRANSFER_PROGRESS_PENDING))) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertEquals(TransferControls.Placement.CENTER, render.placement) + assertEquals(2, render.itemCount) + } + + @Test + fun `gallery pending playable is corner without item count`() { + every { MediaUtil.isInstantVideoSupported(any()) } returns true + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING), slide(AttachmentTable.TRANSFER_PROGRESS_PENDING))) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertEquals(TransferControls.Placement.CORNER, render.placement) + assertNull(render.itemCount) + } + + @Test + fun `gallery failed download is retry`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_FAILED), slide(AttachmentTable.TRANSFER_PROGRESS_FAILED))) + assertEquals(TransferControlsRenderState.Retry(isUpload = false), TransferControls.deriveRenderState(state)) + } + + @Test + fun `pending hides size text when showSecondaryText is false`() { + val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING)), showSecondaryText = false) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertNull(render.sizeBytes) + } + + @Test + fun `download label uses fixed slide size as denominator, not network total`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + // Network total (2000) is intentionally larger than the slide's fixed file size (1000) to prove the denominator + // comes from the slide size, which does not ramp up mid-transfer. + val state = stateOf(slides, networkProgress = progressOf(slides, completed = 500, total = 2000)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Bytes(500L.bytes, 1000L.bytes), render.label) + } + + @Test + fun `download label clamps completed to total`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + // Network bytes include encryption overhead, so completed can edge past the file size; it should clamp to total. + val state = stateOf(slides, networkProgress = progressOf(slides, completed = 1100, total = 1100)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Bytes(1000L.bytes, 1000L.bytes), render.label) + } + + @Test + fun `upload with no bytes sent shows Processing`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + val state = stateOf(slides, isUpload = true, networkProgress = progressOf(slides, completed = 0, total = 1000)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Processing, render.label) + } + + @Test + fun `upload while still compressing shows Processing`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + val state = stateOf( + slides, + isUpload = true, + // Some bytes have been transmitted (so the zero-bytes branch does not apply)... + networkProgress = progressOf(slides, completed = 500, total = 1000), + // ...but compression is only halfway done, which should still read as Processing. + compressionProgress = progressOf(slides, completed = 500, total = 1000) + ) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Processing, render.label) + } + + @Test + fun `upload that is transmitting with no pending compression shows Bytes`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + val state = stateOf(slides, isUpload = true, networkProgress = progressOf(slides, completed = 500, total = 1000)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Bytes(500L.bytes, 1000L.bytes), render.label) + } + + @Test + fun `upload label uses network total, not pre-transcode slide size`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 12_000_000)) + val state = stateOf(slides, isUpload = true, networkProgress = progressOf(slides, completed = 200_000, total = 400_000)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + assertEquals(TransferControls.ProgressLabel.Bytes(200_000L.bytes, 400_000L.bytes), render.label) + } + + @Test + fun `calculateProgress weights compression three to one against network`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_STARTED, size = 1000)) + val state = stateOf( + slides, + isUpload = true, + networkProgress = progressOf(slides, completed = 500, total = 1000), + compressionProgress = progressOf(slides, completed = 1000, total = 1000) + ) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.InProgress + // weighted = (1 * 0.5) + (3 * 1.0) = 3.5; total weight = (1 * 1) + (3 * 1) = 4; 3.5 / 4 = 0.875 + assertEquals(0.875f, render.progress!!, 0.0001f) + } + + @Test + fun `gallery pending size is remaining network bytes`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING), slide(AttachmentTable.TRANSFER_PROGRESS_PENDING)) + // Two slides, each 200/1000 complete -> remaining = sumTotal(2000) - sumCompleted(400) = 1600 + val state = stateOf(slides, networkProgress = progressOf(slides, completed = 200, total = 1000)) + val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending + assertEquals(1600L.bytes, render.sizeBytes) + } + + @Test + fun `getTransferState prefers pending over done`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_DONE), slide(AttachmentTable.TRANSFER_PROGRESS_PENDING)) + assertEquals(AttachmentTable.TRANSFER_PROGRESS_PENDING, TransferControls.getTransferState(slides)) + } + + @Test + fun `getTransferState all permanent failures is permanent failure`() { + val slides = listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE), slide(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)) + assertEquals(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE, TransferControls.getTransferState(slides)) + } + + private fun slide( + transferState: Int, + hasVideo: Boolean = false, + size: Long = 1024 + ): Slide { + val attachment = mockk(relaxed = true) + val slide = mockk(relaxed = true) + every { slide.transferState } returns transferState + every { slide.hasVideo() } returns hasVideo + every { slide.asAttachment() } returns attachment + every { slide.fileSize } returns size + return slide + } + + private fun stateOf( + slides: List, + isUpload: Boolean = false, + playableWhileDownloading: Boolean = false, + isVisible: Boolean = true, + showSecondaryText: Boolean = true, + awaitingPrimaryResponse: Boolean = false, + networkProgress: Map = slides.associate { it.asAttachment() to TransferControlView.Progress(0L.bytes, 1024L.bytes) }, + compressionProgress: Map = emptyMap() + ): TransferControlViewState { + return TransferControlViewState( + slides = slides, + isUpload = isUpload, + playableWhileDownloading = playableWhileDownloading, + isVisible = isVisible, + showSecondaryText = showSecondaryText, + awaitingPrimaryResponse = awaitingPrimaryResponse, + networkProgress = networkProgress, + compressionProgress = compressionProgress + ) + } + + private fun progressOf(slides: List, completed: Long, total: Long): Map { + return slides.associate { it.asAttachment() to TransferControlView.Progress(completed.bytes, total.bytes) } + } +} diff --git a/core/util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt index a9ccc192a1..22f26a0d55 100644 --- a/core/util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt @@ -66,34 +66,61 @@ class ByteSize(val bytes: Long) { val inTebiBytes: Float get() = inGibiBytes / 1024f - fun getLargestNonZeroValue(): Pair { + fun getLargestNonZeroSize(): Size { return when { - inWholeTebiBytes > 0L -> inTebiBytes to Size.TEBIBYTE - inWholeGibiBytes > 0L -> inGibiBytes to Size.GIBIBYTE - inWholeMebiBytes > 0L -> inMebiBytes to Size.MEBIBYTE - inWholeKibiBytes > 0L -> inKibiBytes to Size.KIBIBYTE - else -> inWholeBytes.toFloat() to Size.BYTE + inWholeTebiBytes > 0L -> Size.TEBIBYTE + inWholeGibiBytes > 0L -> Size.GIBIBYTE + inWholeMebiBytes > 0L -> Size.MEBIBYTE + inWholeKibiBytes > 0L -> Size.KIBIBYTE + else -> Size.BYTE + } + } + + /** The value of this size expressed in [size] (e.g. [Size.MEBIBYTE] -> a count of mebibytes). */ + fun inUnit(size: Size): Float { + return when (size) { + Size.BYTE -> inWholeBytes.toFloat() + Size.KIBIBYTE -> inKibiBytes + Size.MEBIBYTE -> inMebiBytes + Size.GIBIBYTE -> inGibiBytes + Size.TEBIBYTE -> inTebiBytes } } @JvmOverloads fun toUnitString(maxPlaces: Int = 2, spaced: Boolean = true): String { - val (size, unit) = getLargestNonZeroValue() + return toUnitString(getLargestNonZeroSize(), maxPlaces, spaced) + } - val formatter = NumberFormat.getInstance().apply { - minimumFractionDigits = 0 - maximumFractionDigits = when (unit) { - Size.BYTE, - Size.KIBIBYTE -> 0 + /** + * Format as a specific unit. + * + * @param unit The unit to use when rendering + * @param maxPlaces Max number of digits to the right of the decimal + * @param padDecimals If true add zeros as necessary to match [maxPlaces] + * @param withUnit If true include the unit label in the text + */ + @JvmOverloads + fun toUnitString(unit: Size, maxPlaces: Int = 2, spaced: Boolean = true, padDecimals: Boolean = false, withUnit: Boolean = true): String { + val size: Float = inUnit(unit) - Size.MEBIBYTE -> min(1, maxPlaces) + val places = when (unit) { + Size.BYTE, + Size.KIBIBYTE -> 0 - Size.GIBIBYTE, - Size.TEBIBYTE -> min(2, maxPlaces) - } + Size.MEBIBYTE -> min(1, maxPlaces) + + Size.GIBIBYTE, + Size.TEBIBYTE -> min(2, maxPlaces) } - return BidiUtil.forceLtr("${formatter.format(size)}${if (spaced) " " else ""}${unit.label}") + val formatter = NumberFormat.getInstance().apply { + minimumFractionDigits = if (padDecimals) places else 0 + maximumFractionDigits = places + } + + val suffix = if (withUnit) "${if (spaced) " " else ""}${unit.label}" else "" + return BidiUtil.forceLtr("${formatter.format(size)}$suffix") } operator fun compareTo(other: ByteSize): Int {