Block avatar downloads in message request states.

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

View File

@@ -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))
}
/**

View File

@@ -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<Transformation<Bitmap>> 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<Transformation<Bitmap>> transforms = Collections.singletonList(new CircleCrop());
RequestBuilder<Drawable> 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<RetrieveProfileAvatarJob> {
@@ -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));
}
}
}

View File

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

View File

@@ -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<Transformation<Bitmap>> = 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)

View File

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

View File

@@ -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<NicknameActivity.Args> 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<TextView> 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="80"
android:viewportHeight="80">
<path
android:pathData="M40,80C62.091,80 80,62.091 80,40C80,17.909 62.091,0 40,0C17.909,0 0,17.909 0,40C0,62.091 17.909,80 40,80Z"
android:fillColor="#FBFCFE"/>
<path
android:pathData="M40,80C62.091,80 80,62.091 80,40C80,17.909 62.091,0 40,0C17.909,0 0,17.909 0,40C0,62.091 17.909,80 40,80Z"
android:fillColor="#50679F"
android:fillAlpha="0.11"/>
</vector>

View File

@@ -16,6 +16,21 @@
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:visibility="gone"
android:indeterminate="true"
style="?android:attr/progressBarStyleLarge"
android:indeterminateTint="@color/signal_colorOnSurfaceVariant"
app:layout_constraintTop_toTopOf="@id/message_request_avatar"
app:layout_constraintBottom_toBottomOf="@id/message_request_avatar"
app:layout_constraintStart_toStartOf="@id/message_request_avatar"
app:layout_constraintEnd_toEndOf="@id/message_request_avatar"
tools:visibility="visible" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/message_request_badge"
android:layout_width="36dp"
@@ -31,8 +46,6 @@
android:id="@+id/message_request_avatar_tap_to_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/circle_tint_darker"
android:foreground="?attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone"

View File

@@ -56,6 +56,49 @@
app:layout_constraintTop_toTopOf="@+id/rbs_recipient_avatar"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/rbs_progress_bar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:visibility="gone"
android:indeterminate="true"
style="?android:attr/progressBarStyleLarge"
android:indeterminateTint="@color/signal_colorOnSurfaceVariant"
app:layout_constraintTop_toTopOf="@id/rbs_recipient_avatar"
app:layout_constraintBottom_toBottomOf="@id/rbs_recipient_avatar"
app:layout_constraintStart_toStartOf="@id/rbs_recipient_avatar"
app:layout_constraintEnd_toEndOf="@id/rbs_recipient_avatar"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/rbs_tap_to_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/rbs_recipient_avatar"
app:layout_constraintEnd_toEndOf="@+id/rbs_recipient_avatar"
app:layout_constraintStart_toStartOf="@+id/rbs_recipient_avatar"
app:layout_constraintTop_toTopOf="@+id/rbs_recipient_avatar">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:srcCompat="@drawable/ic_tap_outline_24"
app:tint="@color/core_white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/MessageRequestProfileView_view"
android:textAppearance="@style/TextAppearance.Signal.Subtitle"
android:textColor="@color/core_white" />
</LinearLayout>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/rbs_full_name"
android:layout_width="0dp"

View File

@@ -680,6 +680,8 @@
<string name="ConversationFragment_profile_names">Profile names</string>
<!-- Placeholder text shown in conversation header that when clicked will open a dialog about group names -->
<string name="ConversationFragment_group_names">Group names</string>
<!-- Snackbar toast message shown when a profile cannot be downloaded and to try again. -->
<string name="ConversationFragment_photo_failed">Photo failed to download. Try again.</string>
<!-- Title of Safety Tips bottom sheet dialog -->
<string name="SafetyTips_title">Safety Tips</string>