Convert TransferControlView rendering to compose.
This commit is contained in:
parent
73557ae72a
commit
a5359e05a3
@ -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);
|
||||
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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 -->
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user