Convert TransferControlView rendering to compose.

This commit is contained in:
Cody Henthorne 2026-06-08 09:50:02 -04:00
parent 73557ae72a
commit a5359e05a3
16 changed files with 1330 additions and 1017 deletions

View File

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

View File

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

View File

@ -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>(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<View>, inactiveViews: List<View>) {
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<Slide>) {
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<Attachment, Progress> = 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<Slide>): String {
if (!VERBOSE_DEVELOPMENT_LOGGING) {
return ""
}
return slides.map { it.asAttachment().uploadTimestamp }.joinToString()
}
private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List<Slide>): 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<Slide>): Int {
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
var allFailed = true
for (slide in slides) {
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
slide.transferState
} else {
transferState.coerceAtLeast(slide.transferState)
}
}
}
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
}
@JvmStatic
fun containsPlayableSlides(slides: List<Slide>): Boolean {
return slides.any { MediaUtil.isInstantVideoSupported(it) }
}
private fun slidesAsLogString(slides: List<Slide>): 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<Attachment, Progress>.sumCompleted(): ByteSize {
return this.values.sumOf { it.completed.inWholeBytes }.bytes
}
private fun Map<Attachment, Progress>.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
}
}

View File

@ -21,5 +21,6 @@ data class TransferControlViewState(
val networkProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val compressionProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val playableWhileDownloading: Boolean = false,
val isUpload: Boolean = false
val isUpload: Boolean = false,
val awaitingPrimaryResponse: Boolean = false
)

View File

@ -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<Slide>): Int {
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
var allFailed = true
for (slide in slides) {
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
slide.transferState
} else {
transferState.coerceAtLeast(slide.transferState)
}
}
}
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
}
@JvmStatic
fun containsPlayableSlides(slides: List<Slide>): Boolean {
return slides.any { MediaUtil.isInstantVideoSupported(it) }
}
private fun Map<Attachment, TransferControlView.Progress>.sumCompleted(): ByteSize {
return this.values.sumOf { it.completed.inWholeBytes }.bytes
}
private fun Map<Attachment, TransferControlView.Progress>.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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,138 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:parentTag="org.thoughtcrime.securesms.components.transfercontrols.TransferControlView">
<View
android:id="@+id/secondary_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/transfer_controls_background"
android:longClickable="false"
app:layout_constraintBottom_toBottomOf="@+id/secondary_details_text"
app:layout_constraintEnd_toEndOf="@+id/secondary_details_text"
app:layout_constraintStart_toStartOf="@+id/secondary_progress_view"
app:layout_constraintTop_toTopOf="@+id/secondary_details_text" />
<View
android:id="@+id/primary_background"
android:layout_width="0dp"
android:layout_height="@dimen/transfer_control_view_primary_background_height"
android:layout_gravity="center"
android:background="@drawable/transfer_controls_play_background"
android:longClickable="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/primary_details_text"
app:layout_constraintStart_toStartOf="@+id/primary_progress_view"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.transfercontrols.TransferProgressView
android:id="@+id/secondary_progress_view"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:paddingStart="4dp"
android:paddingTop="4dp"
android:paddingEnd="-4dp"
android:paddingBottom="4dp"
app:iconSize="16dp"
app:layout_constraintStart_toStartOf="@+id/vertical_guideline"
app:layout_constraintTop_toTopOf="@+id/horizontal_guideline"
app:stopIconCornerRadius="1dp"
app:stopIconSize="8dp" />
<TextView
android:id="@+id/secondary_details_text"
style="@style/Signal.Text.Caption"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="@dimen/transfer_control_view_progressbar_to_textview_margin"
android:layout_marginEnd="4dp"
android:fontFamily="sans-serif-light"
android:gravity="center_vertical|end"
android:includeFontPadding="false"
android:longClickable="false"
android:maxLines="1"
android:paddingStart="4dp"
android:paddingTop="2dp"
android:paddingEnd="9dp"
android:paddingBottom="2dp"
android:textAlignment="viewEnd"
android:textColor="@color/signal_colorOnCustom"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@+id/secondary_progress_view"
app:layout_constraintTop_toTopOf="@+id/secondary_progress_view" />
<org.thoughtcrime.securesms.components.transfercontrols.TransferProgressView
android:id="@+id/primary_progress_view"
android:layout_width="@dimen/transfer_control_view_primary_background_height"
android:layout_height="@dimen/transfer_control_view_primary_background_height"
app:iconSize="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/primary_details_text"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:stopIconCornerRadius="2dp"
app:stopIconSize="14dp" />
<TextView
android:id="@+id/primary_details_text"
style="@style/Signal.Text.BodyLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:longClickable="false"
android:paddingStart="0dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:textColor="@color/signal_colorOnCustom"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="@+id/primary_progress_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/primary_progress_view"
app:layout_constraintTop_toTopOf="@+id/primary_progress_view" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/play_video_button"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="12dp"
android:contentDescription="@string/ThumbnailView_Play_video_description"
android:scaleType="fitXY"
android:tint="@color/signal_colorOnCustom"
app:layout_constraintBottom_toBottomOf="@+id/primary_progress_view"
app:layout_constraintEnd_toEndOf="@+id/primary_progress_view"
app:layout_constraintStart_toStartOf="@+id/primary_progress_view"
app:layout_constraintTop_toTopOf="@+id/primary_progress_view"
app:srcCompat="@drawable/triangle_right"
tools:ignore="RtlHardcoded,RtlSymmetry"
tools:visibility="gone" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/vertical_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="4dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/horizontal_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="4dp" />
</merge>

View File

@ -3525,6 +3525,10 @@
<string name="TransferControlView__processing">Processing…</string>
<!-- Status update label used while the device is transmitting data over the network. Will take the form of "1.0 MB/2.0 MB" -->
<string name="TransferControlView__download_progress_s_s">%1$s/%2$s</string>
<!-- Accessibility description for the button that starts downloading an attachment -->
<string name="TransferControlView__download">Download</string>
<!-- Accessibility description for the button that starts uploading an attachment -->
<string name="TransferControlView__upload">Upload</string>
<!-- UnauthorizedReminder -->
<!-- Message shown in a reminder banner when the user\'s device is no longer registered -->

View File

@ -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<Attachment>(relaxed = true)
val slide = mockk<Slide>(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<Slide>,
isUpload: Boolean = false,
playableWhileDownloading: Boolean = false,
isVisible: Boolean = true,
showSecondaryText: Boolean = true,
awaitingPrimaryResponse: Boolean = false,
networkProgress: Map<Attachment, TransferControlView.Progress> = slides.associate { it.asAttachment() to TransferControlView.Progress(0L.bytes, 1024L.bytes) },
compressionProgress: Map<Attachment, TransferControlView.Progress> = 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<Slide>, completed: Long, total: Long): Map<Attachment, TransferControlView.Progress> {
return slides.associate { it.asAttachment() to TransferControlView.Progress(completed.bytes, total.bytes) }
}
}

View File

@ -66,34 +66,61 @@ class ByteSize(val bytes: Long) {
val inTebiBytes: Float
get() = inGibiBytes / 1024f
fun getLargestNonZeroValue(): Pair<Float, Size> {
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 {