Convert TransferControlView rendering to compose.

This commit is contained in:
Cody Henthorne
2026-06-09 17:21:47 -04:00
parent 73557ae72a
commit a5359e05a3
16 changed files with 1330 additions and 1017 deletions
@@ -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;