Live group update messages on conversation list and conversation.

This commit is contained in:
Alan Evans 2020-07-24 12:35:44 -03:00 committed by Greyson Parrelli
parent 7446c2096d
commit bd1c164d57
17 changed files with 1140 additions and 393 deletions

View File

@ -359,10 +359,10 @@ dependencies {
testImplementation 'junit:junit:4.12' testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation 'org.mockito:mockito-core:1.9.5' testImplementation 'org.mockito:mockito-core:1.9.5'
testImplementation 'org.powermock:powermock-api-mockito:1.6.1' testImplementation 'org.powermock:powermock-api-mockito:1.6.5'
testImplementation 'org.powermock:powermock-module-junit4:1.6.1' testImplementation 'org.powermock:powermock-module-junit4:1.6.5'
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.5'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' testImplementation 'org.powermock:powermock-classloading-xstream:1.6.5'
testImplementation 'androidx.test:core:1.2.0' testImplementation 'androidx.test:core:1.2.0'
testImplementation ('org.robolectric:robolectric:4.2') { testImplementation ('org.robolectric:robolectric:4.2') {

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.graphics.ColorFilter; import android.graphics.ColorFilter;
import android.graphics.PorterDuff; import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter; import android.graphics.PorterDuffColorFilter;
import android.text.SpannableString;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
@ -13,31 +14,39 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity; import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
public class ConversationUpdateItem extends LinearLayout public final class ConversationUpdateItem extends LinearLayout
implements RecipientForeverObserver, BindableConversationItem implements RecipientForeverObserver,
BindableConversationItem,
Observer<SpannableString>
{ {
private static final String TAG = ConversationUpdateItem.class.getSimpleName(); private static final String TAG = ConversationUpdateItem.class.getSimpleName();
@ -50,6 +59,9 @@ public class ConversationUpdateItem extends LinearLayout
private LiveRecipient sender; private LiveRecipient sender;
private MessageRecord messageRecord; private MessageRecord messageRecord;
private Locale locale; private Locale locale;
private LiveData<SpannableString> displayBody;
private final Debouncer bodyClearDebouncer = new Debouncer(150);
public ConversationUpdateItem(Context context) { public ConversationUpdateItem(Context context) {
super(context); super(context);
@ -108,9 +120,8 @@ public class ConversationUpdateItem extends LinearLayout
this.sender.removeForeverObserver(this); this.sender.removeForeverObserver(this);
} }
if (this.messageRecord != null && messageRecord.isGroupAction()) { observeDisplayBody(null);
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this); setBodyText(null);
}
this.messageRecord = messageRecord; this.messageRecord = messageRecord;
this.sender = messageRecord.getIndividualRecipient().live(); this.sender = messageRecord.getIndividualRecipient().live();
@ -118,23 +129,49 @@ public class ConversationUpdateItem extends LinearLayout
this.sender.observeForever(this); this.sender.observeForever(this);
if (this.messageRecord != null && messageRecord.isGroupAction()) { UpdateDescription updateDescription = Objects.requireNonNull(messageRecord.getUpdateDisplayBody(getContext()));
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).addObserver(this); LiveData<String> liveUpdateMessage = LiveUpdateMessage.fromMessageDescription(updateDescription);
} LiveData<SpannableString> spannableStringMessage = Transformations.map(liveUpdateMessage, SpannableString::new);
present(messageRecord); present(messageRecord);
observeDisplayBody(spannableStringMessage);
}
private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
if (this.displayBody != displayBody) {
if (this.displayBody != null) {
this.displayBody.removeObserver(this);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observeForever(this);
}
}
}
private void setBodyText(@Nullable CharSequence text) {
if (text == null) {
bodyClearDebouncer.publish(() -> body.setText(null));
} else {
bodyClearDebouncer.clear();
body.setText(text);
body.setVisibility(VISIBLE);
}
} }
private void present(MessageRecord messageRecord) { private void present(MessageRecord messageRecord) {
if (messageRecord.isGroupAction()) setGroupRecord(messageRecord); if (messageRecord.isGroupAction()) setGroupRecord();
else if (messageRecord.isCallLog()) setCallRecord(messageRecord); else if (messageRecord.isCallLog()) setCallRecord(messageRecord);
else if (messageRecord.isJoined()) setJoinedRecord(messageRecord); else if (messageRecord.isJoined()) setJoinedRecord();
else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord); else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord);
else if (messageRecord.isEndSession()) setEndSessionRecord(messageRecord); else if (messageRecord.isEndSession()) setEndSessionRecord();
else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord); else if (messageRecord.isIdentityUpdate()) setIdentityRecord();
else if (messageRecord.isIdentityVerified() || else if (messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord); messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord);
else if (messageRecord.isProfileChange()) setProfileNameChangeRecord(messageRecord); else if (messageRecord.isProfileChange()) setProfileNameChangeRecord();
else throw new AssertionError("Neither group nor log nor joined."); else throw new AssertionError("Neither group nor log nor joined.");
if (batchSelected.contains(messageRecord)) setSelected(true); if (batchSelected.contains(messageRecord)) setSelected(true);
@ -146,11 +183,9 @@ public class ConversationUpdateItem extends LinearLayout
else if (messageRecord.isOutgoingCall()) icon.setImageResource(R.drawable.ic_call_made_grey600_24dp); else if (messageRecord.isOutgoingCall()) icon.setImageResource(R.drawable.ic_call_made_grey600_24dp);
else icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp); else icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp);
body.setText(messageRecord.getDisplayBody(getContext()));
date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived())); date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(View.VISIBLE); date.setVisibility(View.VISIBLE);
} }
@ -163,10 +198,8 @@ public class ConversationUpdateItem extends LinearLayout
icon.setColorFilter(getIconTintFilter()); icon.setColorFilter(getIconTintFilter());
title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000))); title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000)));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(VISIBLE); title.setVisibility(VISIBLE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
@ -174,13 +207,11 @@ public class ConversationUpdateItem extends LinearLayout
return new PorterDuffColorFilter(ThemeUtil.getThemedColor(getContext(), R.attr.icon_tint), PorterDuff.Mode.SRC_IN); return new PorterDuffColorFilter(ThemeUtil.getThemedColor(getContext(), R.attr.icon_tint), PorterDuff.Mode.SRC_IN);
} }
private void setIdentityRecord(final MessageRecord messageRecord) { private void setIdentityRecord() {
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.safety_number_icon)); icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.safety_number_icon));
icon.setColorFilter(getIconTintFilter()); icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
@ -189,52 +220,38 @@ public class ConversationUpdateItem extends LinearLayout
else icon.setImageResource(R.drawable.ic_info_outline_white_24dp); else icon.setImageResource(R.drawable.ic_info_outline_white_24dp);
icon.setColorFilter(getIconTintFilter()); icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
private void setProfileNameChangeRecord(MessageRecord messageRecord) { private void setProfileNameChangeRecord() {
icon.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_outline_20)); icon.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_outline_20));
icon.setColorFilter(getIconTintFilter()); icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
private void setGroupRecord(MessageRecord messageRecord) { private void setGroupRecord() {
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.menu_group_icon)); icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.menu_group_icon));
icon.clearColorFilter(); icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
private void setJoinedRecord(MessageRecord messageRecord) { private void setJoinedRecord() {
icon.setImageResource(R.drawable.ic_favorite_grey600_24dp); icon.setImageResource(R.drawable.ic_favorite_grey600_24dp);
icon.clearColorFilter(); icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE); title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE); date.setVisibility(GONE);
} }
private void setEndSessionRecord(MessageRecord messageRecord) { private void setEndSessionRecord() {
icon.setImageResource(R.drawable.ic_refresh_white_24dp); icon.setImageResource(R.drawable.ic_refresh_white_24dp);
icon.setColorFilter(getIconTintFilter()); icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
} }
@Override @Override
@ -252,9 +269,13 @@ public class ConversationUpdateItem extends LinearLayout
if (sender != null) { if (sender != null) {
sender.removeForeverObserver(this); sender.removeForeverObserver(this);
} }
if (this.messageRecord != null && messageRecord.isGroupAction()) {
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this); observeDisplayBody(null);
} }
@Override
public void onChanged(SpannableString update) {
setBodyText(update);
} }
private class InternalClickListener implements View.OnClickListener { private class InternalClickListener implements View.OnClickListener {

View File

@ -21,7 +21,6 @@ import android.content.res.ColorStateList;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.RippleDrawable; import android.graphics.drawable.RippleDrawable;
import android.os.Build.VERSION; import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
@ -32,6 +31,9 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.BindableConversationListItem; import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
@ -42,41 +44,49 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView; import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.components.TypingIndicatorView; import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SearchUtil; import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections; import java.util.Collections;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
public class ConversationListItem extends RelativeLayout import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync;
public final class ConversationListItem extends RelativeLayout
implements RecipientForeverObserver, implements RecipientForeverObserver,
BindableConversationListItem, Unbindable BindableConversationListItem,
Unbindable,
Observer<SpannableString>
{ {
@SuppressWarnings("unused") @SuppressWarnings("unused")
private final static String TAG = ConversationListItem.class.getSimpleName(); private final static String TAG = Log.tag(ConversationListItem.class);
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
private static final int MAX_SNIPPET_LENGTH = 500;
private Set<Long> selectedThreads; private Set<Long> selectedThreads;
private Set<Long> typingThreads;
private LiveRecipient recipient; private LiveRecipient recipient;
private LiveRecipient groupAddedBy;
private long threadId; private long threadId;
private GlideRequests glideRequests; private GlideRequests glideRequests;
private View subjectContainer; private View subjectContainer;
@ -96,13 +106,9 @@ public class ConversationListItem extends RelativeLayout
private AvatarImageView contactPhotoImage; private AvatarImageView contactPhotoImage;
private ThumbnailView thumbnailView; private ThumbnailView thumbnailView;
private int distributionType; private final Debouncer subjectViewClearDebouncer = new Debouncer(150);
private final RecipientForeverObserver groupAddedByObserver = adder -> { private LiveData<SpannableString> displayBody;
if (isAttachedToWindow() && subjectView != null && thread != null) {
subjectView.setText(getThreadDisplayBody(getContext(), thread));
}
};
public ConversationListItem(Context context) { public ConversationListItem(Context context) {
this(context, null); this(context, null);
@ -152,14 +158,14 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring) @Nullable String highlightSubstring)
{ {
if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = selectedThreads; this.selectedThreads = selectedThreads;
this.recipient = thread.getRecipient().live(); this.recipient = thread.getRecipient().live();
this.threadId = thread.getThreadId(); this.threadId = thread.getThreadId();
this.glideRequests = glideRequests; this.glideRequests = glideRequests;
this.unreadCount = thread.getUnreadCount(); this.unreadCount = thread.getUnreadCount();
this.distributionType = thread.getDistributionType();
this.lastSeen = thread.getLastSeen(); this.lastSeen = thread.getLastSeen();
this.thread = thread; this.thread = thread;
@ -172,14 +178,10 @@ public class ConversationListItem extends RelativeLayout
this.fromView.setText(recipient.get(), thread.isRead()); this.fromView.setText(recipient.get(), thread.isRead());
} }
this.typingThreads = typingThreads;
updateTypingIndicator(typingThreads); updateTypingIndicator(typingThreads);
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread))); observeDisplayBody(getThreadDisplayBody(getContext(), thread));
if (thread.getGroupAddedBy() != null) {
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
groupAddedBy.observeForever(groupAddedByObserver);
}
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
@ -213,7 +215,8 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring) @Nullable String highlightSubstring)
{ {
if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = Collections.emptySet(); this.selectedThreads = Collections.emptySet();
this.recipient = contact.live(); this.recipient = contact.live();
@ -242,7 +245,8 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring) @Nullable String highlightSubstring)
{ {
if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = Collections.emptySet(); this.selectedThreads = Collections.emptySet();
this.recipient = messageResult.conversationRecipient.live(); this.recipient = messageResult.conversationRecipient.live();
@ -274,10 +278,7 @@ public class ConversationListItem extends RelativeLayout
contactPhotoImage.setAvatar(glideRequests, null, !batchMode); contactPhotoImage.setAvatar(glideRequests, null, !batchMode);
} }
if (this.groupAddedBy != null) { observeDisplayBody(null);
this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.groupAddedBy = null;
}
} }
@Override @Override
@ -317,17 +318,30 @@ public class ConversationListItem extends RelativeLayout
return unreadCount; return unreadCount;
} }
public int getDistributionType() {
return distributionType;
}
public long getLastSeen() { public long getLastSeen() {
return lastSeen; return lastSeen;
} }
private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) { private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet if (this.displayBody != null) {
: snippet.subSequence(0, MAX_SNIPPET_LENGTH); this.displayBody.removeObserver(this);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observeForever(this);
}
}
private void setSubjectViewText(@Nullable CharSequence text) {
if (text == null) {
subjectViewClearDebouncer.publish(() -> subjectView.setText(null));
} else {
subjectViewClearDebouncer.clear();
subjectView.setText(text);
subjectView.setVisibility(VISIBLE);
}
} }
private void setThumbnailSnippet(ThreadRecord thread) { private void setThumbnailSnippet(ThreadRecord thread) {
@ -371,7 +385,7 @@ public class ConversationListItem extends RelativeLayout
} }
private void setRippleColor(Recipient recipient) { private void setRippleColor(Recipient recipient) {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { if (VERSION.SDK_INT >= 21) {
((RippleDrawable)(getBackground()).mutate()) ((RippleDrawable)(getBackground()).mutate())
.setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext()))); .setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext())));
} }
@ -394,16 +408,20 @@ public class ConversationListItem extends RelativeLayout
setRippleColor(recipient); setRippleColor(recipient);
} }
private static @NonNull LiveData<SpannableString> getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
if (thread.getGroupAddedBy() != null) { if (thread.getGroupAddedBy() != null) {
return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group return emphasisAdded(recipientToStringAsync(thread.getGroupAddedBy(),
r -> context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
: R.string.ThreadRecord_s_added_you_to_the_group, : R.string.ThreadRecord_s_added_you_to_the_group,
Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context))); r.getDisplayName(context))));
} else if (!thread.isMessageRequestAccepted()) { } else if (!thread.isMessageRequestAccepted()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request)); return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
} else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) { } else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) {
if (thread.getRecipient().isPushV2Group()) {
return emphasisAdded(MessageRecord.getGv2ChangeDescription(context, thread.getBody()));
} else {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
}
} else if (SmsDatabase.Types.isGroupQuit(thread.getType())) { } else if (SmsDatabase.Types.isGroupQuit(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group)); return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) { } else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
@ -418,15 +436,15 @@ public class ConversationListItem extends RelativeLayout
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
} else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) { } else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) {
String draftText = context.getString(R.string.ThreadRecord_draft); String draftText = context.getString(R.string.ThreadRecord_draft);
return emphasisAdded(draftText + " " + thread.getBody(), 0, draftText.length()); return emphasisAdded(draftText + " " + thread.getBody());
} else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) { } else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called)); return emphasisAdded(context.getString(R.string.ThreadRecord_called));
} else if (SmsDatabase.Types.isIncomingCall(thread.getType())) { } else if (SmsDatabase.Types.isIncomingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you)); return emphasisAdded(context.getString(R.string.ThreadRecord_called_you));
} else if (SmsDatabase.Types.isMissedCall(thread.getType())) { } else if (SmsDatabase.Types.isMissedCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call)); return emphasisAdded(context.getString(R.string.ThreadRecord_missed_call));
} else if (SmsDatabase.Types.isJoinedType(thread.getType())) { } else if (SmsDatabase.Types.isJoinedType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, thread.getRecipient().getDisplayName(context))); return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_s_is_on_signal, r.getDisplayName(context))));
} else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) { } else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) {
int seconds = (int)(thread.getExpiresIn() / 1000); int seconds = (int)(thread.getExpiresIn() / 1000);
if (seconds <= 0) { if (seconds <= 0) {
@ -438,7 +456,7 @@ public class ConversationListItem extends RelativeLayout
if (thread.getRecipient().isGroup()) { if (thread.getRecipient().isGroup()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed)); return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
} else { } else {
return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, thread.getRecipient().getDisplayName(context))); return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context))));
} }
} else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) { } else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified)); return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
@ -449,11 +467,11 @@ public class ConversationListItem extends RelativeLayout
} else { } else {
ThreadDatabase.Extra extra = thread.getExtra(); ThreadDatabase.Extra extra = thread.getExtra();
if (extra != null && extra.isViewOnce()) { if (extra != null && extra.isViewOnce()) {
return new SpannableString(emphasisAdded(getViewOnceDescription(context, thread.getContentType()))); return emphasisAdded(getViewOnceDescription(context, thread.getContentType()));
} else if (extra != null && extra.isRemoteDelete()) { } else if (extra != null && extra.isRemoteDelete()) {
return new SpannableString(emphasisAdded(context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted))); return emphasisAdded(context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted));
} else { } else {
return new SpannableString(removeNewlines(thread.getBody())); return LiveDataUtil.just(new SpannableString(removeNewlines(thread.getBody())));
} }
} }
} }
@ -470,17 +488,23 @@ public class ConversationListItem extends RelativeLayout
} }
} }
private static @NonNull SpannableString emphasisAdded(String sequence) { private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull String string) {
return emphasisAdded(sequence, 0, sequence.length()); return emphasisAdded(UpdateDescription.staticDescription(string));
} }
private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) { private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull UpdateDescription description) {
return emphasisAdded(LiveUpdateMessage.fromMessageDescription(description));
}
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull LiveData<String> description) {
return Transformations.map(description, sequence -> {
SpannableString spannable = new SpannableString(sequence); SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), spannable.setSpan(new StyleSpan(Typeface.ITALIC),
start, 0,
end, sequence.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable; return spannable;
});
} }
private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) { private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
@ -493,6 +517,15 @@ public class ConversationListItem extends RelativeLayout
} }
} }
@Override
public void onChanged(SpannableString spannableString) {
setSubjectViewText(spannableString);
if (typingThreads != null) {
updateTypingIndicator(typingThreads);
}
}
private static class ThumbnailPositioner implements Runnable { private static class ThumbnailPositioner implements Runnable {
private final View thumbnailView; private final View thumbnailView;

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database.model;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
@ -21,6 +22,8 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -37,8 +40,7 @@ final class GroupsV2UpdateMessageProducer {
*/ */
GroupsV2UpdateMessageProducer(@NonNull Context context, GroupsV2UpdateMessageProducer(@NonNull Context context,
@NonNull DescribeMemberStrategy descriptionStrategy, @NonNull DescribeMemberStrategy descriptionStrategy,
@NonNull UUID selfUuid) @NonNull UUID selfUuid) {
{
this.context = context; this.context = context;
this.descriptionStrategy = descriptionStrategy; this.descriptionStrategy = descriptionStrategy;
this.selfUuid = selfUuid; this.selfUuid = selfUuid;
@ -50,10 +52,10 @@ final class GroupsV2UpdateMessageProducer {
* <p> * <p>
* Invitation and groups you create are the most common cases where no change is available. * Invitation and groups you create are the most common cases where no change is available.
*/ */
String describeNewGroup(@NonNull DecryptedGroup group) { UpdateDescription describeNewGroup(@NonNull DecryptedGroup group) {
Optional<DecryptedPendingMember> selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid); Optional<DecryptedPendingMember> selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid);
if (selfPending.isPresent()) { if (selfPending.isPresent()) {
return context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(selfPending.get().getAddedByUuid())); return updateDescription(selfPending.get().getAddedByUuid(), inviteBy -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, inviteBy));
} }
if (group.getRevision() == 0) { if (group.getRevision() == 0) {
@ -61,22 +63,22 @@ final class GroupsV2UpdateMessageProducer {
if (foundingMember.isPresent()) { if (foundingMember.isPresent()) {
ByteString foundingMemberUuid = foundingMember.get().getUuid(); ByteString foundingMemberUuid = foundingMember.get().getUuid();
if (selfUuidBytes.equals(foundingMemberUuid)) { if (selfUuidBytes.equals(foundingMemberUuid)) {
return context.getString(R.string.MessageRecord_you_created_the_group); return updateDescription(context.getString(R.string.MessageRecord_you_created_the_group));
} else { } else {
return context.getString(R.string.MessageRecord_s_added_you, describe(foundingMemberUuid)); return updateDescription(foundingMemberUuid, creator -> context.getString(R.string.MessageRecord_s_added_you, creator));
} }
} }
} }
if (DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid).isPresent()) { if (DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid).isPresent()) {
return context.getString(R.string.MessageRecord_you_joined_the_group); return updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group));
} else { } else {
return context.getString(R.string.MessageRecord_group_updated); return updateDescription(context.getString(R.string.MessageRecord_group_updated));
} }
} }
List<String> describeChange(@NonNull DecryptedGroupChange change) { List<UpdateDescription> describeChanges(@NonNull DecryptedGroupChange change) {
List<String> updates = new LinkedList<>(); List<UpdateDescription> updates = new LinkedList<>();
if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) { if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) {
describeUnknownEditorMemberAdditions(change, updates); describeUnknownEditorMemberAdditions(change, updates);
@ -119,21 +121,21 @@ final class GroupsV2UpdateMessageProducer {
/** /**
* Handles case of future protocol versions where we don't know what has changed. * Handles case of future protocol versions where we don't know what has changed.
*/ */
private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_updated_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_updated_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_updated_group, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), (editor) -> context.getString(R.string.MessageRecord_s_updated_group, editor)));
} }
} }
private void describeUnknownEditorUnknownChange(@NonNull List<String> updates) { private void describeUnknownEditorUnknownChange(@NonNull List<UpdateDescription> updates) {
updates.add(context.getString(R.string.MessageRecord_the_group_was_updated)); updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_updated)));
} }
private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember member : change.getNewMembersList()) { for (DecryptedMember member : change.getNewMembersList()) {
@ -141,37 +143,37 @@ final class GroupsV2UpdateMessageProducer {
if (editorIsYou) { if (editorIsYou) {
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_you_added_s, describe(member.getUuid()))); updates.add(updateDescription(member.getUuid(), added -> context.getString(R.string.MessageRecord_you_added_s, added)));
} }
} else { } else {
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor)));
} else { } else {
if (member.getUuid().equals(change.getEditor())) { if (member.getUuid().equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(member.getUuid()))); updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_added_s, describe(change.getEditor()), describe(member.getUuid()))); updates.add(updateDescription(change.getEditor(), member.getUuid(), (editor, newMember) -> context.getString(R.string.MessageRecord_s_added_s, editor, newMember)));
} }
} }
} }
} }
} }
private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedMember member : change.getNewMembersList()) { for (DecryptedMember member : change.getNewMembersList()) {
boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes); boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(member.getUuid()))); updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember)));
} }
} }
} }
private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString member : change.getDeleteMembersList()) { for (ByteString member : change.getDeleteMembersList()) {
@ -179,87 +181,87 @@ final class GroupsV2UpdateMessageProducer {
if (editorIsYou) { if (editorIsYou) {
if (removedMemberIsYou) { if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_left_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_left_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_you_removed_s, describe(member))); updates.add(updateDescription(member, removedMember -> context.getString(R.string.MessageRecord_you_removed_s, removedMember)));
} }
} else { } else {
if (removedMemberIsYou) { if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_removed_you_from_the_group, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_removed_you_from_the_group, editor)));
} else { } else {
if (member.equals(change.getEditor())) { if (member.equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_left_the_group, describe(member))); updates.add(updateDescription(member, leavingMember -> context.getString(R.string.MessageRecord_s_left_the_group, leavingMember)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_removed_s, describe(change.getEditor()), describe(member))); updates.add(updateDescription(change.getEditor(), member, (editor, removedMember) -> context.getString(R.string.MessageRecord_s_removed_s, editor, removedMember)));
} }
} }
} }
} }
} }
private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (ByteString member : change.getDeleteMembersList()) { for (ByteString member : change.getDeleteMembersList()) {
boolean removedMemberIsYou = member.equals(selfUuidBytes); boolean removedMemberIsYou = member.equals(selfUuidBytes);
if (removedMemberIsYou) { if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_no_longer_in_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_in_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_is_no_longer_in_the_group, describe(member))); updates.add(updateDescription(member, oldMember -> context.getString(R.string.MessageRecord_s_is_no_longer_in_the_group, oldMember)));
} }
} }
} }
private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) {
boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { if (roleChange.getRole() == Member.Role.ADMINISTRATOR) {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_made_s_an_admin, describe(roleChange.getUuid()))); updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_you_made_s_an_admin, newAdmin)));
} else { } else {
if (changedMemberIsYou) { if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_made_you_an_admin, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_made_you_an_admin, editor)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_made_s_an_admin, describe(change.getEditor()), describe(roleChange.getUuid()))); updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, newAdmin) -> context.getString(R.string.MessageRecord_s_made_s_an_admin, editor, newAdmin)));
} }
} }
} else { } else {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, describe(roleChange.getUuid()))); updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, oldAdmin)));
} else { } else {
if (changedMemberIsYou) { if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, editor)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, describe(change.getEditor()), describe(roleChange.getUuid()))); updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, oldAdmin) -> context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, editor, oldAdmin)));
} }
} }
} }
} }
} }
private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) {
boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { if (roleChange.getRole() == Member.Role.ADMINISTRATOR) {
if (changedMemberIsYou) { if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_now_an_admin)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_now_an_admin)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_is_now_an_admin, describe(roleChange.getUuid()))); updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_s_is_now_an_admin, newAdmin)));
} }
} else { } else {
if (changedMemberIsYou) { if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_no_longer_an_admin)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_an_admin)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_is_no_longer_an_admin, describe(roleChange.getUuid()))); updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_s_is_no_longer_an_admin, oldAdmin)));
} }
} }
} }
} }
private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notYouInviteCount = 0; int notYouInviteCount = 0;
@ -267,10 +269,10 @@ final class GroupsV2UpdateMessageProducer {
boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, editor)));
} else { } else {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_invited_s_to_the_group, describe(invitee.getUuid()))); updates.add(updateDescription(invitee.getUuid(), newInvitee -> context.getString(R.string.MessageRecord_you_invited_s_to_the_group, newInvitee)));
} else { } else {
notYouInviteCount++; notYouInviteCount++;
} }
@ -278,29 +280,30 @@ final class GroupsV2UpdateMessageProducer {
} }
if (notYouInviteCount > 0) { if (notYouInviteCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCount, describe(change.getEditor()), notYouInviteCount)); final int notYouInviteCountFinalCopy = notYouInviteCount;
updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCountFinalCopy, editor, notYouInviteCountFinalCopy)));
} }
} }
private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
int notYouInviteCount = 0; int notYouInviteCount = 0;
for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) {
boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_were_invited_to_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_were_invited_to_the_group)));
} else { } else {
notYouInviteCount++; notYouInviteCount++;
} }
} }
if (notYouInviteCount > 0) { if (notYouInviteCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_d_people_were_invited_to_the_group, notYouInviteCount, notYouInviteCount)); updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_people_were_invited_to_the_group, notYouInviteCount, notYouInviteCount)));
} }
} }
private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notDeclineCount = 0; int notDeclineCount = 0;
@ -308,9 +311,9 @@ final class GroupsV2UpdateMessageProducer {
boolean decline = invitee.getUuid().equals(change.getEditor()); boolean decline = invitee.getUuid().equals(change.getEditor());
if (decline) { if (decline) {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_declined_the_invitation_to_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_declined_the_invitation_to_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group)));
} }
} else { } else {
notDeclineCount++; notDeclineCount++;
@ -319,32 +322,33 @@ final class GroupsV2UpdateMessageProducer {
if (notDeclineCount > 0) { if (notDeclineCount > 0) {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_you_revoked_invites, notDeclineCount, notDeclineCount)); updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_you_revoked_invites, notDeclineCount, notDeclineCount)));
} else { } else {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_s_revoked_invites, notDeclineCount, describe(change.getEditor()), notDeclineCount)); final int notDeclineCountFinalCopy = notDeclineCount;
updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_revoked_invites, notDeclineCountFinalCopy, editor, notDeclineCountFinalCopy)));
} }
} }
} }
private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
int notDeclineCount = 0; int notDeclineCount = 0;
for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) { for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) {
boolean inviteeWasYou = invitee.getUuid().equals(selfUuidBytes); boolean inviteeWasYou = invitee.getUuid().equals(selfUuidBytes);
if (inviteeWasYou) { if (inviteeWasYou) {
updates.add(context.getString(R.string.MessageRecord_your_invitation_to_the_group_was_revoked)); updates.add(updateDescription(context.getString(R.string.MessageRecord_your_invitation_to_the_group_was_revoked)));
} else { } else {
notDeclineCount++; notDeclineCount++;
} }
} }
if (notDeclineCount > 0) { if (notDeclineCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_d_invitations_were_revoked, notDeclineCount, notDeclineCount)); updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_invitations_were_revoked, notDeclineCount, notDeclineCount)));
} }
} }
private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember newMember : change.getPromotePendingMembersList()) { for (DecryptedMember newMember : change.getPromotePendingMembersList()) {
@ -353,142 +357,166 @@ final class GroupsV2UpdateMessageProducer {
if (editorIsYou) { if (editorIsYou) {
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_accepted_invite)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_accepted_invite)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_you_added_invited_member_s, describe(uuid))); updates.add(updateDescription(uuid, newPromotedMember -> context.getString(R.string.MessageRecord_you_added_invited_member_s, newPromotedMember)));
} }
} else { } else {
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor)));
} else { } else {
if (uuid.equals(change.getEditor())) { if (uuid.equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_accepted_invite, describe(uuid))); updates.add(updateDescription(uuid, newAcceptedMember -> context.getString(R.string.MessageRecord_s_accepted_invite, newAcceptedMember)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_added_invited_member_s, describe(change.getEditor()), describe(uuid))); updates.add(updateDescription(change.getEditor(), uuid, (editor, newAcceptedMember) -> context.getString(R.string.MessageRecord_s_added_invited_member_s, editor, newAcceptedMember)));
} }
} }
} }
} }
} }
private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedMember newMember : change.getPromotePendingMembersList()) { for (DecryptedMember newMember : change.getPromotePendingMembersList()) {
ByteString uuid = newMember.getUuid(); ByteString uuid = newMember.getUuid();
boolean newMemberIsYou = uuid.equals(selfUuidBytes); boolean newMemberIsYou = uuid.equals(selfUuidBytes);
if (newMemberIsYou) { if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(uuid))); updates.add(updateDescription(uuid, newMemberName -> context.getString(R.string.MessageRecord_s_joined_the_group, newMemberName)));
} }
} }
} }
private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTitle()) { if (change.hasNewTitle()) {
String newTitle = change.getNewTitle().getValue();
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, change.getNewTitle().getValue())); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, describe(change.getEditor()), change.getNewTitle().getValue())); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, editor, newTitle)));
} }
} }
} }
private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewTitle()) { if (change.hasNewTitle()) {
updates.add(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, change.getNewTitle().getValue())); updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, change.getNewTitle().getValue())));
} }
} }
private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewAvatar()) { if (change.hasNewAvatar()) {
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_the_group_avatar)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_avatar)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_changed_the_group_avatar, describe(change.getEditor()))); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_avatar, editor)));
} }
} }
} }
private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewAvatar()) { if (change.hasNewAvatar()) {
updates.add(context.getString(R.string.MessageRecord_the_group_group_avatar_has_been_changed)); updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_group_avatar_has_been_changed)));
} }
} }
private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTimer()) { if (change.hasNewTimer()) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration()); String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration());
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, describe(change.getEditor()), time)); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, editor, time)));
} }
} }
} }
private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewTimer()) { if (change.hasNewTimer()) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration()); String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration());
updates.add(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time)); updates.add(updateDescription(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time)));
} }
} }
private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess()); String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess());
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_info_to_s, accessLevel)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_info_to_s, accessLevel)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, describe(change.getEditor()), accessLevel)); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, editor, accessLevel)));
} }
} }
} }
private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess()); String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess());
updates.add(context.getString(R.string.MessageRecord_who_can_edit_group_info_has_been_changed_to_s, accessLevel)); updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_info_has_been_changed_to_s, accessLevel)));
} }
} }
private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess()); String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess());
if (editorIsYou) { if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_membership_to_s, accessLevel)); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_membership_to_s, accessLevel)));
} else { } else {
updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, describe(change.getEditor()), accessLevel)); updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, editor, accessLevel)));
} }
} }
} }
private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) { private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess()); String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess());
updates.add(context.getString(R.string.MessageRecord_who_can_edit_group_membership_has_been_changed_to_s, accessLevel)); updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_membership_has_been_changed_to_s, accessLevel)));
} }
} }
private @NonNull String describe(@NonNull ByteString uuid) {
return descriptionStrategy.describe(UuidUtil.fromByteString(uuid));
}
interface DescribeMemberStrategy { interface DescribeMemberStrategy {
/** /**
* Map a UUID to a string that describes the group member. * Map a UUID to a string that describes the group member.
*/ */
@NonNull String describe(@NonNull UUID uuid); @NonNull
@WorkerThread
String describe(@NonNull UUID uuid);
}
private interface StringFactory1Arg {
String create(String arg1);
}
private interface StringFactory2Args {
String create(String arg1, String arg2);
}
private static UpdateDescription updateDescription(@NonNull String string) {
return UpdateDescription.staticDescription(string);
}
private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, @NonNull StringFactory1Arg stringFactory) {
UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes);
return UpdateDescription.mentioning(Collections.singletonList(uuid1), () -> stringFactory.create(descriptionStrategy.describe(uuid1)));
}
private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, @NonNull ByteString uuid2Bytes, @NonNull StringFactory2Args stringFactory) {
UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes);
UUID uuid2 = UuidUtil.fromByteStringOrUnknown(uuid2Bytes);
return UpdateDescription.mentioning(Arrays.asList(uuid1, uuid2), () -> stringFactory.create(descriptionStrategy.describe(uuid1), descriptionStrategy.describe(uuid2)));
} }
} }

View File

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Function;
import java.util.List;
public final class LiveUpdateMessage {
/**
* Creates a live data that observes the recipients mentioned in the {@link UpdateDescription} and
* recreates the string asynchronously when they change.
*/
@AnyThread
public static LiveData<String> fromMessageDescription(@NonNull UpdateDescription updateDescription) {
if (updateDescription.isStringStatic()) {
return LiveDataUtil.just(updateDescription.getStaticString());
}
List<LiveData<Recipient>> allMentionedRecipients = Stream.of(updateDescription.getMentioned())
.map(uuid -> Recipient.resolved(RecipientId.from(uuid, null)).live().getLiveData())
.toList();
LiveData<?> mentionedRecipientChangeStream = allMentionedRecipients.isEmpty() ? LiveDataUtil.just(new Object())
: LiveDataUtil.merge(allMentionedRecipients);
return LiveDataUtil.mapAsync(mentionedRecipientChangeStream, event -> updateDescription.getString());
}
/**
* Observes a single recipient and recreates the string asynchronously when they change.
*/
public static LiveData<String> recipientToStringAsync(@NonNull RecipientId recipientId,
@NonNull Function<Recipient, String> createStringInBackground)
{
return LiveDataUtil.mapAsync(Recipient.live(recipientId).getLiveData(), createStringInBackground);
}
}

View File

@ -23,8 +23,8 @@ import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
@ -39,9 +39,11 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -109,78 +111,84 @@ public abstract class MessageRecord extends DisplayRecord {
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdate() && isGroupV2()) { UpdateDescription updateDisplayBody = getUpdateDisplayBody(context);
return new SpannableString(getGv2Description(context));
} else if (isGroupUpdate() && isOutgoing()) { if (updateDisplayBody != null) {
return new SpannableString(context.getString(R.string.MessageRecord_you_updated_group)); return new SpannableString(updateDisplayBody.getString());
} else if (isGroupUpdate()) {
return new SpannableString(GroupUtil.getDescription(context, getBody(), false).toString(getIndividualRecipient()));
} else if (isGroupQuit() && isOutgoing()) {
return new SpannableString(context.getString(R.string.MessageRecord_left_group));
} else if (isGroupQuit()) {
return new SpannableString(context.getString(R.string.ConversationItem_group_action_left, getIndividualRecipient().getDisplayName(context)));
} else if (isIncomingCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_s_called_you, getIndividualRecipient().getDisplayName(context)));
} else if (isOutgoingCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_you_called));
} else if (isMissedCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_missed_call));
} else if (isJoined()) {
return new SpannableString(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)));
} else if (isExpirationTimerUpdate()) {
int seconds = (int)(getExpiresIn() / 1000);
if (seconds <= 0) {
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages))
: new SpannableString(context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, getIndividualRecipient().getDisplayName(context)));
}
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time))
: new SpannableString(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, getIndividualRecipient().getDisplayName(context), time));
} else if (isIdentityUpdate()) {
return new SpannableString(context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, getIndividualRecipient().getDisplayName(context)));
} else if (isIdentityVerified()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, getIndividualRecipient().getDisplayName(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, getIndividualRecipient().getDisplayName(context)));
} else if (isIdentityDefault()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, getIndividualRecipient().getDisplayName(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, getIndividualRecipient().getDisplayName(context)));
} else if (isProfileChange()) {
return new SpannableString(getProfileChangeDescription(context));
} }
return new SpannableString(getBody()); return new SpannableString(getBody());
} }
private @NonNull String getGv2Description(@NonNull Context context) { public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context) {
if (!isGroupUpdate() || !isGroupV2()) { if (isGroupUpdate() && isGroupV2()) {
throw new AssertionError(); return getGv2ChangeDescription(context, getBody());
} else if (isGroupUpdate() && isOutgoing()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_you_updated_group));
} else if (isGroupUpdate()) {
return fromRecipient(getIndividualRecipient(), r -> GroupUtil.getNonV2GroupDescription(context, getBody()).toString(r));
} else if (isGroupQuit() && isOutgoing()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_left_group));
} else if (isGroupQuit()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.ConversationItem_group_action_left, r.getDisplayName(context)));
} else if (isIncomingCall()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_called_you, r.getDisplayName(context)));
} else if (isOutgoingCall()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_you_called));
} else if (isMissedCall()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_call));
} else if (isJoined()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)));
} else if (isExpirationTimerUpdate()) {
int seconds = (int)(getExpiresIn() / 1000);
if (seconds <= 0) {
return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages))
: fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, r.getDisplayName(context)));
} }
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time))
: fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, r.getDisplayName(context), time));
} else if (isIdentityUpdate()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context)));
} else if (isIdentityVerified()) {
if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, r.getDisplayName(context)));
else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, r.getDisplayName(context)));
} else if (isIdentityDefault()) {
if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, r.getDisplayName(context)));
else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, r.getDisplayName(context)));
} else if (isProfileChange()) {
return staticUpdateDescription(getProfileChangeDescription(context));
}
return null;
}
public static @NonNull UpdateDescription getGv2ChangeDescription(@NonNull Context context, @NonNull String body) {
try { try {
ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context); ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context);
byte[] decoded = Base64.decode(getBody()); byte[] decoded = Base64.decode(body);
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded); DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get()); GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) { if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
DecryptedGroupChange change = decryptedGroupV2Context.getChange(); return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
List<String> strings = updateMessageProducer.describeChange(change);
StringBuilder result = new StringBuilder();
for (int i = 0; i < strings.size(); i++) {
if (i > 0) result.append('\n');
result.append(strings.get(i));
}
return result.toString();
} else { } else {
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState()); return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());
} }
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, "GV2 Message update detail could not be read", e); Log.w(TAG, "GV2 Message update detail could not be read", e);
return context.getString(R.string.MessageRecord_group_updated); return staticUpdateDescription(context.getString(R.string.MessageRecord_group_updated));
} }
} }
private static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, @NonNull Function<Recipient, String> stringFunction) {
return UpdateDescription.mentioning(Collections.singletonList(recipient.getUuid().or(UuidUtil.UNKNOWN_UUID)), () -> stringFunction.apply(recipient.resolve()));
}
private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string) {
return UpdateDescription.staticDescription(string);
}
private @NonNull String getProfileChangeDescription(@NonNull Context context) { private @NonNull String getProfileChangeDescription(@NonNull Context context) {
try { try {
byte[] decoded = Base64.decode(getBody()); byte[] decoded = Base64.decode(getBody());
@ -316,7 +324,7 @@ public abstract class MessageRecord extends DisplayRecord {
return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure()); return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure());
} }
protected SpannableString emphasisAdded(String sequence) { protected static SpannableString emphasisAdded(String sequence) {
SpannableString spannable = new SpannableString(sequence); SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

View File

@ -0,0 +1,151 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Contains a list of people mentioned in an update message and a function to create the update message.
*/
public final class UpdateDescription {
public interface StringFactory {
@WorkerThread
String create();
}
private final Collection<UUID> mentioned;
private final StringFactory stringFactory;
private final String staticString;
private UpdateDescription(@NonNull Collection<UUID> mentioned,
@Nullable StringFactory stringFactory,
@Nullable String staticString)
{
if (staticString == null && stringFactory == null) {
throw new AssertionError();
}
this.mentioned = mentioned;
this.stringFactory = stringFactory;
this.staticString = staticString;
}
/**
* Create an update description which has a string value created by a supplied factory method that
* will be run on a background thread.
*
* @param mentioned UUIDs of recipients that are mentioned in the string.
* @param stringFactory The background method for generating the string.
*/
public static UpdateDescription mentioning(@NonNull Collection<UUID> mentioned,
@NonNull StringFactory stringFactory)
{
return new UpdateDescription(UuidUtil.filterKnown(mentioned),
stringFactory,
null);
}
/**
* Create an update description that's string value is fixed.
*/
public static UpdateDescription staticDescription(@NonNull String staticString) {
return new UpdateDescription(Collections.emptyList(), null, staticString);
}
public boolean isStringStatic() {
return staticString != null;
}
@AnyThread
public @NonNull String getStaticString() {
if (staticString == null) {
throw new UnsupportedOperationException();
}
return staticString;
}
@WorkerThread
public @NonNull String getString() {
if (staticString != null) {
return staticString;
}
Util.assertNotMainThread();
//noinspection ConstantConditions
return stringFactory.create();
}
@AnyThread
public Collection<UUID> getMentioned() {
return mentioned;
}
public static UpdateDescription concatWithNewLines(@NonNull List<UpdateDescription> updateDescriptions) {
if (updateDescriptions.size() == 0) {
throw new AssertionError();
}
if (updateDescriptions.size() == 1) {
return updateDescriptions.get(0);
}
if (allAreStatic(updateDescriptions)) {
return UpdateDescription.staticDescription(concatStaticLines(updateDescriptions));
}
Set<UUID> allMentioned = new HashSet<>();
for (UpdateDescription updateDescription : updateDescriptions) {
allMentioned.addAll(updateDescription.getMentioned());
}
return UpdateDescription.mentioning(allMentioned, () -> concatLines(updateDescriptions));
}
private static boolean allAreStatic(@NonNull Collection<UpdateDescription> updateDescriptions) {
for (UpdateDescription description : updateDescriptions) {
if (!description.isStringStatic()) {
return false;
}
}
return true;
}
@WorkerThread
private static String concatLines(@NonNull List<UpdateDescription> updateDescriptions) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < updateDescriptions.size(); i++) {
if (i > 0) result.append('\n');
result.append(updateDescriptions.get(i).getString());
}
return result.toString();
}
@AnyThread
private static String concatStaticLines(@NonNull List<UpdateDescription> updateDescriptions) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < updateDescriptions.size(); i++) {
if (i > 0) result.append('\n');
result.append(updateDescriptions.get(i).getStaticString());
}
return result.toString();
}
}

View File

@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.mms.MessageGroupContext;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
@ -67,13 +66,13 @@ public final class GroupUtil {
return Optional.absent(); return Optional.absent();
} }
public static @NonNull GroupDescription getDescription(@NonNull Context context, @Nullable String encodedGroup, boolean isV2) { public static @NonNull GroupDescription getNonV2GroupDescription(@NonNull Context context, @Nullable String encodedGroup) {
if (encodedGroup == null) { if (encodedGroup == null) {
return new GroupDescription(context, null); return new GroupDescription(context, null);
} }
try { try {
MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, isV2); MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, false);
return new GroupDescription(context, groupContext); return new GroupDescription(context, groupContext);
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, e); Log.w(TAG, e);
@ -117,7 +116,8 @@ public final class GroupUtil {
} }
} }
public String toString(Recipient sender) { @WorkerThread
public String toString(@NonNull Recipient sender) {
StringBuilder description = new StringBuilder(); StringBuilder description = new StringBuilder();
description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.getDisplayName(context))); description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.getDisplayName(context)));
@ -142,22 +142,6 @@ public final class GroupUtil {
return description.toString(); return description.toString();
} }
public void addObserver(RecipientForeverObserver listener) {
if (this.members != null) {
for (RecipientId member : this.members) {
Recipient.live(member).observeForever(listener);
}
}
}
public void removeObserver(RecipientForeverObserver listener) {
if (this.members != null) {
for (RecipientId member : this.members) {
Recipient.live(member).removeForeverObserver(listener);
}
}
}
private String toString(List<RecipientId> recipients) { private String toString(List<RecipientId> recipients) {
StringBuilder result = new StringBuilder(); StringBuilder result = new StringBuilder();

View File

@ -11,6 +11,10 @@ import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Function; import org.whispersystems.libsignal.util.guava.Function;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
public final class LiveDataUtil { public final class LiveDataUtil {
@ -78,6 +82,34 @@ public final class LiveDataUtil {
return new CombineLiveData<>(a, b, combine); return new CombineLiveData<>(a, b, combine);
} }
/**
* Merges the supplied live data streams.
*/
public static <T> LiveData<T> merge(@NonNull List<LiveData<T>> liveDataList) {
Set<LiveData<T>> set = new LinkedHashSet<>(liveDataList);
set.addAll(liveDataList);
if (set.size() == 1) {
return liveDataList.get(0);
}
MediatorLiveData<T> mergedLiveData = new MediatorLiveData<>();
for (LiveData<T> liveDataSource : set) {
mergedLiveData.addSource(liveDataSource, mergedLiveData::setValue);
}
return mergedLiveData;
}
/**
* @return Live data with just the initial value.
*/
public static <T> LiveData<T> just(@NonNull T item) {
return new MutableLiveData<>(item);
}
public interface Combine<A, B, R> { public interface Combine<A, B, R> {
@NonNull R apply(@NonNull A a, @NonNull B b); @NonNull R apply(@NonNull A a, @NonNull B b);
} }

View File

@ -41,7 +41,7 @@
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="7dp" android:layout_marginBottom="7dp"
android:gravity="center" android:gravity="center"
@ -50,7 +50,7 @@
<org.thoughtcrime.securesms.components.emoji.EmojiTextView <org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/conversation_update_body" android:id="@+id/conversation_update_body"
style="@style/Signal.Text.Preview" style="@style/Signal.Text.Preview"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:autoLink="all" android:autoLink="all"
android:gravity="center" android:gravity="center"

View File

@ -5,11 +5,16 @@ import android.app.Application;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import com.annimon.stream.Stream;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.rule.PowerMockRule;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.AccessControl;
@ -22,19 +27,27 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedString;
import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.storageservice.protos.groups.local.DecryptedTimer;
import org.thoughtcrime.securesms.testutil.MainThreadUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class) @Config(manifest = Config.NONE, application = Application.class)
@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*", "androidx.*" })
@PrepareForTest(Util.class)
public final class GroupsV2UpdateMessageProducerTest { public final class GroupsV2UpdateMessageProducerTest {
private UUID you; private UUID you;
@ -43,6 +56,9 @@ public final class GroupsV2UpdateMessageProducerTest {
private GroupsV2UpdateMessageProducer producer; private GroupsV2UpdateMessageProducer producer;
@Rule
public PowerMockRule powerMockRule = new PowerMockRule();
@Before @Before
public void setup() { public void setup() {
you = UUID.randomUUID(); you = UUID.randomUUID();
@ -57,7 +73,7 @@ public final class GroupsV2UpdateMessageProducerTest {
DecryptedGroupChange change = changeBy(alice) DecryptedGroupChange change = changeBy(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice updated the group."))); assertThat(describeChange(change), is(singletonList("Alice updated the group.")));
} }
@Test @Test
@ -65,7 +81,7 @@ public final class GroupsV2UpdateMessageProducerTest {
DecryptedGroupChange change = changeBy(you) DecryptedGroupChange change = changeBy(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You updated the group."))); assertThat(describeChange(change), is(singletonList("You updated the group.")));
} }
@Test @Test
@ -73,7 +89,7 @@ public final class GroupsV2UpdateMessageProducerTest {
DecryptedGroupChange change = changeByUnknown() DecryptedGroupChange change = changeByUnknown()
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("The group was updated."))); assertThat(describeChange(change), is(singletonList("The group was updated.")));
} }
// Member additions // Member additions
@ -84,7 +100,16 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(bob) .addMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice added Bob."))); assertThat(describeChange(change), is(singletonList("Alice added Bob.")));
}
@Test
public void member_added_member_mentions_both() {
DecryptedGroupChange change = changeBy(alice)
.addMember(bob)
.build();
assertSingleChangeMentioning(change, Arrays.asList(alice, bob));
} }
@Test @Test
@ -93,7 +118,16 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(bob) .addMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You added Bob."))); assertThat(describeChange(change), is(singletonList("You added Bob.")));
}
@Test
public void you_added_member_mentions_just_member() {
DecryptedGroupChange change = changeBy(you)
.addMember(bob)
.build();
assertSingleChangeMentioning(change, singletonList(bob));
} }
@Test @Test
@ -102,7 +136,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(you) .addMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice added you to the group."))); assertThat(describeChange(change), is(singletonList("Alice added you to the group.")));
} }
@Test @Test
@ -111,7 +145,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(you) .addMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You joined the group."))); assertThat(describeChange(change), is(singletonList("You joined the group.")));
} }
@Test @Test
@ -120,7 +154,16 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(bob) .addMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob joined the group."))); assertThat(describeChange(change), is(singletonList("Bob joined the group.")));
}
@Test
public void member_added_themselves_mentions_just_member() {
DecryptedGroupChange change = changeBy(bob)
.addMember(bob)
.build();
assertSingleChangeMentioning(change, singletonList(bob));
} }
@Test @Test
@ -129,7 +172,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(you) .addMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You joined the group."))); assertThat(describeChange(change), is(singletonList("You joined the group.")));
} }
@Test @Test
@ -138,18 +181,18 @@ public final class GroupsV2UpdateMessageProducerTest {
.addMember(bob) .addMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob joined the group."))); assertThat(describeChange(change), is(singletonList("Bob joined the group.")));
} }
// Member removals
// Member removals
@Test @Test
public void member_removed_member() { public void member_removed_member() {
DecryptedGroupChange change = changeBy(alice) DecryptedGroupChange change = changeBy(alice)
.deleteMember(bob) .deleteMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice removed Bob."))); assertThat(describeChange(change), is(singletonList("Alice removed Bob.")));
} }
@Test @Test
@ -158,7 +201,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(bob) .deleteMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You removed Bob."))); assertThat(describeChange(change), is(singletonList("You removed Bob.")));
} }
@Test @Test
@ -167,7 +210,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(you) .deleteMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice removed you from the group."))); assertThat(describeChange(change), is(singletonList("Alice removed you from the group.")));
} }
@Test @Test
@ -176,7 +219,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(you) .deleteMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You left the group."))); assertThat(describeChange(change), is(singletonList("You left the group.")));
} }
@Test @Test
@ -185,7 +228,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(bob) .deleteMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob left the group."))); assertThat(describeChange(change), is(singletonList("Bob left the group.")));
} }
@Test @Test
@ -194,7 +237,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(alice) .deleteMember(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice is no longer in the group."))); assertThat(describeChange(change), is(singletonList("Alice is no longer in the group.")));
} }
@Test @Test
@ -203,7 +246,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.deleteMember(you) .deleteMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You are no longer in the group."))); assertThat(describeChange(change), is(singletonList("You are no longer in the group.")));
} }
// Member role modifications // Member role modifications
@ -214,7 +257,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promoteToAdmin(alice) .promoteToAdmin(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You made Alice an admin."))); assertThat(describeChange(change), is(singletonList("You made Alice an admin.")));
} }
@Test @Test
@ -223,7 +266,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promoteToAdmin(alice) .promoteToAdmin(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob made Alice an admin."))); assertThat(describeChange(change), is(singletonList("Bob made Alice an admin.")));
} }
@Test @Test
@ -232,7 +275,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promoteToAdmin(you) .promoteToAdmin(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice made you an admin."))); assertThat(describeChange(change), is(singletonList("Alice made you an admin.")));
} }
@Test @Test
@ -241,7 +284,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.demoteToMember(bob) .demoteToMember(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You revoked admin privileges from Bob."))); assertThat(describeChange(change), is(singletonList("You revoked admin privileges from Bob.")));
} }
@Test @Test
@ -250,7 +293,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.demoteToMember(alice) .demoteToMember(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob revoked admin privileges from Alice."))); assertThat(describeChange(change), is(singletonList("Bob revoked admin privileges from Alice.")));
} }
@Test @Test
@ -259,7 +302,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.demoteToMember(you) .demoteToMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice revoked your admin privileges."))); assertThat(describeChange(change), is(singletonList("Alice revoked your admin privileges.")));
} }
@Test @Test
@ -268,7 +311,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promoteToAdmin(alice) .promoteToAdmin(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice is now an admin."))); assertThat(describeChange(change), is(singletonList("Alice is now an admin.")));
} }
@Test @Test
@ -277,7 +320,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promoteToAdmin(you) .promoteToAdmin(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You are now an admin."))); assertThat(describeChange(change), is(singletonList("You are now an admin.")));
} }
@Test @Test
@ -286,7 +329,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.demoteToMember(alice) .demoteToMember(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice is no longer an admin."))); assertThat(describeChange(change), is(singletonList("Alice is no longer an admin.")));
} }
@Test @Test
@ -295,7 +338,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.demoteToMember(you) .demoteToMember(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You are no longer an admin."))); assertThat(describeChange(change), is(singletonList("You are no longer an admin.")));
} }
// Member invitation // Member invitation
@ -306,7 +349,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(alice) .invite(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You invited Alice to the group."))); assertThat(describeChange(change), is(singletonList("You invited Alice to the group.")));
} }
@Test @Test
@ -315,7 +358,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(you) .invite(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice invited you to the group."))); assertThat(describeChange(change), is(singletonList("Alice invited you to the group.")));
} }
@Test @Test
@ -324,7 +367,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(bob) .invite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice invited 1 person to the group."))); assertThat(describeChange(change), is(singletonList("Alice invited 1 person to the group.")));
} }
@Test @Test
@ -334,7 +377,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(UUID.randomUUID()) .invite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice invited 2 people to the group."))); assertThat(describeChange(change), is(singletonList("Alice invited 2 people to the group.")));
} }
@Test @Test
@ -346,7 +389,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(UUID.randomUUID()) .invite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(Arrays.asList("Bob invited you to the group.", "Bob invited 3 people to the group."))); assertThat(describeChange(change), is(Arrays.asList("Bob invited you to the group.", "Bob invited 3 people to the group.")));
} }
@Test @Test
@ -355,7 +398,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(you) .invite(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You were invited to the group."))); assertThat(describeChange(change), is(singletonList("You were invited to the group.")));
} }
@Test @Test
@ -364,7 +407,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(alice) .invite(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("1 person was invited to the group."))); assertThat(describeChange(change), is(singletonList("1 person was invited to the group.")));
} }
@Test @Test
@ -374,7 +417,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(bob) .invite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("2 people were invited to the group."))); assertThat(describeChange(change), is(singletonList("2 people were invited to the group.")));
} }
@Test @Test
@ -386,7 +429,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(UUID.randomUUID()) .invite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(Arrays.asList("You were invited to the group.", "3 people were invited to the group."))); assertThat(describeChange(change), is(Arrays.asList("You were invited to the group.", "3 people were invited to the group.")));
} }
// Member invitation revocation // Member invitation revocation
@ -397,7 +440,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(bob) .uninvite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice revoked an invitation to the group."))); assertThat(describeChange(change), is(singletonList("Alice revoked an invitation to the group.")));
} }
@Test @Test
@ -407,7 +450,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(UUID.randomUUID()) .uninvite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice revoked 2 invitations to the group."))); assertThat(describeChange(change), is(singletonList("Alice revoked 2 invitations to the group.")));
} }
@Test @Test
@ -416,7 +459,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(bob) .uninvite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You revoked an invitation to the group."))); assertThat(describeChange(change), is(singletonList("You revoked an invitation to the group.")));
} }
@Test @Test
@ -426,7 +469,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(UUID.randomUUID()) .uninvite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You revoked 2 invitations to the group."))); assertThat(describeChange(change), is(singletonList("You revoked 2 invitations to the group.")));
} }
@Test @Test
@ -435,7 +478,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(bob) .uninvite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Someone declined an invitation to the group."))); assertThat(describeChange(change), is(singletonList("Someone declined an invitation to the group.")));
} }
@Test @Test
@ -444,7 +487,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(you) .uninvite(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You declined the invitation to the group."))); assertThat(describeChange(change), is(singletonList("You declined the invitation to the group.")));
} }
@Test @Test
@ -453,7 +496,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(you) .uninvite(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Your invitation to the group was revoked."))); assertThat(describeChange(change), is(singletonList("Your invitation to the group was revoked.")));
} }
@Test @Test
@ -462,7 +505,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(bob) .uninvite(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("An invitation to the group was revoked."))); assertThat(describeChange(change), is(singletonList("An invitation to the group was revoked.")));
} }
@Test @Test
@ -472,7 +515,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(UUID.randomUUID()) .uninvite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("2 invitations to the group were revoked."))); assertThat(describeChange(change), is(singletonList("2 invitations to the group were revoked.")));
} }
@Test @Test
@ -484,7 +527,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.uninvite(UUID.randomUUID()) .uninvite(UUID.randomUUID())
.build(); .build();
assertThat(producer.describeChange(change), is(Arrays.asList("Your invitation to the group was revoked.", "3 invitations to the group were revoked."))); assertThat(describeChange(change), is(Arrays.asList("Your invitation to the group was revoked.", "3 invitations to the group were revoked.")));
} }
// Promote pending members // Promote pending members
@ -495,7 +538,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(bob) .promote(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob accepted an invitation to the group."))); assertThat(describeChange(change), is(singletonList("Bob accepted an invitation to the group.")));
} }
@Test @Test
@ -504,7 +547,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(you) .promote(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You accepted the invitation to the group."))); assertThat(describeChange(change), is(singletonList("You accepted the invitation to the group.")));
} }
@Test @Test
@ -513,7 +556,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(alice) .promote(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob added invited member Alice."))); assertThat(describeChange(change), is(singletonList("Bob added invited member Alice.")));
} }
@Test @Test
@ -522,7 +565,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(bob) .promote(bob)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You added invited member Bob."))); assertThat(describeChange(change), is(singletonList("You added invited member Bob.")));
} }
@Test @Test
@ -531,7 +574,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(you) .promote(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob added you to the group."))); assertThat(describeChange(change), is(singletonList("Bob added you to the group.")));
} }
@Test @Test
@ -540,7 +583,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(you) .promote(you)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You joined the group."))); assertThat(describeChange(change), is(singletonList("You joined the group.")));
} }
@Test @Test
@ -549,7 +592,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.promote(alice) .promote(alice)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice joined the group."))); assertThat(describeChange(change), is(singletonList("Alice joined the group.")));
} }
// Title change // Title change
@ -560,7 +603,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("New title") .title("New title")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice changed the group name to \"New title\"."))); assertThat(describeChange(change), is(singletonList("Alice changed the group name to \"New title\".")));
} }
@Test @Test
@ -569,7 +612,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("Title 2") .title("Title 2")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You changed the group name to \"Title 2\"."))); assertThat(describeChange(change), is(singletonList("You changed the group name to \"Title 2\".")));
} }
@Test @Test
@ -578,7 +621,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("Title 3") .title("Title 3")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("The group name has changed to \"Title 3\"."))); assertThat(describeChange(change), is(singletonList("The group name has changed to \"Title 3\".")));
} }
// Avatar change // Avatar change
@ -589,7 +632,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.avatar("Avatar1") .avatar("Avatar1")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice changed the group avatar."))); assertThat(describeChange(change), is(singletonList("Alice changed the group avatar.")));
} }
@Test @Test
@ -598,7 +641,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.avatar("Avatar2") .avatar("Avatar2")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You changed the group avatar."))); assertThat(describeChange(change), is(singletonList("You changed the group avatar.")));
} }
@Test @Test
@ -607,7 +650,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.avatar("Avatar3") .avatar("Avatar3")
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("The group avatar has been changed."))); assertThat(describeChange(change), is(singletonList("The group avatar has been changed.")));
} }
// Timer change // Timer change
@ -618,7 +661,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.timer(10) .timer(10)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob set the disappearing message timer to 10 seconds."))); assertThat(describeChange(change), is(singletonList("Bob set the disappearing message timer to 10 seconds.")));
} }
@Test @Test
@ -627,7 +670,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.timer(60) .timer(60)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You set the disappearing message timer to 1 minute."))); assertThat(describeChange(change), is(singletonList("You set the disappearing message timer to 1 minute.")));
} }
@Test @Test
@ -636,7 +679,16 @@ public final class GroupsV2UpdateMessageProducerTest {
.timer(120) .timer(120)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("The disappearing message timer has been set to 2 minutes."))); assertThat(describeChange(change), is(singletonList("The disappearing message timer has been set to 2 minutes.")));
}
@Test
public void unknown_change_timer_mentions_no_one() {
DecryptedGroupChange change = changeByUnknown()
.timer(120)
.build();
assertSingleChangeMentioning(change, emptyList());
} }
// Attribute access change // Attribute access change
@ -647,7 +699,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.attributeAccess(AccessControl.AccessRequired.MEMBER) .attributeAccess(AccessControl.AccessRequired.MEMBER)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Bob changed who can edit group info to \"All members\"."))); assertThat(describeChange(change), is(singletonList("Bob changed who can edit group info to \"All members\".")));
} }
@Test @Test
@ -656,7 +708,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.attributeAccess(AccessControl.AccessRequired.ADMINISTRATOR) .attributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You changed who can edit group info to \"Only admins\"."))); assertThat(describeChange(change), is(singletonList("You changed who can edit group info to \"Only admins\".")));
} }
@Test @Test
@ -665,7 +717,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.attributeAccess(AccessControl.AccessRequired.ADMINISTRATOR) .attributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Who can edit group info has been changed to \"Only admins\"."))); assertThat(describeChange(change), is(singletonList("Who can edit group info has been changed to \"Only admins\".")));
} }
// Membership access change // Membership access change
@ -676,7 +728,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.membershipAccess(AccessControl.AccessRequired.ADMINISTRATOR) .membershipAccess(AccessControl.AccessRequired.ADMINISTRATOR)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Alice changed who can edit group membership to \"Only admins\"."))); assertThat(describeChange(change), is(singletonList("Alice changed who can edit group membership to \"Only admins\".")));
} }
@Test @Test
@ -685,7 +737,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.membershipAccess(AccessControl.AccessRequired.MEMBER) .membershipAccess(AccessControl.AccessRequired.MEMBER)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("You changed who can edit group membership to \"All members\"."))); assertThat(describeChange(change), is(singletonList("You changed who can edit group membership to \"All members\".")));
} }
@Test @Test
@ -694,7 +746,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.membershipAccess(AccessControl.AccessRequired.ADMINISTRATOR) .membershipAccess(AccessControl.AccessRequired.ADMINISTRATOR)
.build(); .build();
assertThat(producer.describeChange(change), is(singletonList("Who can edit group membership has been changed to \"Only admins\"."))); assertThat(describeChange(change), is(singletonList("Who can edit group membership has been changed to \"Only admins\".")));
} }
// Multiple changes // Multiple changes
@ -708,7 +760,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.timer(300) .timer(300)
.build(); .build();
assertThat(producer.describeChange(change), is(Arrays.asList( assertThat(describeChange(change), is(Arrays.asList(
"Alice added Bob.", "Alice added Bob.",
"Alice changed the group name to \"Title\".", "Alice changed the group name to \"Title\".",
"Alice set the disappearing message timer to 5 minutes.", "Alice set the disappearing message timer to 5 minutes.",
@ -725,7 +777,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.timer(600) .timer(600)
.build(); .build();
assertThat(producer.describeChange(change), is(Arrays.asList( assertThat(describeChange(change), is(Arrays.asList(
"Bob joined the group.", "Bob joined the group.",
"The group name has changed to \"Title 2\".", "The group name has changed to \"Title 2\".",
"The group avatar has been changed.", "The group avatar has been changed.",
@ -740,7 +792,7 @@ public final class GroupsV2UpdateMessageProducerTest {
DecryptedGroup group = newGroupBy(you, 0) DecryptedGroup group = newGroupBy(you, 0)
.build(); .build();
assertThat(producer.describeNewGroup(group), is("You created the group.")); assertThat(describeNewGroup(group), is("You created the group."));
} }
@Test @Test
@ -749,7 +801,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.member(you) .member(you)
.build(); .build();
assertThat(producer.describeNewGroup(group), is("Alice added you to the group.")); assertThat(describeNewGroup(group), is("Alice added you to the group."));
} }
@Test @Test
@ -758,7 +810,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.member(you) .member(you)
.build(); .build();
assertThat(producer.describeNewGroup(group), is("You joined the group.")); assertThat(describeNewGroup(group), is("You joined the group."));
} }
@Test @Test
@ -767,7 +819,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.invite(bob, you) .invite(bob, you)
.build(); .build();
assertThat(producer.describeNewGroup(group), is("Bob invited you to the group.")); assertThat(describeNewGroup(group), is("Bob invited you to the group."));
} }
@Test @Test
@ -775,13 +827,40 @@ public final class GroupsV2UpdateMessageProducerTest {
DecryptedGroup group = newGroupBy(alice, 1) DecryptedGroup group = newGroupBy(alice, 1)
.build(); .build();
assertThat(producer.describeNewGroup(group), is("Group updated.")); assertThat(describeNewGroup(group), is("Group updated."));
} }
private GroupStateBuilder newGroupBy(UUID foundingMember, int revision) { private @NonNull List<String> describeChange(@NonNull DecryptedGroupChange change) {
MainThreadUtil.setMainThread(false);
return Stream.of(producer.describeChanges(change))
.map(UpdateDescription::getString)
.toList();
}
private @NonNull String describeNewGroup(@NonNull DecryptedGroup group) {
MainThreadUtil.setMainThread(false);
return producer.describeNewGroup(group).getString();
}
private static GroupStateBuilder newGroupBy(UUID foundingMember, int revision) {
return new GroupStateBuilder(foundingMember, revision); return new GroupStateBuilder(foundingMember, revision);
} }
private void assertSingleChangeMentioning(DecryptedGroupChange change, List<UUID> expectedMentions) {
List<UpdateDescription> changes = producer.describeChanges(change);
assertThat(changes.size(), is(1));
UpdateDescription description = changes.get(0);
assertThat(description.getMentioned(), is(expectedMentions));
if (expectedMentions.isEmpty()) {
assertTrue(description.isStringStatic());
} else {
assertFalse(description.isStringStatic());
}
}
private static class GroupStateBuilder { private static class GroupStateBuilder {
private final DecryptedGroup.Builder builder; private final DecryptedGroup.Builder builder;

View File

@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.database.model;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.thoughtcrime.securesms.testutil.MainThreadUtil.setMainThread;
@RunWith(PowerMockRunner.class)
@PrepareForTest(Util.class)
public final class UpdateDescriptionTest {
@Before
public void setup() {
setMainThread(true);
}
@Test
public void staticDescription_byGetStaticString() {
UpdateDescription description = UpdateDescription.staticDescription("update");
assertEquals("update", description.getStaticString());
}
@Test
public void staticDescription_has_empty_mentions() {
UpdateDescription description = UpdateDescription.staticDescription("update");
assertTrue(description.getMentioned().isEmpty());
}
@Test
public void staticDescription_byString() {
UpdateDescription description = UpdateDescription.staticDescription("update");
assertEquals("update", description.getString());
}
@Test(expected = AssertionError.class)
public void stringFactory_cannot_run_on_main_thread() {
UpdateDescription description = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), () -> "update");
setMainThread(true);
description.getString();
}
@Test(expected = UnsupportedOperationException.class)
public void stringFactory_cannot_call_static_string() {
UpdateDescription description = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), () -> "update");
description.getStaticString();
}
@Test
public void stringFactory_not_evaluated_until_getString() {
AtomicInteger factoryCalls = new AtomicInteger();
UpdateDescription.StringFactory stringFactory = () -> {
factoryCalls.incrementAndGet();
return "update";
};
UpdateDescription description = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory);
assertEquals(0, factoryCalls.get());
setMainThread(false);
String string = description.getString();
assertEquals("update", string);
assertEquals(1, factoryCalls.get());
}
@Test
public void stringFactory_reevaluated_on_every_call() {
AtomicInteger factoryCalls = new AtomicInteger();
UpdateDescription.StringFactory stringFactory = () -> "call" + factoryCalls.incrementAndGet();
UpdateDescription description = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory);
setMainThread(false);
assertEquals("call1", description.getString());
assertEquals("call2", description.getString());
assertEquals("call3", description.getString());
}
@Test
public void concat_static_lines() {
UpdateDescription description1 = UpdateDescription.staticDescription("update1");
UpdateDescription description2 = UpdateDescription.staticDescription("update2");
UpdateDescription description = UpdateDescription.concatWithNewLines(Arrays.asList(description1, description2));
assertTrue(description.isStringStatic());
assertEquals("update1\nupdate2", description.getStaticString());
assertEquals("update1\nupdate2", description.getString());
}
@Test
public void concat_single_does_not_make_new_object() {
UpdateDescription description = UpdateDescription.staticDescription("update1");
UpdateDescription concat = UpdateDescription.concatWithNewLines(Collections.singletonList(description));
assertSame(description, concat);
}
@Test
public void concat_dynamic_lines() {
AtomicInteger factoryCalls1 = new AtomicInteger();
AtomicInteger factoryCalls2 = new AtomicInteger();
UpdateDescription.StringFactory stringFactory1 = () -> "update." + factoryCalls1.incrementAndGet();
UpdateDescription.StringFactory stringFactory2 = () -> "update." + factoryCalls2.incrementAndGet();
UpdateDescription description1 = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory1);
UpdateDescription description2 = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory2);
factoryCalls1.set(10);
factoryCalls2.set(20);
UpdateDescription description = UpdateDescription.concatWithNewLines(Arrays.asList(description1, description2));
assertFalse(description.isStringStatic());
setMainThread(false);
assertEquals("update.11\nupdate.21", description.getString());
assertEquals("update.12\nupdate.22", description.getString());
assertEquals("update.13\nupdate.23", description.getString());
}
@Test
public void concat_dynamic_lines_and_static_lines() {
AtomicInteger factoryCalls1 = new AtomicInteger();
AtomicInteger factoryCalls2 = new AtomicInteger();
UpdateDescription.StringFactory stringFactory1 = () -> "update." + factoryCalls1.incrementAndGet();
UpdateDescription.StringFactory stringFactory2 = () -> "update." + factoryCalls2.incrementAndGet();
UpdateDescription description1 = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory1);
UpdateDescription description2 = UpdateDescription.staticDescription("static");
UpdateDescription description3 = UpdateDescription.mentioning(Collections.singletonList(UUID.randomUUID()), stringFactory2);
factoryCalls1.set(100);
factoryCalls2.set(200);
UpdateDescription description = UpdateDescription.concatWithNewLines(Arrays.asList(description1, description2, description3));
assertFalse(description.isStringStatic());
setMainThread(false);
assertEquals("update.101\nstatic\nupdate.201", description.getString());
assertEquals("update.102\nstatic\nupdate.202", description.getString());
assertEquals("update.103\nstatic\nupdate.203", description.getString());
}
}

View File

@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.testutil;
import org.thoughtcrime.securesms.util.Util;
import static org.mockito.Mockito.when;
import static org.powermock.api.mockito.PowerMockito.doCallRealMethod;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
public final class MainThreadUtil {
private MainThreadUtil() {
}
/**
* Makes {@link Util}'s Main thread assertions pass or fail during tests.
* <p>
* Use with {@link org.powermock.modules.junit4.PowerMockRunner} or robolectric with powermock
* rule and {@code @PrepareForTest(Util.class)}
*/
public static void setMainThread(boolean isMainThread) {
mockStatic(Util.class);
when(Util.isMainThread()).thenReturn(isMainThread);
try {
doCallRealMethod().when(Util.class, "assertMainThread");
doCallRealMethod().when(Util.class, "assertNotMainThread");
} catch (Exception e) {
throw new AssertionError();
}
}
}

View File

@ -14,7 +14,7 @@ public final class LiveDataTestUtil {
* <p> * <p>
* This will therefore only work in conjunction with {@link LiveDataRule}. * This will therefore only work in conjunction with {@link LiveDataRule}.
*/ */
public static <T> T getValue(final LiveData<T> liveData) { public static <T> T observeAndGetOneValue(final LiveData<T> liveData) {
AtomicReference<T> data = new AtomicReference<>(); AtomicReference<T> data = new AtomicReference<>();
Observer<T> observer = data::set; Observer<T> observer = data::set;

View File

@ -10,9 +10,9 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.assertNoValue; import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.assertNoValue;
import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.getValue; import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.observeAndGetOneValue;
public final class LiveDataUtilTest { public final class LiveDataUtilTest_combineLatest {
@Rule @Rule
public TestRule rule = new LiveDataRule(); public TestRule rule = new LiveDataRule();
@ -61,7 +61,7 @@ public final class LiveDataUtilTest {
liveDataA.setValue("Hello, "); liveDataA.setValue("Hello, ");
liveDataB.setValue("World!"); liveDataB.setValue("World!");
assertEquals("Hello, World!", getValue(combined)); assertEquals("Hello, World!", observeAndGetOneValue(combined));
} }
@Test @Test
@ -74,10 +74,10 @@ public final class LiveDataUtilTest {
liveDataA.setValue("Hello, "); liveDataA.setValue("Hello, ");
liveDataB.setValue("World!"); liveDataB.setValue("World!");
assertEquals("Hello, World!", getValue(combined)); assertEquals("Hello, World!", observeAndGetOneValue(combined));
liveDataA.setValue("Welcome, "); liveDataA.setValue("Welcome, ");
assertEquals("Welcome, World!", getValue(combined)); assertEquals("Welcome, World!", observeAndGetOneValue(combined));
} }
@Test @Test
@ -90,10 +90,10 @@ public final class LiveDataUtilTest {
liveDataA.setValue("Hello, "); liveDataA.setValue("Hello, ");
liveDataB.setValue("World!"); liveDataB.setValue("World!");
assertEquals("Hello, World!", getValue(combined)); assertEquals("Hello, World!", observeAndGetOneValue(combined));
liveDataB.setValue("Joe!"); liveDataB.setValue("Joe!");
assertEquals("Hello, Joe!", getValue(combined)); assertEquals("Hello, Joe!", observeAndGetOneValue(combined));
} }
@Test @Test
@ -104,7 +104,7 @@ public final class LiveDataUtilTest {
liveDataA.setValue("Echo! "); liveDataA.setValue("Echo! ");
assertEquals("Echo! Echo! ", getValue(combined)); assertEquals("Echo! Echo! ", observeAndGetOneValue(combined));
} }
@Test @Test
@ -118,7 +118,7 @@ public final class LiveDataUtilTest {
liveDataB.setValue("World!"); liveDataB.setValue("World!");
assertEquals("Hello, World!", getValue(combined)); assertEquals("Hello, World!", observeAndGetOneValue(combined));
} }
@Test @Test
@ -128,6 +128,6 @@ public final class LiveDataUtilTest {
LiveData<Integer> combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a * b); LiveData<Integer> combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a * b);
assertEquals(Integer.valueOf(300), getValue(combined)); assertEquals(Integer.valueOf(300), observeAndGetOneValue(combined));
} }
} }

View File

@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.util.livedata;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.assertNoValue;
import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.observeAndGetOneValue;
public final class LiveDataUtilTest_merge {
@Rule
public TestRule rule = new LiveDataRule();
@Test
public void merge_nothing() {
LiveData<String> combined = LiveDataUtil.merge(Collections.emptyList());
assertNoValue(combined);
}
@Test
public void merge_one_is_a_no_op() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Collections.singletonList(liveDataA));
assertSame(liveDataA, combined);
}
@Test
public void initially_no_value() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB));
assertNoValue(combined);
}
@Test
public void value_on_first() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB));
liveDataA.setValue("A");
assertEquals("A", observeAndGetOneValue(combined));
}
@Test
public void value_on_second() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB));
liveDataB.setValue("B");
assertEquals("B", observeAndGetOneValue(combined));
}
@Test
public void value_on_third() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
MutableLiveData<String> liveDataC = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB, liveDataC));
liveDataC.setValue("C");
assertEquals("C", observeAndGetOneValue(combined));
}
@Test
public void several_values_merged() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
MutableLiveData<String> liveDataC = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB, liveDataC));
liveDataC.setValue("C");
assertEquals("C", observeAndGetOneValue(combined));
liveDataA.setValue("A");
assertEquals("A", observeAndGetOneValue(combined));
liveDataB.setValue("B");
assertEquals("B", observeAndGetOneValue(combined));
}
@Test
public void combined_same_instance() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataA));
liveDataA.setValue("Echo! ");
assertSame(liveDataA, combined);
}
@Test
public void combined_same_instances_repeated() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
MutableLiveData<String> liveDataC = new MutableLiveData<>();
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB, liveDataC, liveDataA, liveDataB, liveDataC));
liveDataC.setValue("C");
assertEquals("C", observeAndGetOneValue(combined));
liveDataA.setValue("A");
assertEquals("A", observeAndGetOneValue(combined));
liveDataB.setValue("B");
assertEquals("B", observeAndGetOneValue(combined));
}
@Test
public void on_a_set_before_combine() {
MutableLiveData<String> liveDataA = new MutableLiveData<>();
MutableLiveData<String> liveDataB = new MutableLiveData<>();
liveDataA.setValue("A");
LiveData<String> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB));
assertEquals("A", observeAndGetOneValue(combined));
}
@Test
public void on_default_values() {
MutableLiveData<Integer> liveDataA = new DefaultValueLiveData<>(10);
MutableLiveData<Integer> liveDataB = new DefaultValueLiveData<>(30);
LiveData<Integer> combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB));
assertEquals(Integer.valueOf(30), observeAndGetOneValue(combined));
}
}

View File

@ -63,6 +63,11 @@ public final class UuidUtil {
return parseOrNull(bytes.toByteArray()); return parseOrNull(bytes.toByteArray());
} }
public static UUID fromByteStringOrUnknown(ByteString bytes) {
UUID uuid = fromByteStringOrNull(bytes);
return uuid != null ? uuid : UNKNOWN_UUID;
}
private static UUID parseOrNull(byte[] byteArray) { private static UUID parseOrNull(byte[] byteArray) {
return byteArray != null && byteArray.length == 16 ? parseOrThrow(byteArray) : null; return byteArray != null && byteArray.length == 16 ? parseOrThrow(byteArray) : null;
} }