From 451d12ed536f3d6442c93f89a7f94c02004871c7 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Thu, 6 Mar 2025 12:36:58 -0500 Subject: [PATCH] Block avatar downloads in message request states. --- .../securesms/avatar/view/AvatarView.kt | 14 +- .../securesms/components/AvatarImageView.java | 59 ++- .../conversation/ConversationHeaderView.java | 76 ++- .../conversation/ConversationTitleView.java | 2 +- .../colors/AvatarGradientColors.kt | 59 +++ .../conversation/v2/ConversationAdapterV2.kt | 15 +- .../conversation/v2/ConversationFragment.kt | 24 + .../conversation/v2/ConversationViewModel.kt | 17 + .../v2/MessageRequestViewModel.kt | 21 + .../v2/data/AvatarDownloadStateCache.kt | 49 ++ .../v2/data/ConversationDataSource.kt | 2 +- .../v2/data/ConversationElements.kt | 2 +- .../securesms/database/RecipientTable.kt | 15 +- .../v2/processing/GroupsV2StateProcessor.kt | 9 +- .../jobs/AvatarGroupsV2DownloadJob.java | 44 +- .../jobs/RetrieveProfileAvatarJob.java | 42 +- .../MessageRequestRepository.java | 4 + .../v2/NotificationExtensions.kt | 16 +- .../securesms/recipients/Recipient.kt | 17 +- .../RecipientBottomSheetDialogFragment.java | 418 ---------------- .../RecipientBottomSheetDialogFragment.kt | 455 ++++++++++++++++++ .../bottomsheet/RecipientDialogViewModel.java | 26 + .../securesms/util/AvatarUtil.java | 20 +- .../securesms/util/ProfileUtil.java | 2 +- .../res/drawable/circle_profile_photo.xml | 13 + .../res/layout/conversation_header_view.xml | 17 +- .../res/layout/recipient_bottom_sheet.xml | 43 ++ app/src/main/res/values/strings.xml | 2 + 28 files changed, 991 insertions(+), 492 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarGradientColors.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/AvatarDownloadStateCache.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt create mode 100644 app/src/main/res/drawable/circle_profile_photo.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt index 66d68f3b9f..29e43554d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt @@ -4,10 +4,12 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.FrameLayout +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.res.use import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors.getGradientDrawable import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.Stories @@ -76,8 +78,16 @@ class AvatarView @JvmOverloads constructor( /** * Displays Note-to-Self */ - fun displayChatAvatar(requestManager: RequestManager, recipient: Recipient, isQuickContactEnabled: Boolean) { - avatar.setAvatar(requestManager, recipient, isQuickContactEnabled) + fun displayChatAvatar(requestManager: RequestManager, recipient: Recipient, isQuickContactEnabled: Boolean, useBlurGradient: Boolean) { + avatar.setAvatar(requestManager, recipient, isQuickContactEnabled, false, useBlurGradient) + } + + fun displayLoadingAvatar() { + avatar.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.circle_profile_photo)) + } + + fun displayGradientBlur(recipient: Recipient) { + avatar.setImageDrawable(getGradientDrawable(recipient)) } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index e0b67a3705..2f8ed6240c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Bitmap; -import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.AttributeSet; @@ -25,14 +24,11 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.load.resource.bitmap.CircleCrop; import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.SimpleTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; -import com.google.android.material.imageview.ShapeableImageView; -import com.google.android.material.shape.RelativeCornerSize; -import com.google.android.material.shape.RoundedCornerTreatment; -import com.google.android.material.shape.ShapeAppearanceModel; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -41,17 +37,18 @@ import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; import org.thoughtcrime.securesms.conversation.colors.AvatarColor; +import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors; import org.thoughtcrime.securesms.conversation.colors.ChatColors; -import org.thoughtcrime.securesms.dependencies.AppDependencies; +import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache; import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; +import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.util.AvatarUtil; -import org.thoughtcrime.securesms.util.BlurTransformation; -import org.thoughtcrime.securesms.util.Util; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -157,9 +154,14 @@ public final class AvatarImageView extends AppCompatImageView { } public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) { + setAvatar(requestManager, recipient, quickContactEnabled, useSelfProfileAvatar, false); + } + + public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar, boolean useBlurGradient) { setAvatar(requestManager, recipient, new AvatarOptions.Builder(this) .withUseSelfProfileAvatar(useSelfProfileAvatar) .withQuickContactEnabled(quickContactEnabled) + .withUseBlurGradient(useBlurGradient) .build()); } @@ -195,23 +197,21 @@ public final class AvatarImageView extends AppCompatImageView { }; } - Drawable fallback = new FallbackAvatarDrawable(getContext(), activeFallbackPhotoProvider.getFallbackAvatar(recipient)).circleCrop(); + boolean wasUnblurred = shouldBlur != blurred; + boolean inProgressDownload = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS; + boolean shouldShowBlurGradient = avatarOptions.useBlurGradient && (!recipient.getShouldShowAvatarByDefault() || inProgressDownload); + boolean shouldHaveAvatar = recipient.getHasAvatar(); + boolean hasAvatar = AvatarHelper.hasAvatar(getContext(), recipient.getId()) || (photo.contactPhoto instanceof SystemContactPhoto); + Drawable fallback = new FallbackAvatarDrawable(getContext(), activeFallbackPhotoProvider.getFallbackAvatar(recipient)).circleCrop(); if (fixedSizeTarget != null) { requestManager.clear(fixedSizeTarget); } - if (photo.contactPhoto != null) { - - List> transforms = new ArrayList<>(); - if (shouldBlur) { - transforms.add(new BlurTransformation(AppDependencies.getApplication(), 0.25f, BlurTransformation.MAX_RADIUS)); - } - transforms.add(new CircleCrop()); - blurred = shouldBlur; + if (photo.contactPhoto != null && hasAvatar && !shouldBlur) { + List> transforms = Collections.singletonList(new CircleCrop()); RequestBuilder request = requestManager.load(photo.contactPhoto) - .dontAnimate() .fallback(fallback) .error(fallback) .diskCacheStrategy(DiskCacheStrategy.ALL) @@ -219,6 +219,13 @@ public final class AvatarImageView extends AppCompatImageView { .transform(new MultiTransformation<>(transforms)) .addListener(redownloadRequestListener); + if (wasUnblurred) { + blurred = shouldBlur; + request = request.transition(DrawableTransitionOptions.withCrossFade(200)); + } else { + request = request.dontAnimate(); + } + if (avatarOptions.fixedSize > 0) { fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize); request.into(fixedSizeTarget); @@ -226,7 +233,13 @@ public final class AvatarImageView extends AppCompatImageView { request.into(this); } + } else if ((shouldBlur || shouldShowBlurGradient) && shouldHaveAvatar) { + setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient)); + blurred = true; } else { + if (!shouldBlur && shouldHaveAvatar && !hasAvatar && recipient.isIndividual()) { + RetrieveProfileAvatarJob.enqueueForceUpdate(recipient); + } setImageDrawable(fallback); } } @@ -336,11 +349,13 @@ public final class AvatarImageView extends AppCompatImageView { private final boolean quickContactEnabled; private final boolean useSelfProfileAvatar; + private final boolean useBlurGradient; private final int fixedSize; private AvatarOptions(@NonNull Builder builder) { this.quickContactEnabled = builder.quickContactEnabled; this.useSelfProfileAvatar = builder.useSelfProfileAvatar; + this.useBlurGradient = builder.useBlurGradient; this.fixedSize = builder.fixedSize; } @@ -350,6 +365,7 @@ public final class AvatarImageView extends AppCompatImageView { private boolean quickContactEnabled = false; private boolean useSelfProfileAvatar = false; + private boolean useBlurGradient = false; private int fixedSize = -1; private Builder(@NonNull AvatarImageView avatarImageView) { @@ -366,6 +382,11 @@ public final class AvatarImageView extends AppCompatImageView { return this; } + public @NonNull Builder withUseBlurGradient(boolean useBlurGradient) { + this.useBlurGradient = useBlurGradient; + return this; + } + public @NonNull Builder withFixedSize(@Px @IntRange(from = 1) int fixedSize) { this.fixedSize = fixedSize; return this; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java index 7a1c0726fc..b28da8ca8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index ed9cd0a899..8e97a77491 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarGradientColors.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarGradientColors.kt new file mode 100644 index 0000000000..994e6f2f59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarGradientColors.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index e690edd341..38097bbd0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index e438b36e91..9e43cd7360 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 1bd04fcb88..629dcfc1e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt index 9e330eeed3..0a17fad298 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt @@ -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> { 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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/AvatarDownloadStateCache.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/AvatarDownloadStateCache.kt new file mode 100644 index 0000000000..e6f1144233 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/AvatarDownloadStateCache.kt @@ -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>(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 { + if (cache[id] == null) { + cache[id] = MutableStateFlow(DownloadState.NONE) + } + + return cache[id]!!.asStateFlow() + } + + enum class DownloadState { + NONE, + IN_PROGRESS, + FINISHED, + FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index 577116fa0a..dbb54e3768 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -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<*> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt index 297398231b..2ec700ea5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt @@ -73,7 +73,7 @@ data class IncomingMedia( } } -data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo) : MappingModel { +data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo, val avatarDownloadState: AvatarDownloadStateCache.DownloadState) : MappingModel { override fun areItemsTheSame(newItem: ThreadHeader): Boolean { return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index abdad1eb60..69b33b555b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -1859,7 +1859,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } - fun setProfileAvatar(id: RecipientId, profileAvatar: String?) { + fun setProfileAvatar(id: RecipientId, profileAvatar: String?, forceNotify: Boolean = false) { val contentValues = ContentValues(1).apply { put(PROFILE_AVATAR, profileAvatar) } @@ -1869,6 +1869,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da rotateStorageId(id) StorageSyncHelper.scheduleSyncForDataChange() } + } else if (forceNotify) { + AppDependencies.databaseObserver.notifyRecipientChanged(id) } } @@ -3815,8 +3817,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } - fun manuallyShowAvatar(recipientId: RecipientId) { - updateExtras(recipientId) { b: RecipientExtras.Builder -> b.manuallyShownAvatar(true) } + fun clearHasGroupsInCommon(recipientId: RecipientId) { + if (update(recipientId, contentValuesOf(GROUPS_IN_COMMON to 0))) { + Log.i(TAG, "Reset $recipientId to have no groups in common.") + Recipient.live(recipientId).refresh() + } + } + + fun manuallyUpdateShowAvatar(recipientId: RecipientId, showAvatar: Boolean) { + updateExtras(recipientId) { b: RecipientExtras.Builder -> b.manuallyShownAvatar(showAvatar) } } fun getCapabilities(id: RecipientId): RecipientRecord.Capabilities? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 2435cd3867..6b83990d9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -555,13 +555,13 @@ class GroupsV2StateProcessor private constructor( AppDependencies.jobManager.add(AvatarGroupsV2DownloadJob(groupId, updatedGroupState.avatar)) } - profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState) + profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState, needsAvatarFetch) } @VisibleForTesting internal class ProfileAndMessageHelper(private val aci: ACI, private val masterKey: GroupMasterKey, private val groupId: GroupId.V2) { - fun setProfileSharing(groupStateDiff: GroupStateDiff, newLocalState: DecryptedGroup) { + fun setProfileSharing(groupStateDiff: GroupStateDiff, newLocalState: DecryptedGroup, needsAvatarFetch: Boolean) { val previousGroupState = groupStateDiff.previousGroupState if (previousGroupState != null && DecryptedGroupUtil.findMemberByAci(previousGroupState.members, aci).isPresent) { @@ -591,8 +591,11 @@ class GroupsV2StateProcessor private constructor( return } else if ((addedBy.isSystemContact || addedBy.isProfileSharing) && !addedBy.isHidden) { Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact + ", profileSharing: " + addedBy.isProfileSharing) - Log.i(TAG, "Added to a group and auto-enabling profile sharing") + Log.i(TAG, "Added to a group and auto-enabling profile sharing and redownloading avatar if needed") SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId).id, true) + if (needsAvatarFetch) { + AppDependencies.jobManager.add(AvatarGroupsV2DownloadJob(groupId, newLocalState.avatar, true)) + } } else { Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java index 5a47eb8d80..33eb9435b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -6,9 +6,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.StreamUtil; +import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -18,6 +20,7 @@ import org.thoughtcrime.securesms.jobmanager.JsonJobData; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.ByteUnit; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; @@ -38,32 +41,48 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { private static final long AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE = ByteUnit.MEGABYTES.toBytes(5); private static final String KEY_GROUP_ID = "group_id"; - private static final String CDN_KEY = "cdn_key"; + private static final String KEY_CDN_KEY = "cdn_key"; + private static final String KEY_FORCE = "force"; private final GroupId.V2 groupId; private final String cdnKey; + private final boolean force; + + public static void enqueueUnblurredAvatar(@NonNull GroupId.V2 groupId) { + SignalExecutors.BOUNDED.execute(() -> { + String cdnKey = SignalDatabase.groups().getGroup(groupId).get().requireV2GroupProperties().getAvatarKey(); + AppDependencies.getJobManager().add(new AvatarGroupsV2DownloadJob(groupId, cdnKey, true)); + }); + } public AvatarGroupsV2DownloadJob(@NonNull GroupId.V2 groupId, @NonNull String cdnKey) { + this(groupId, cdnKey, false); + } + + public AvatarGroupsV2DownloadJob(@NonNull GroupId.V2 groupId, @NonNull String cdnKey, boolean force) { this(new Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setQueue("AvatarGroupsV2DownloadJob::" + groupId) .setMaxAttempts(10) .build(), groupId, - cdnKey); + cdnKey, + force); } - private AvatarGroupsV2DownloadJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, @NonNull String cdnKey) { + private AvatarGroupsV2DownloadJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, @NonNull String cdnKey, boolean force) { super(parameters); this.groupId = groupId; this.cdnKey = cdnKey; + this.force = force; } @Override public @Nullable byte[] serialize() { return new JsonJobData.Builder() .putString(KEY_GROUP_ID, groupId.toString()) - .putString(CDN_KEY, cdnKey) + .putString(KEY_CDN_KEY, cdnKey) + .putBoolean(KEY_FORCE, force) .serialize(); } @@ -91,13 +110,23 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { return; } + Recipient recipient = Recipient.resolved(record.get().getRecipientId()); + if (recipient.getShouldBlurAvatar() && !force) { + Log.w(TAG, "Marking group as having an avatar but not downloading because avatar is blurred"); + database.onAvatarUpdated(groupId, true); + return; + } + Log.i(TAG, "Downloading new avatar for group " + groupId); + if (force) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS); byte[] decryptedAvatar = downloadGroupAvatarBytes(context, record.get().requireV2GroupProperties().getGroupMasterKey(), cdnKey); AvatarHelper.setAvatar(context, record.get().getRecipientId(), decryptedAvatar != null ? new ByteArrayInputStream(decryptedAvatar) : null); database.onAvatarUpdated(groupId, true); + if (force) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.FINISHED); } catch (NonSuccessfulResponseCodeException e) { + if (force) AvatarDownloadStateCache.set(Recipient.resolved(record.get().getRecipientId()), AvatarDownloadStateCache.DownloadState.FAILED); Log.w(TAG, e); } } @@ -136,7 +165,9 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { } @Override - public void onFailure() {} + public void onFailure() { + if (force) AvatarDownloadStateCache.set(Recipient.externalPossiblyMigratedGroup(groupId), AvatarDownloadStateCache.DownloadState.FAILED); + } @Override public boolean onShouldRetry(@NonNull Exception exception) { @@ -150,7 +181,8 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { return new AvatarGroupsV2DownloadJob(parameters, GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2(), - data.getString(CDN_KEY)); + data.getString(KEY_CDN_KEY), + data.getBooleanOrDefault(KEY_FORCE, false)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java index 86e2401ce6..f11e982518 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java @@ -9,6 +9,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -41,35 +42,47 @@ public class RetrieveProfileAvatarJob extends BaseJob { private static final String KEY_PROFILE_AVATAR = "profile_avatar"; private static final String KEY_RECIPIENT = "recipient"; private static final String KEY_FORCE_UPDATE = "force"; + private static final String KEY_FOR_UNBLURRED = "for_unblurred"; private final String profileAvatar; private final Recipient recipient; private final boolean forceUpdate; + private final boolean forUnblurred; public static void enqueueForceUpdate(Recipient recipient) { SignalExecutors.BOUNDED.execute(() -> AppDependencies.getJobManager().add(new RetrieveProfileAvatarJob(recipient, recipient.resolve().getProfileAvatar(), true))); } + public static void enqueueUnblurredAvatar(Recipient recipient) { + SignalExecutors.BOUNDED.execute(() -> AppDependencies.getJobManager().add(new RetrieveProfileAvatarJob(recipient, recipient.resolve().getProfileAvatar(), true, true))); + } + public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar) { this(recipient, profileAvatar, false); } public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar, boolean forceUpdate) { + this(recipient, profileAvatar, forceUpdate, false); + } + + public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar, boolean forceUpdate, boolean forUnblurred) { this(new Job.Parameters.Builder().setQueue("RetrieveProfileAvatarJob::" + recipient.getId().toQueueKey()) .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.HOURS.toMillis(1)) .build(), recipient, profileAvatar, - forceUpdate); + forceUpdate, + forUnblurred); } - private RetrieveProfileAvatarJob(@NonNull Job.Parameters parameters, @NonNull Recipient recipient, String profileAvatar, boolean forceUpdate) { + private RetrieveProfileAvatarJob(@NonNull Job.Parameters parameters, @NonNull Recipient recipient, String profileAvatar, boolean forceUpdate, boolean forUnblurred) { super(parameters); this.recipient = recipient; this.profileAvatar = profileAvatar; this.forceUpdate = forceUpdate; + this.forUnblurred = forUnblurred; } @Override @@ -77,6 +90,7 @@ public class RetrieveProfileAvatarJob extends BaseJob { return new JsonJobData.Builder().putString(KEY_PROFILE_AVATAR, profileAvatar) .putString(KEY_RECIPIENT, recipient.getId().serialize()) .putBoolean(KEY_FORCE_UPDATE, forceUpdate) + .putBoolean(KEY_FOR_UNBLURRED, forUnblurred) .serialize(); } @@ -95,10 +109,17 @@ public class RetrieveProfileAvatarJob extends BaseJob { return; } + if (recipient.getShouldBlurAvatar() && !forUnblurred) { + Log.w(TAG, "Saving profile avatar but not downloading the file because avatar is blurred"); + database.setProfileAvatar(recipient.getId(), profileAvatar, false); + return; + } + + if (forUnblurred) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS); ProfileAvatarFileDetails details = recipient.getProfileAvatarFileDetails(); - if (!details.hasFile() || forceUpdate) { - if (details.getLastModified() > System.currentTimeMillis() || details.getLastModified() + MIN_TIME_BETWEEN_FORCE_RETRY < System.currentTimeMillis()) { - Log.i(TAG, "Forcing re-download of avatar. hasFile: " + details.hasFile()); + if (!details.hasFile() || forceUpdate || forUnblurred) { + if (details.getLastModified() > System.currentTimeMillis() || details.getLastModified() + MIN_TIME_BETWEEN_FORCE_RETRY < System.currentTimeMillis() || forUnblurred) { + Log.i(TAG, "Forcing re-download of avatar. hasFile: " + details.hasFile() + " unblurred: " + forUnblurred); } else { Log.i(TAG, "Too early to force re-download avatar. hasFile: " + details.hasFile()); return; @@ -112,8 +133,9 @@ public class RetrieveProfileAvatarJob extends BaseJob { if (AvatarHelper.hasAvatar(context, recipient.getId())) { Log.w(TAG, "Removing profile avatar (no url) for: " + recipient.getId().serialize()); AvatarHelper.delete(context, recipient.getId()); - database.setProfileAvatar(recipient.getId(), profileAvatar); + database.setProfileAvatar(recipient.getId(), profileAvatar, false); } + if (forUnblurred) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.FAILED); return; } @@ -129,10 +151,12 @@ public class RetrieveProfileAvatarJob extends BaseJob { if (recipient.isSelf()) { SignalStore.misc().setHasEverHadAnAvatar(true); } + if (forUnblurred) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.FINISHED); } catch (AssertionError e) { throw new IOException("Failed to copy stream. Likely a Conscrypt issue.", e); } } catch (PushNetworkException e) { + if (forUnblurred) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.FAILED); if (e.getCause() instanceof NonSuccessfulResponseCodeException) { Log.w(TAG, "Removing profile avatar (no image available) for: " + recipient.getId().serialize()); AvatarHelper.delete(context, recipient.getId()); @@ -143,7 +167,7 @@ public class RetrieveProfileAvatarJob extends BaseJob { if (downloadDestination != null) downloadDestination.delete(); } - database.setProfileAvatar(recipient.getId(), profileAvatar); + database.setProfileAvatar(recipient.getId(), profileAvatar, forUnblurred); } @Override @@ -154,6 +178,7 @@ public class RetrieveProfileAvatarJob extends BaseJob { @Override public void onFailure() { + if (forUnblurred) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.FAILED); } public static final class Factory implements Job.Factory { @@ -165,7 +190,8 @@ public class RetrieveProfileAvatarJob extends BaseJob { return new RetrieveProfileAvatarJob(parameters, Recipient.resolved(RecipientId.from(data.getString(KEY_RECIPIENT))), data.getString(KEY_PROFILE_AVATAR), - data.getBooleanOrDefault(KEY_FORCE_UPDATE, false)); + data.getBooleanOrDefault(KEY_FORCE_UPDATE, false), + data.getBooleanOrDefault(KEY_FOR_UNBLURRED, false)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 27a9ea2474..bb4174fa0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -84,6 +84,10 @@ public final class MessageRequestRepository { Recipient recipient = Recipient.resolved(recipientId); + if (sharedGroups.isEmpty() && recipient.getHasGroupsInCommon()) { + SignalDatabase.recipients().clearHasGroupsInCommon(recipient.getId()); + } + return new MessageRequestRecipientInfo( recipient, groupInfo, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt index ef5e8d8910..7c3524cf3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Build import com.bumptech.glide.Glide import com.bumptech.glide.load.MultiTransformation -import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CircleCrop import org.thoughtcrime.securesms.R @@ -17,12 +16,11 @@ import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto -import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.BitmapUtil -import org.thoughtcrime.securesms.util.BlurTransformation import java.util.concurrent.ExecutionException fun Drawable?.toLargeBitmap(context: Context): Bitmap? { @@ -38,18 +36,14 @@ fun Drawable?.toLargeBitmap(context: Context): Bitmap? { fun Recipient.getContactDrawable(context: Context): Drawable? { val contactPhoto: ContactPhoto? = if (isSelf) ProfileContactPhoto(this) else contactPhoto val fallbackAvatar: FallbackAvatar = if (isSelf) getFallback(context) else getFallbackAvatar() - return if (contactPhoto != null) { + return if (shouldBlurAvatar && hasAvatar) { + return AvatarGradientColors.getGradientDrawable(this) + } else if (contactPhoto != null) { try { - val transforms: MutableList> = mutableListOf() - if (shouldBlurAvatar) { - transforms += BlurTransformation(AppDependencies.application, 0.25f, BlurTransformation.MAX_RADIUS) - } - transforms += CircleCrop() - Glide.with(context.applicationContext) .load(contactPhoto) .diskCacheStrategy(DiskCacheStrategy.ALL) - .transform(MultiTransformation(transforms)) + .transform(MultiTransformation(listOf(CircleCrop()))) .submit( context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width), context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_height) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index c7cc3d5a32..3699a41f4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -257,6 +257,14 @@ class Recipient( null } + /** + * Whether or not a recipient (either individual or group) has a corresponding avatar + */ + val hasAvatar: Boolean + get() { + return (isIndividual && profileAvatar != null) || (isGroup && groupAvatarId.orElse(0L) != 0L) + } + /** The URI of the ringtone that should be used when receiving a message from this recipient, if set. */ val messageRingtone: Uri? by lazy { if (messageRingtoneUri != null && messageRingtoneUri.scheme != null && messageRingtoneUri.scheme!!.startsWith("file")) { @@ -378,6 +386,12 @@ class Recipient( return !showOverride && !isSelf && !isProfileSharing && !isSystemContact && !hasGroupsInCommon && isRegistered } + /** Whether or not the recipient's avatar should be shown in the chat list by default. Even if false, user can still manually choose to show the avatar */ + val shouldShowAvatarByDefault: Boolean + get() { + return (isSelf || isProfileSharing || isSystemContact || hasGroupsInCommon) && isRegistered + } + /** The chat color to use when the "automatic" chat color setting is active, which derives a color from the wallpaper. */ private val autoChatColor: ChatColors get() = wallpaper?.autoChatColors ?: ChatColorsPalette.Bubbles.default.withId(Auto) @@ -801,7 +815,8 @@ class Recipient( callLinkRoomId == other.callLinkRoomId && phoneNumberSharing == other.phoneNumberSharing && nickname == other.nickname && - note == other.note + note == other.note && + shouldBlurAvatar == other.shouldBlurAvatar } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java deleted file mode 100644 index 9d72f057f4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ /dev/null @@ -1,418 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.bottomsheet; - -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.DialogInterface; -import android.content.Intent; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.ViewModelProvider; - -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.avatar.view.AvatarView; -import org.thoughtcrime.securesms.badges.BadgeImageView; -import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment; -import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar; -import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon; -import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference; -import org.thoughtcrime.securesms.fonts.SignalSymbols; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.nicknames.NicknameActivity; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientExporter; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet; -import org.thoughtcrime.securesms.util.BottomSheetUtil; -import org.thoughtcrime.securesms.util.ContextUtil; -import org.thoughtcrime.securesms.util.SpanUtil; -import org.thoughtcrime.securesms.util.ThemeUtil; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.WindowUtil; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -import kotlin.Unit; - -/** - * A bottom sheet that shows some simple recipient details, as well as some actions (like calling, - * adding to contacts, etc). - */ -public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogFragment { - - public static final String TAG = Log.tag(RecipientBottomSheetDialogFragment.class); - - public static final int REQUEST_CODE_SYSTEM_CONTACT_SHEET = 1111; - - private static final String ARGS_RECIPIENT_ID = "RECIPIENT_ID"; - private static final String ARGS_GROUP_ID = "GROUP_ID"; - - private RecipientDialogViewModel viewModel; - private AvatarView avatar; - private TextView fullName; - private TextView about; - private TextView nickname; - private TextView blockButton; - private TextView unblockButton; - private TextView addContactButton; - private TextView contactDetailsButton; - private TextView addToGroupButton; - private TextView viewSafetyNumberButton; - private TextView makeGroupAdminButton; - private TextView removeAdminButton; - private TextView removeFromGroupButton; - private ProgressBar adminActionBusy; - private View noteToSelfDescription; - private View buttonStrip; - private View interactionsContainer; - private BadgeImageView badgeImageView; - private Callback callback; - - private ButtonStripPreference.ViewHolder buttonStripViewHolder; - - private ActivityResultLauncher nicknameLauncher; - - public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) { - Recipient recipient = Recipient.resolved(recipientId); - if (recipient.isSelf()) { - AboutSheet.create(recipient).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } else { - Bundle args = new Bundle(); - RecipientBottomSheetDialogFragment fragment = new RecipientBottomSheetDialogFragment(); - - args.putString(ARGS_RECIPIENT_ID, recipientId.serialize()); - if (groupId != null) { - args.putString(ARGS_GROUP_ID, groupId.toString()); - } - - fragment.setArguments(args); - - fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - setStyle(DialogFragment.STYLE_NORMAL, - ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet - : R.style.Theme_Signal_RoundedBottomSheet_Light); - - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.recipient_bottom_sheet, container, false); - - avatar = view.findViewById(R.id.rbs_recipient_avatar); - fullName = view.findViewById(R.id.rbs_full_name); - about = view.findViewById(R.id.rbs_about); - nickname = view.findViewById(R.id.rbs_nickname_button); - blockButton = view.findViewById(R.id.rbs_block_button); - unblockButton = view.findViewById(R.id.rbs_unblock_button); - addContactButton = view.findViewById(R.id.rbs_add_contact_button); - contactDetailsButton = view.findViewById(R.id.rbs_contact_details_button); - addToGroupButton = view.findViewById(R.id.rbs_add_to_group_button); - viewSafetyNumberButton = view.findViewById(R.id.rbs_view_safety_number_button); - makeGroupAdminButton = view.findViewById(R.id.rbs_make_group_admin_button); - removeAdminButton = view.findViewById(R.id.rbs_remove_group_admin_button); - removeFromGroupButton = view.findViewById(R.id.rbs_remove_from_group_button); - adminActionBusy = view.findViewById(R.id.rbs_admin_action_busy); - noteToSelfDescription = view.findViewById(R.id.rbs_note_to_self_description); - buttonStrip = view.findViewById(R.id.button_strip); - interactionsContainer = view.findViewById(R.id.interactions_container); - badgeImageView = view.findViewById(R.id.rbs_badge); - - buttonStripViewHolder = new ButtonStripPreference.ViewHolder(buttonStrip); - return view; - } - - @Override - public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) { - super.onViewCreated(fragmentView, savedInstanceState); - - nicknameLauncher = registerForActivityResult(new NicknameActivity.Contract(), (b) -> {}); - - Bundle arguments = requireArguments(); - RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID))); - GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)); - - RecipientDialogViewModel.Factory factory = new RecipientDialogViewModel.Factory(requireContext().getApplicationContext(), recipientId, groupId); - - viewModel = new ViewModelProvider(this, factory).get(RecipientDialogViewModel.class); - - viewModel.getStoryViewState().observe(getViewLifecycleOwner(), state -> { - avatar.setStoryRingFromState(state); - }); - - viewModel.getRecipient().observe(getViewLifecycleOwner(), recipient -> { - interactionsContainer.setVisibility(recipient.isSelf() ? View.GONE : View.VISIBLE); - avatar.displayChatAvatar(recipient); - - if (!recipient.isSelf()) { - badgeImageView.setBadgeFromRecipient(recipient); - } - - if (recipient.isSelf()) { - avatar.setOnClickListener(v -> { - dismiss(); - viewModel.onNoteToSelfClicked(requireActivity()); - }); - } - - String name = recipient.isSelf() ? requireContext().getString(R.string.note_to_self) - : recipient.getDisplayName(requireContext()); - fullName.setVisibility(TextUtils.isEmpty(name) ? View.GONE : View.VISIBLE); - SpannableStringBuilder nameBuilder = new SpannableStringBuilder(name); - if (recipient.getShowVerified()) { - SpanUtil.appendSpacer(nameBuilder, 8); - SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28); - } else if (recipient.isSystemContact()) { - CharSequence systemContactGlyph = SignalSymbols.getSpannedString(requireContext(), - SignalSymbols.Weight.BOLD, - SignalSymbols.Glyph.PERSON_CIRCLE); - - nameBuilder.append(" "); - nameBuilder.append(SpanUtil.ofSize(systemContactGlyph, 20)); - } - - if (!recipient.isSelf() && recipient.isIndividual()) { - CharSequence chevronGlyph = SignalSymbols.getSpannedString(requireContext(), - SignalSymbols.Weight.BOLD, - SignalSymbols.Glyph.CHEVRON_RIGHT); - - nameBuilder.append(" "); - nameBuilder.append(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOutline), - SpanUtil.ofSize(chevronGlyph, 24))); - - fullName.setText(nameBuilder); - fullName.setOnClickListener(v -> { - dismiss(); - AboutSheet.create(recipient).show(getParentFragmentManager(), null); - }); - - nickname.setVisibility(View.VISIBLE); - nickname.setOnClickListener(v -> { - nicknameLauncher.launch(new NicknameActivity.Args( - recipientId, - false - )); - }); - } - - String aboutText = recipient.getCombinedAboutAndEmoji(); - if (recipient.isReleaseNotes()) { - aboutText = getString(R.string.ReleaseNotes__signal_release_notes_and_news); - } - - if (!Util.isEmpty(aboutText)) { - about.setText(aboutText); - about.setVisibility(View.VISIBLE); - } else { - about.setVisibility(View.GONE); - } - - noteToSelfDescription.setVisibility(recipient.isSelf() ? View.VISIBLE : View.GONE); - - if (RecipientUtil.isBlockable(recipient)) { - boolean blocked = recipient.isBlocked(); - - blockButton .setVisibility(recipient.isSelf() || blocked ? View.GONE : View.VISIBLE); - unblockButton.setVisibility(recipient.isSelf() || !blocked ? View.GONE : View.VISIBLE); - } else { - blockButton .setVisibility(View.GONE); - unblockButton.setVisibility(View.GONE); - } - - boolean isAudioAvailable = recipient.isRegistered() && - !recipient.isGroup() && - !recipient.isBlocked() && - !recipient.isSelf() && - !recipient.isReleaseNotes(); - - ButtonStripPreference.State buttonStripState = new ButtonStripPreference.State( - /* isMessageAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && !recipient.isReleaseNotes(), - /* isVideoAvailable = */ !recipient.isBlocked() && !recipient.isSelf() && recipient.isRegistered(), - /* isAudioAvailable = */ isAudioAvailable, - /* isMuteAvailable = */ false, - /* isSearchAvailable = */ false, - /* isAudioSecure = */ recipient.isRegistered(), - /* isMuted = */ false, - /* isAddToStoryAvailable = */ false - ); - - ButtonStripPreference.Model buttonStripModel = new ButtonStripPreference.Model( - buttonStripState, - DSLSettingsIcon.from(ContextUtil.requireDrawable(requireContext(), R.drawable.selectable_recipient_bottom_sheet_icon_button)), - !viewModel.isDeprecatedOrUnregistered(), - () -> Unit.INSTANCE, - () -> { - dismiss(); - viewModel.onMessageClicked(requireActivity()); - return Unit.INSTANCE; - }, - () -> { - viewModel.onSecureVideoCallClicked(requireActivity(), () -> YouAreAlreadyInACallSnackbar.show(requireView())); - return Unit.INSTANCE; - }, - () -> { - if (buttonStripState.isAudioSecure()) { - viewModel.onSecureCallClicked(requireActivity(), () -> YouAreAlreadyInACallSnackbar.show(requireView())); - } else { - viewModel.onInsecureCallClicked(requireActivity()); - } - return Unit.INSTANCE; - }, - () -> Unit.INSTANCE, - () -> Unit.INSTANCE - ); - - buttonStripViewHolder.bind(buttonStripModel); - - if (recipient.isReleaseNotes()) { - buttonStrip.setVisibility(View.GONE); - } - - if (recipient.isSystemContact() || recipient.isGroup() || recipient.isSelf() || recipient.isBlocked() || recipient.isReleaseNotes() || !recipient.getHasE164() || !recipient.getShouldShowE164()) { - addContactButton.setVisibility(View.GONE); - } else { - addContactButton.setVisibility(View.VISIBLE); - addContactButton.setOnClickListener(v -> { - openSystemContactSheet(RecipientExporter.export(recipient).asAddContactIntent()); - }); - } - - if (recipient.isSystemContact() && !recipient.isGroup() && !recipient.isSelf()) { - contactDetailsButton.setVisibility(View.VISIBLE); - contactDetailsButton.setOnClickListener(v -> { - openSystemContactSheet(new Intent(Intent.ACTION_VIEW, recipient.getContactUri())); - }); - } else { - contactDetailsButton.setVisibility(View.GONE); - } - }); - - viewModel.getCanAddToAGroup().observe(getViewLifecycleOwner(), canAdd -> { - addToGroupButton.setText(groupId == null ? R.string.RecipientBottomSheet_add_to_a_group : R.string.RecipientBottomSheet_add_to_another_group); - addToGroupButton.setVisibility(canAdd ? View.VISIBLE : View.GONE); - }); - - viewModel.getAdminActionStatus().observe(getViewLifecycleOwner(), adminStatus -> { - makeGroupAdminButton.setVisibility(adminStatus.isCanMakeAdmin() ? View.VISIBLE : View.GONE); - removeAdminButton.setVisibility(adminStatus.isCanMakeNonAdmin() ? View.VISIBLE : View.GONE); - removeFromGroupButton.setVisibility(adminStatus.isCanRemove() ? View.VISIBLE : View.GONE); - - if (adminStatus.isCanRemove()) { - removeFromGroupButton.setOnClickListener(view -> viewModel.onRemoveFromGroupClicked(requireActivity(), adminStatus.isLinkActive(), this::dismiss)); - } - }); - - viewModel.getIdentity().observe(getViewLifecycleOwner(), identityRecord -> { - if (identityRecord != null) { - viewSafetyNumberButton.setVisibility(View.VISIBLE); - viewSafetyNumberButton.setOnClickListener(view -> { - dismiss(); - viewModel.onViewSafetyNumberClicked(requireActivity(), identityRecord); - }); - } - }); - - avatar.setOnClickListener(view -> { - dismiss(); - viewModel.onAvatarClicked(requireActivity()); - }); - - badgeImageView.setOnClickListener(view -> { - dismiss(); - ViewBadgeBottomSheetDialogFragment.show(getParentFragmentManager(), recipientId, null); - }); - - blockButton.setOnClickListener(view -> viewModel.onBlockClicked(requireActivity())); - unblockButton.setOnClickListener(view -> viewModel.onUnblockClicked(requireActivity())); - - makeGroupAdminButton.setOnClickListener(view -> viewModel.onMakeGroupAdminClicked(requireActivity())); - removeAdminButton.setOnClickListener(view -> viewModel.onRemoveGroupAdminClicked(requireActivity())); - - addToGroupButton.setOnClickListener(view -> { - dismiss(); - viewModel.onAddToGroupButton(requireActivity()); - }); - - viewModel.getAdminActionBusy().observe(getViewLifecycleOwner(), busy -> { - adminActionBusy.setVisibility(busy ? View.VISIBLE : View.GONE); - - boolean userLoggedOut = viewModel.isDeprecatedOrUnregistered(); - makeGroupAdminButton.setEnabled(!busy && !userLoggedOut); - removeAdminButton.setEnabled(!busy && !userLoggedOut); - removeFromGroupButton.setEnabled(!busy && !userLoggedOut); - }); - - callback = getParentFragment() != null && getParentFragment() instanceof Callback ? (Callback) getParentFragment() : null; - - if (viewModel.isDeprecatedOrUnregistered()) { - List viewsToDisable = Arrays.asList(blockButton, unblockButton, removeFromGroupButton, makeGroupAdminButton, removeAdminButton, addToGroupButton, viewSafetyNumberButton); - for (TextView view : viewsToDisable) { - view.setEnabled(false); - view.setAlpha(0.5f); - } - } - } - - @Override - public void onResume() { - super.onResume(); - WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow()); - } - - private void openSystemContactSheet(@NonNull Intent intent) { - try { - startActivityForResult(intent, REQUEST_CODE_SYSTEM_CONTACT_SHEET); - } catch (ActivityNotFoundException e) { - Log.w(TAG, "No activity existed to open the contact."); - Toast.makeText(requireContext(), R.string.RecipientBottomSheet_unable_to_open_contacts, Toast.LENGTH_LONG).show(); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_SYSTEM_CONTACT_SHEET) { - viewModel.refreshRecipient(); - } - } - - @Override - public void show(@NonNull FragmentManager manager, @Nullable String tag) { - BottomSheetUtil.show(manager, tag, this); - } - - @Override - public void onDismiss(@NonNull DialogInterface dialog) { - super.onDismiss(dialog); - if (callback != null) { - callback.onRecipientBottomSheetDismissed(); - } - } - - public interface Callback { - void onRecipientBottomSheetDismissed(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..268db1dc62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt @@ -0,0 +1,455 @@ +package org.thoughtcrime.securesms.recipients.ui.bottomsheet + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.view.AvatarView +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment +import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference +import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache +import org.thoughtcrime.securesms.fonts.SignalSymbols +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.nicknames.NicknameActivity +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientExporter +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.ContextUtil +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.ThemeUtil +import org.thoughtcrime.securesms.util.WindowUtil +import org.thoughtcrime.securesms.util.visible + +/** + * A bottom sheet that shows some simple recipient details, as well as some actions (like calling, + * adding to contacts, etc). + */ +class RecipientBottomSheetDialogFragment : BottomSheetDialogFragment() { + + companion object { + val TAG: String = Log.tag(RecipientBottomSheetDialogFragment::class.java) + + const val REQUEST_CODE_SYSTEM_CONTACT_SHEET: Int = 1111 + + private const val ARGS_RECIPIENT_ID = "RECIPIENT_ID" + private const val ARGS_GROUP_ID = "GROUP_ID" + private const val LOADING_DELAY = 800L + private const val FADE_DURATION = 150L + + @JvmStatic + fun show(fragmentManager: FragmentManager, recipientId: RecipientId, groupId: GroupId?) { + val recipient = Recipient.resolved(recipientId) + if (recipient.isSelf) { + AboutSheet.create(recipient).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } else { + val args = Bundle() + val fragment = RecipientBottomSheetDialogFragment() + + args.putString(ARGS_RECIPIENT_ID, recipientId.serialize()) + if (groupId != null) { + args.putString(ARGS_GROUP_ID, groupId.toString()) + } + + fragment.setArguments(args) + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + } + + private val viewModel: RecipientDialogViewModel by viewModels(factoryProducer = this::createFactory) + private var callback: Callback? = null + + private fun createFactory(): RecipientDialogViewModel.Factory { + val arguments: Bundle = requireArguments() + val recipientId = RecipientId.from(arguments.getString(ARGS_RECIPIENT_ID)!!) + val groupId: GroupId? = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)) + + return RecipientDialogViewModel.Factory(requireContext(), recipientId, groupId) + } + + override fun onCreate(savedInstanceState: Bundle?) { + setStyle( + DialogFragment.STYLE_NORMAL, + if (ThemeUtil.isDarkTheme(requireContext())) R.style.Theme_Signal_RoundedBottomSheet else R.style.Theme_Signal_RoundedBottomSheet_Light + ) + + super.onCreate(savedInstanceState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.recipient_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val avatar: AvatarView = view.findViewById(R.id.rbs_recipient_avatar) + val fullName: TextView = view.findViewById(R.id.rbs_full_name) + val about: TextView = view.findViewById(R.id.rbs_about) + val nickname: TextView = view.findViewById(R.id.rbs_nickname_button) + val blockButton: TextView = view.findViewById(R.id.rbs_block_button) + val unblockButton: TextView = view.findViewById(R.id.rbs_unblock_button) + val addContactButton: TextView = view.findViewById(R.id.rbs_add_contact_button) + val contactDetailsButton: TextView = view.findViewById(R.id.rbs_contact_details_button) + val addToGroupButton: TextView = view.findViewById(R.id.rbs_add_to_group_button) + val viewSafetyNumberButton: TextView = view.findViewById(R.id.rbs_view_safety_number_button) + val makeGroupAdminButton: TextView = view.findViewById(R.id.rbs_make_group_admin_button) + val removeAdminButton: TextView = view.findViewById(R.id.rbs_remove_group_admin_button) + val removeFromGroupButton: TextView = view.findViewById(R.id.rbs_remove_from_group_button) + val adminActionBusy: ProgressBar = view.findViewById(R.id.rbs_admin_action_busy) + val noteToSelfDescription: View = view.findViewById(R.id.rbs_note_to_self_description) + val buttonStrip: View = view.findViewById(R.id.button_strip) + val interactionsContainer: View = view.findViewById(R.id.interactions_container) + val badgeImageView: BadgeImageView = view.findViewById(R.id.rbs_badge) + val tapToView: View = view.findViewById(R.id.rbs_tap_to_view) + val progressBar: ProgressBar = view.findViewById(R.id.rbs_progress_bar) + + val buttonStripViewHolder = ButtonStripPreference.ViewHolder(buttonStrip) + + val nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) {} + + val arguments = requireArguments() + val recipientId = RecipientId.from(arguments.getString(ARGS_RECIPIENT_ID)!!) + val groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)) + + viewModel.storyViewState.observe(viewLifecycleOwner) { state -> + avatar.setStoryRingFromState(state) + } + + var inProgress = false + + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + val recipient = viewModel.recipient.value + if (recipient != null) { + AvatarDownloadStateCache.forRecipient(recipient.id).collect { + when (it) { + AvatarDownloadStateCache.DownloadState.NONE -> {} + AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> { + if (inProgress) { + return@collect + } + inProgress = true + animateAvatarLoading(recipient, avatar) + tapToView.visible = false + tapToView.setOnClickListener(null) + delay(LOADING_DELAY) + progressBar.visible = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS + } + AvatarDownloadStateCache.DownloadState.FINISHED -> { + AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) + viewModel.refreshGroupId(groupId) + inProgress = false + progressBar.visible = false + } + AvatarDownloadStateCache.DownloadState.FAILED -> { + AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) + avatar.displayGradientBlur(recipient) + viewModel.onResetBlurAvatar(recipient) + inProgress = false + progressBar.visible = false + Snackbar.make(view, R.string.ConversationFragment_photo_failed, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + } + + viewModel.recipient.observe(viewLifecycleOwner) { recipient -> + interactionsContainer.visible = !recipient.isSelf + if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) { + avatar.displayChatAvatar(recipient) + } + + if (!recipient.isSelf) { + badgeImageView.setBadgeFromRecipient(recipient) + } + + if (recipient.isSelf) { + avatar.setOnClickListener { + dismiss() + viewModel.onNoteToSelfClicked(requireActivity()) + } + } + + if (recipient.shouldBlurAvatar && recipient.hasAvatar) { + tapToView.visible = true + tapToView.setOnClickListener { + AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS) + viewModel.onTapToViewAvatar(recipient) + } + } else { + tapToView.visible = false + tapToView.setOnClickListener(null) + } + + val name = if (recipient.isSelf) requireContext().getString(R.string.note_to_self) else recipient.getDisplayName(requireContext()) + + fullName.visible = name.isNotEmpty() + val nameBuilder = SpannableStringBuilder(name) + if (recipient.showVerified) { + SpanUtil.appendSpacer(nameBuilder, 8) + SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28) + } else if (recipient.isSystemContact) { + val systemContactGlyph = SignalSymbols.getSpannedString( + requireContext(), + SignalSymbols.Weight.BOLD, + SignalSymbols.Glyph.PERSON_CIRCLE + ) + + nameBuilder.append(" ") + nameBuilder.append(SpanUtil.ofSize(systemContactGlyph, 20)) + } + + if (!recipient.isSelf && recipient.isIndividual) { + val chevronGlyph = SignalSymbols.getSpannedString( + requireContext(), + SignalSymbols.Weight.BOLD, + SignalSymbols.Glyph.CHEVRON_RIGHT + ) + + nameBuilder.append(" ") + nameBuilder.append( + SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOutline), SpanUtil.ofSize(chevronGlyph, 24)) + ) + + fullName.text = nameBuilder + fullName.setOnClickListener { + dismiss() + AboutSheet.create(recipient).show(getParentFragmentManager(), null) + } + + nickname.visible = true + nickname.setOnClickListener { + nicknameLauncher.launch(NicknameActivity.Args(recipientId, false)) + } + } + + var aboutText = recipient.combinedAboutAndEmoji + if (recipient.isReleaseNotes) { + aboutText = getString(R.string.ReleaseNotes__signal_release_notes_and_news) + } + + if (!aboutText.isNullOrEmpty()) { + about.text = aboutText + about.visible = true + } else { + about.visible = false + } + + noteToSelfDescription.visible = recipient.isSelf + + if (RecipientUtil.isBlockable(recipient)) { + val blocked = recipient.isBlocked + + blockButton.visible = !recipient.isSelf && !blocked + unblockButton.visible = !recipient.isSelf && blocked + } else { + blockButton.visible = false + unblockButton.visible = false + } + + val isAudioAvailable = recipient.isRegistered && + !recipient.isGroup && + !recipient.isBlocked && + !recipient.isSelf && + !recipient.isReleaseNotes + + val buttonStripState = ButtonStripPreference.State( + isMessageAvailable = !recipient.isBlocked && !recipient.isSelf && !recipient.isReleaseNotes, + isVideoAvailable = !recipient.isBlocked && !recipient.isSelf && recipient.isRegistered, + isAudioAvailable = isAudioAvailable, + isAudioSecure = recipient.isRegistered + ) + + val buttonStripModel = ButtonStripPreference.Model( + state = buttonStripState, + background = DSLSettingsIcon.from(ContextUtil.requireDrawable(requireContext(), R.drawable.selectable_recipient_bottom_sheet_icon_button)), + enabled = !viewModel.isDeprecatedOrUnregistered, + onMessageClick = { + dismiss() + viewModel.onMessageClicked(requireActivity()) + }, + onVideoClick = { + viewModel.onSecureVideoCallClicked(requireActivity()) { YouAreAlreadyInACallSnackbar.show(requireView()) } + }, + onAudioClick = { + if (buttonStripState.isAudioSecure) { + viewModel.onSecureCallClicked(requireActivity()) { YouAreAlreadyInACallSnackbar.show(requireView()) } + } else { + viewModel.onInsecureCallClicked(requireActivity()) + } + } + ) + + buttonStripViewHolder.bind(buttonStripModel) + + if (recipient.isReleaseNotes) { + buttonStrip.visible = false + } + + if (recipient.isSystemContact || recipient.isGroup || recipient.isSelf || recipient.isBlocked || recipient.isReleaseNotes || !recipient.hasE164 || !recipient.shouldShowE164) { + addContactButton.visible = false + } else { + addContactButton.visible = true + addContactButton.setOnClickListener { + openSystemContactSheet(RecipientExporter.export(recipient).asAddContactIntent()) + } + } + + if (recipient.isSystemContact && !recipient.isGroup && !recipient.isSelf) { + contactDetailsButton.visible = true + contactDetailsButton.setOnClickListener { + openSystemContactSheet(Intent(Intent.ACTION_VIEW, recipient.contactUri)) + } + } else { + contactDetailsButton.visible = false + } + } + + viewModel.canAddToAGroup.observe(getViewLifecycleOwner()) { canAdd: Boolean -> + addToGroupButton.setText(if (groupId == null) R.string.RecipientBottomSheet_add_to_a_group else R.string.RecipientBottomSheet_add_to_another_group) + addToGroupButton.visible = canAdd + } + + viewModel.adminActionStatus.observe(viewLifecycleOwner) { adminStatus -> + makeGroupAdminButton.visible = adminStatus.isCanMakeAdmin + removeAdminButton.visible = adminStatus.isCanMakeNonAdmin + removeFromGroupButton.visible = adminStatus.isCanRemove + + if (adminStatus.isCanRemove) { + removeFromGroupButton.setOnClickListener { viewModel.onRemoveFromGroupClicked(requireActivity(), adminStatus.isLinkActive) { this.dismiss() } } + } + } + + viewModel.identity.observe(viewLifecycleOwner) { identityRecord -> + if (identityRecord != null) { + viewSafetyNumberButton.visible = true + viewSafetyNumberButton.setOnClickListener { + dismiss() + viewModel.onViewSafetyNumberClicked(requireActivity(), identityRecord) + } + } + } + + avatar.setOnClickListener { + dismiss() + viewModel.onAvatarClicked(requireActivity()) + } + + badgeImageView.setOnClickListener { + dismiss() + ViewBadgeBottomSheetDialogFragment.show(getParentFragmentManager(), recipientId, null) + } + + blockButton.setOnClickListener { viewModel.onBlockClicked(requireActivity()) } + unblockButton.setOnClickListener { viewModel.onUnblockClicked(requireActivity()) } + + makeGroupAdminButton.setOnClickListener { viewModel.onMakeGroupAdminClicked(requireActivity()) } + removeAdminButton.setOnClickListener { viewModel.onRemoveGroupAdminClicked(requireActivity()) } + + addToGroupButton.setOnClickListener { + dismiss() + viewModel.onAddToGroupButton(requireActivity()) + } + + viewModel.adminActionBusy.observe(viewLifecycleOwner) { busy -> + adminActionBusy.visible = busy + + val userLoggedOut = viewModel.isDeprecatedOrUnregistered + makeGroupAdminButton.isEnabled = !busy && !userLoggedOut + removeAdminButton.isEnabled = !busy && !userLoggedOut + removeFromGroupButton.isEnabled = !busy && !userLoggedOut + } + + callback = if (parentFragment != null && parentFragment is Callback) parentFragment as Callback else null + + if (viewModel.isDeprecatedOrUnregistered) { + val viewsToDisable = listOf(blockButton, unblockButton, removeFromGroupButton, makeGroupAdminButton, removeAdminButton, addToGroupButton, viewSafetyNumberButton) + for (textView in viewsToDisable) { + textView.isEnabled = false + textView.alpha = 0.5f + } + } + } + + override fun onResume() { + super.onResume() + WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().window!!) + } + + private fun openSystemContactSheet(intent: Intent) { + try { + startActivityForResult(intent, REQUEST_CODE_SYSTEM_CONTACT_SHEET) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No activity existed to open the contact.") + Toast.makeText(requireContext(), R.string.RecipientBottomSheet_unable_to_open_contacts, Toast.LENGTH_LONG).show() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_SYSTEM_CONTACT_SHEET) { + viewModel.refreshRecipient() + } + } + + override fun show(manager: FragmentManager, tag: String?) { + BottomSheetUtil.show(manager, tag, this) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + if (callback != null) { + callback!!.onRecipientBottomSheetDismissed() + } + } + + private fun animateAvatarLoading(recipient: Recipient, avatar: AvatarView) { + val animator = ObjectAnimator.ofFloat(avatar, "alpha", 1f, 0f).setDuration(FADE_DURATION) + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) { + avatar.displayLoadingAvatar() + } + ObjectAnimator.ofFloat(avatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start() + } + }) + animator.start() + } + + interface Callback { + fun onRecipientBottomSheetDismissed() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index 9e9ada41a1..d63933c5b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -17,11 +17,13 @@ import androidx.lifecycle.ViewModelProvider; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.ThreadUtil; +import org.signal.core.util.concurrent.SignalExecutors; import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.database.GroupTable; +import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.groups.GroupId; @@ -29,6 +31,8 @@ import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -265,6 +269,28 @@ final class RecipientDialogViewModel extends ViewModel { ThreadUtil.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); } + public void onTapToViewAvatar(@NonNull Recipient recipient) { + SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true)); + if (recipient.isPushV2Group()) { + AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2()); + } else { + RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient); + } + } + + public void onResetBlurAvatar(@NonNull Recipient recipient) { + SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), false)); + } + + public void refreshGroupId(@Nullable GroupId groupId) { + if (groupId != null) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientId groupRecipientId = SignalDatabase.groups().getGroup(groupId).get().getRecipientId(); + Recipient.live(groupRecipientId).refresh(); + }); + } + } + static class AdminActionStatus { private final boolean canRemove; private final boolean canMakeAdmin; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 7b6d1ed7ac..625a51b3be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar; import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors; import org.thoughtcrime.securesms.providers.AvatarProvider; import org.thoughtcrime.securesms.recipients.Recipient; @@ -132,10 +133,14 @@ public final class AvatarUtil { AvatarTarget avatarTarget = new AvatarTarget(size); RequestManager requestManager = Glide.with(context); - requestCircle(requestManager.asBitmap(), context, recipient, size).into(avatarTarget); + if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) { + return DrawableUtil.toBitmap(AvatarGradientColors.getGradientDrawable(recipient), size, size); + } else { + requestCircle(requestManager.asBitmap(), context, recipient, size).into(avatarTarget); - Bitmap bitmap = avatarTarget.await(); - return Objects.requireNonNullElseGet(bitmap, () -> DrawableUtil.toBitmap(getFallback(context, recipient, size), size, size)); + Bitmap bitmap = avatarTarget.await(); + return Objects.requireNonNullElseGet(bitmap, () -> DrawableUtil.toBitmap(getFallback(context, recipient, size), size, size)); + } } catch (InterruptedException e) { return DrawableUtil.toBitmap(getFallback(context, recipient, size), size, size); } @@ -168,14 +173,7 @@ public final class AvatarUtil { .diskCacheStrategy(DiskCacheStrategy.ALL) .override(size); - if (recipient.getShouldBlurAvatar()) { - BlurTransformation blur = new BlurTransformation(context, 0.25f, BlurTransformation.MAX_RADIUS); - if (transformation != null) { - return request.transform(blur, transformation); - } else { - return request.transform(blur); - } - } else if (transformation != null) { + if (transformation != null) { return request.transform(transformation); } else { return request; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index aa711fb594..c8e9eb1d05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -378,7 +378,7 @@ public final class ProfileUtil { SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()).orElse(null); SignalStore.registration().setHasUploadedProfile(true); if (!avatar.keepTheSame) { - SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath); + SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath, false); } AppDependencies.getJobManager().add(new RefreshOwnProfileJob()); } diff --git a/app/src/main/res/drawable/circle_profile_photo.xml b/app/src/main/res/drawable/circle_profile_photo.xml new file mode 100644 index 0000000000..5f99cbba0a --- /dev/null +++ b/app/src/main/res/drawable/circle_profile_photo.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/layout/conversation_header_view.xml b/app/src/main/res/layout/conversation_header_view.xml index c23808ed57..4cef223e82 100644 --- a/app/src/main/res/layout/conversation_header_view.xml +++ b/app/src/main/res/layout/conversation_header_view.xml @@ -16,6 +16,21 @@ app:layout_constraintTop_toTopOf="parent" tools:srcCompat="@tools:sample/avatars" /> + + + + + + + + + + + + Profile names Group names + + Photo failed to download. Try again. Safety Tips