Block avatar downloads in message request states.

This commit is contained in:
Michelle Tang
2025-03-06 12:36:58 -05:00
parent 5592d13258
commit 451d12ed53
28 changed files with 991 additions and 492 deletions

View File

@@ -1,8 +1,12 @@
package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
@@ -13,6 +17,7 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
@@ -21,11 +26,16 @@ import com.bumptech.glide.RequestManager;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
@@ -34,8 +44,15 @@ import org.whispersystems.signalservice.api.util.Preconditions;
public class ConversationHeaderView extends ConstraintLayout {
private static final String TAG = Log.tag(ConversationHeaderView.class);
private static final int FADE_DURATION = 150;
private static final int LOADING_DELAY = 800;
private final ConversationHeaderViewBinding binding;
private boolean inProgress = false;
private Handler handler = new Handler();
public ConversationHeaderView(Context context) {
this(context, null);
}
@@ -52,6 +69,30 @@ public class ConversationHeaderView extends ConstraintLayout {
binding = ConversationHeaderViewBinding.bind(this);
}
public void showProgressBar(@NonNull Recipient recipient) {
if (!inProgress) {
inProgress = true;
animateAvatarLoading(recipient);
binding.messageRequestAvatarTapToView.setVisibility(GONE);
binding.messageRequestAvatarTapToView.setOnClickListener(null);
handler.postDelayed(() -> {
boolean isDownloading = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS;
binding.progressBar.setVisibility(isDownloading ? View.VISIBLE : View.GONE);
}, LOADING_DELAY);
}
}
public void hideProgressBar() {
inProgress = false;
binding.progressBar.setVisibility(View.GONE);
}
public void showFailedAvatarDownload(@NonNull Recipient recipient) {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE);
binding.progressBar.setVisibility(View.GONE);
binding.messageRequestAvatar.setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient));
}
public void setBadge(@Nullable Recipient recipient) {
if (recipient == null || recipient.isSelf()) {
binding.messageRequestBadge.setBadge(null);
@@ -61,12 +102,25 @@ public class ConversationHeaderView extends ConstraintLayout {
}
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient) {
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false);
if (recipient == null) {
return;
}
if (recipient != null && recipient.getShouldBlurAvatar() && recipient.getContactPhoto() != null) {
if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false, false, true);
hideProgressBar();
}
if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) {
binding.messageRequestAvatarTapToView.setVisibility(VISIBLE);
binding.messageRequestAvatarTapToView.setOnClickListener(v -> {
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyShowAvatar(recipient.getId()));
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS);
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true));
if (recipient.isPushV2Group()) {
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2());
} else {
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient);
}
});
} else {
binding.messageRequestAvatarTapToView.setVisibility(GONE);
@@ -209,6 +263,22 @@ public class ConversationHeaderView extends ConstraintLayout {
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
}
private void animateAvatarLoading(@NonNull Recipient recipient) {
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setImageDrawable(loadingProfile);
}
ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start();
}
});
animator.start();
}
private void updateOutlineVisibility() {
if (ViewKt.isVisible(binding.messageRequestSubtitle) || ViewKt.isVisible(binding.messageRequestDescription)) {
if (getBackground() != null) {

View File

@@ -142,7 +142,7 @@ public class ConversationTitleView extends ConstraintLayout {
title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, null, endDrawable, null);
if (recipient != null) {
this.avatar.displayChatAvatar(requestManager, recipient, false);
this.avatar.displayChatAvatar(requestManager, recipient, false, true);
}
if (recipient == null || recipient.isSelf()) {

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.conversation.colors
import android.graphics.drawable.GradientDrawable
import androidx.annotation.ColorInt
import org.thoughtcrime.securesms.recipients.Recipient
import kotlin.jvm.optionals.getOrNull
import kotlin.math.abs
/**
* Lists gradients used to hide profiles during message request states
*/
object AvatarGradientColors {
@JvmStatic
fun getGradientDrawable(recipient: Recipient): GradientDrawable {
return if (recipient.serviceId.getOrNull() != null) {
gradients[abs(recipient.requireServiceId().hashCode() % gradients.size)].getDrawable()
} else if (recipient.groupId.getOrNull() != null) {
gradients[abs(recipient.requireGroupId().hashCode() % gradients.size)].getDrawable()
} else {
gradients[0].getDrawable()
}
}
private val gradients = listOf(
AvatarGradientColor(0xFF252568.toInt(), 0xFF9C8F8F.toInt()),
AvatarGradientColor(0xFF2A4275.toInt(), 0xFF9D9EA1.toInt()),
AvatarGradientColor(0xFF2E4B5F.toInt(), 0xFF8AA9B1.toInt()),
AvatarGradientColor(0xFF2E426C.toInt(), 0xFF7A9377.toInt()),
AvatarGradientColor(0xFF1A341A.toInt(), 0xFF807F6E.toInt()),
AvatarGradientColor(0xFF464E42.toInt(), 0xFFD5C38F.toInt()),
AvatarGradientColor(0xFF595643.toInt(), 0xFF93A899.toInt()),
AvatarGradientColor(0xFF2C2F36.toInt(), 0xFF687466.toInt()),
AvatarGradientColor(0xFF2B1E18.toInt(), 0xFF968980.toInt()),
AvatarGradientColor(0xFF7B7067.toInt(), 0xFFA5A893.toInt()),
AvatarGradientColor(0xFF706359.toInt(), 0xFFBDA194.toInt()),
AvatarGradientColor(0xFF383331.toInt(), 0xFFA48788.toInt()),
AvatarGradientColor(0xFF924F4F.toInt(), 0xFF897A7A.toInt()),
AvatarGradientColor(0xFF663434.toInt(), 0xFFC58D77.toInt()),
AvatarGradientColor(0xFF8F4B02.toInt(), 0xFFAA9274.toInt()),
AvatarGradientColor(0xFF784747.toInt(), 0xFF8C8F6F.toInt()),
AvatarGradientColor(0xFF747474.toInt(), 0xFFACACAC.toInt()),
AvatarGradientColor(0xFF49484C.toInt(), 0xFFA5A6B5.toInt()),
AvatarGradientColor(0xFF4A4E4D.toInt(), 0xFFABAFAE.toInt()),
AvatarGradientColor(0xFF3A3A3A.toInt(), 0xFF929887.toInt())
)
data class AvatarGradientColor(@ColorInt val startGradient: Int, @ColorInt val endGradient: Int) {
fun getDrawable(): GradientDrawable {
val gradientDrawable = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(startGradient, endGradient)
)
gradientDrawable.shape = GradientDrawable.OVAL
return gradientDrawable
}
}
}

View File

@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizable
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.conversation.v2.data.ConversationUpdate
@@ -536,7 +537,19 @@ class ConversationAdapterV2(
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
val isSelf = recipient.id == Recipient.self().id
conversationBanner.setAvatar(requestManager, recipient)
when (model.avatarDownloadState) {
AvatarDownloadStateCache.DownloadState.NONE,
AvatarDownloadStateCache.DownloadState.FINISHED -> {
conversationBanner.setAvatar(requestManager, recipient)
}
AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> {
conversationBanner.showProgressBar(recipient)
}
AvatarDownloadStateCache.DownloadState.FAILED -> {
conversationBanner.showFailedAvatarDownload(recipient)
}
}
conversationBanner.showBackgroundBubble(recipient.hasWallpaper)
val title: String = conversationBanner.setTitle(recipient) {
displayDialogFragment(AboutSheet.create(recipient))

View File

@@ -67,6 +67,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.ConversationLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -202,6 +203,7 @@ import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplace
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsControllerV2
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModelV2
import org.thoughtcrime.securesms.conversation.v2.computed.ConversationMessageComputeWorkers
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel
@@ -1056,6 +1058,28 @@ class ConversationFragment :
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val recipient = viewModel.recipientSnapshot
if (recipient != null) {
AvatarDownloadStateCache.forRecipient(recipient.id).collect {
when (it) {
AvatarDownloadStateCache.DownloadState.NONE,
AvatarDownloadStateCache.DownloadState.IN_PROGRESS,
AvatarDownloadStateCache.DownloadState.FINISHED -> {
viewModel.updateThreadHeader()
}
AvatarDownloadStateCache.DownloadState.FAILED -> {
Snackbar.make(requireView(), R.string.ConversationFragment_photo_failed, Snackbar.LENGTH_LONG).show()
presentConversationTitle(recipient)
viewModel.onAvatarDownloadFailed()
}
}
}
}
}
}
if (TextSecurePreferences.getServiceOutage(context)) {
AppDependencies.jobManager.add(ServiceOutageDetectionJob())
}

View File

@@ -11,6 +11,7 @@ import android.net.Uri
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bumptech.glide.RequestManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
@@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
@@ -55,6 +57,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.Mention
@@ -310,6 +313,20 @@ class ConversationViewModel(
})
}
fun onAvatarDownloadFailed() {
viewModelScope.launch(Dispatchers.IO) {
val recipient = recipientSnapshot
if (recipient != null) {
recipients.manuallyUpdateShowAvatar(recipient.id, false)
}
pagingController.onDataItemChanged(ConversationElementKey.threadHeader)
}
}
fun updateThreadHeader() {
pagingController.onDataItemChanged(ConversationElementKey.threadHeader)
}
fun getBannerFlows(
context: Context,
groupJoinClickListener: () -> Unit,

View File

@@ -4,9 +4,14 @@ import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.Result
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
@@ -29,8 +34,24 @@ class MessageRequestViewModel(
fun onAccept(): Single<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->
val recipient = Recipient.resolved(recipientId)
if (recipient.isPushV2Group) {
if (recipient.shouldBlurAvatar && recipient.hasAvatar) {
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2())
}
val jobs = recipient.participantIds
.map { Recipient.resolved(it) }
.filter { it.shouldBlurAvatar && it.hasAvatar }
.map { RetrieveProfileAvatarJob(it, it.profileAvatar, true, true) }
AppDependencies.jobManager.addAll(jobs)
} else if (recipient.shouldBlurAvatar && recipient.hasAvatar) {
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient)
}
messageRequestRepository.acceptMessageRequest(recipientId, threadId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.conversation.v2.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Cache used to store the progress of both 1:1 and group avatar downloads
*/
object AvatarDownloadStateCache {
private val TAG = Log.tag(AvatarDownloadStateCache::class.java)
private val cache = HashMap<RecipientId, MutableStateFlow<DownloadState>>(100)
@JvmStatic
fun set(recipient: Recipient, downloadState: DownloadState) {
if (cache[recipient.id] == null) {
cache[recipient.id] = MutableStateFlow(downloadState)
} else {
cache[recipient.id]!!.update { downloadState }
}
}
@JvmStatic
fun getDownloadState(recipient: Recipient): DownloadState {
return cache[recipient.id]?.value ?: DownloadState.NONE
}
@JvmStatic
fun forRecipient(id: RecipientId): StateFlow<DownloadState> {
if (cache[id] == null) {
cache[id] = MutableStateFlow(DownloadState.NONE)
}
return cache[id]!!.asStateFlow()
}
enum class DownloadState {
NONE,
IN_PROGRESS,
FINISHED,
FAILED
}
}

View File

@@ -217,7 +217,7 @@ class ConversationDataSource(
}
private fun loadThreadHeader(): ThreadHeader {
return ThreadHeader(messageRequestRepository.getRecipientInfo(threadRecipient.id, threadId))
return ThreadHeader(messageRequestRepository.getRecipientInfo(threadRecipient.id, threadId), AvatarDownloadStateCache.getDownloadState(threadRecipient))
}
private fun ConversationMessage.toMappingModel(): MappingModel<*> {

View File

@@ -73,7 +73,7 @@ data class IncomingMedia(
}
}
data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo) : MappingModel<ThreadHeader> {
data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo, val avatarDownloadState: AvatarDownloadStateCache.DownloadState) : MappingModel<ThreadHeader> {
override fun areItemsTheSame(newItem: ThreadHeader): Boolean {
return true
}