diff --git a/app/build.gradle b/app/build.gradle index 525ebe175..2d9fc5584 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -359,10 +359,10 @@ dependencies { testImplementation 'junit:junit:4.12' testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.mockito:mockito-core:1.9.5' - testImplementation 'org.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' + testImplementation 'org.powermock:powermock-api-mockito:1.6.5' + testImplementation 'org.powermock:powermock-module-junit4:1.6.5' + testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.5' + testImplementation 'org.powermock:powermock-classloading-xstream:1.6.5' testImplementation 'androidx.test:core:1.2.0' testImplementation ('org.robolectric:robolectric:4.2') { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 36bf9c87e..0b11cb8dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -4,6 +4,7 @@ import android.content.Context; import android.graphics.ColorFilter; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; +import android.text.SpannableString; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; @@ -13,43 +14,54 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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.R; import org.thoughtcrime.securesms.VerifyIdentityActivity; 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.UpdateDescription; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.ExpirationUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.whispersystems.libsignal.util.guava.Optional; import java.util.Locale; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ExecutionException; -public class ConversationUpdateItem extends LinearLayout - implements RecipientForeverObserver, BindableConversationItem +public final class ConversationUpdateItem extends LinearLayout + implements RecipientForeverObserver, + BindableConversationItem, + Observer { private static final String TAG = ConversationUpdateItem.class.getSimpleName(); private Set batchSelected; - private ImageView icon; - private TextView title; - private TextView body; - private TextView date; - private LiveRecipient sender; - private MessageRecord messageRecord; - private Locale locale; + private ImageView icon; + private TextView title; + private TextView body; + private TextView date; + private LiveRecipient sender; + private MessageRecord messageRecord; + private Locale locale; + private LiveData displayBody; + + private final Debouncer bodyClearDebouncer = new Debouncer(150); public ConversationUpdateItem(Context context) { super(context); @@ -108,9 +120,8 @@ public class ConversationUpdateItem extends LinearLayout this.sender.removeForeverObserver(this); } - if (this.messageRecord != null && messageRecord.isGroupAction()) { - GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this); - } + observeDisplayBody(null); + setBodyText(null); this.messageRecord = messageRecord; this.sender = messageRecord.getIndividualRecipient().live(); @@ -118,23 +129,49 @@ public class ConversationUpdateItem extends LinearLayout this.sender.observeForever(this); - if (this.messageRecord != null && messageRecord.isGroupAction()) { - GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).addObserver(this); - } + UpdateDescription updateDescription = Objects.requireNonNull(messageRecord.getUpdateDisplayBody(getContext())); + LiveData liveUpdateMessage = LiveUpdateMessage.fromMessageDescription(updateDescription); + LiveData spannableStringMessage = Transformations.map(liveUpdateMessage, SpannableString::new); present(messageRecord); + + observeDisplayBody(spannableStringMessage); + } + + private void observeDisplayBody(@Nullable LiveData 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) { - if (messageRecord.isGroupAction()) setGroupRecord(messageRecord); + if (messageRecord.isGroupAction()) setGroupRecord(); 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.isEndSession()) setEndSessionRecord(messageRecord); - else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord); + else if (messageRecord.isEndSession()) setEndSessionRecord(); + else if (messageRecord.isIdentityUpdate()) setIdentityRecord(); else if (messageRecord.isIdentityVerified() || 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."); 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 icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp); - body.setText(messageRecord.getDisplayBody(getContext())); date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived())); title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(View.VISIBLE); } @@ -163,10 +198,8 @@ public class ConversationUpdateItem extends LinearLayout icon.setColorFilter(getIconTintFilter()); title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000))); - body.setText(messageRecord.getDisplayBody(getContext())); title.setVisibility(VISIBLE); - body.setVisibility(VISIBLE); 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); } - private void setIdentityRecord(final MessageRecord messageRecord) { + private void setIdentityRecord() { icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.safety_number_icon)); icon.setColorFilter(getIconTintFilter()); - body.setText(messageRecord.getDisplayBody(getContext())); title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(GONE); } @@ -189,54 +220,40 @@ public class ConversationUpdateItem extends LinearLayout else icon.setImageResource(R.drawable.ic_info_outline_white_24dp); icon.setColorFilter(getIconTintFilter()); - body.setText(messageRecord.getDisplayBody(getContext())); title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(GONE); } - private void setProfileNameChangeRecord(MessageRecord messageRecord) { + private void setProfileNameChangeRecord() { icon.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_outline_20)); icon.setColorFilter(getIconTintFilter()); - body.setText(messageRecord.getDisplayBody(getContext())); title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(GONE); } - private void setGroupRecord(MessageRecord messageRecord) { + private void setGroupRecord() { icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.menu_group_icon)); icon.clearColorFilter(); - body.setText(messageRecord.getDisplayBody(getContext())); - title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(GONE); } - private void setJoinedRecord(MessageRecord messageRecord) { + private void setJoinedRecord() { icon.setImageResource(R.drawable.ic_favorite_grey600_24dp); icon.clearColorFilter(); - body.setText(messageRecord.getDisplayBody(getContext())); title.setVisibility(GONE); - body.setVisibility(VISIBLE); date.setVisibility(GONE); } - private void setEndSessionRecord(MessageRecord messageRecord) { + private void setEndSessionRecord() { icon.setImageResource(R.drawable.ic_refresh_white_24dp); icon.setColorFilter(getIconTintFilter()); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); } - + @Override public void onRecipientChanged(@NonNull Recipient recipient) { present(messageRecord); @@ -252,9 +269,13 @@ public class ConversationUpdateItem extends LinearLayout if (sender != null) { 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index c8bb1e84d..ad01b87f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -21,7 +21,6 @@ import android.content.res.ColorStateList; import android.graphics.Typeface; import android.graphics.drawable.RippleDrawable; import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.text.Spannable; import android.text.SpannableString; import android.text.style.StyleSpan; @@ -32,6 +31,9 @@ import android.widget.TextView; import androidx.annotation.NonNull; 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.R; @@ -42,41 +44,49 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView; import org.thoughtcrime.securesms.components.FromTextView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.TypingIndicatorView; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; 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.UpdateDescription; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; -import org.thoughtcrime.securesms.conversationlist.model.MessageResult; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.SearchUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import java.util.Collections; import java.util.Locale; import java.util.Set; -public class ConversationListItem extends RelativeLayout - implements RecipientForeverObserver, - BindableConversationListItem, Unbindable +import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync; + +public final class ConversationListItem extends RelativeLayout + implements RecipientForeverObserver, + BindableConversationListItem, + Unbindable, + Observer { @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 LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); - private static final int MAX_SNIPPET_LENGTH = 500; - private Set selectedThreads; + private Set typingThreads; private LiveRecipient recipient; - private LiveRecipient groupAddedBy; private long threadId; private GlideRequests glideRequests; private View subjectContainer; @@ -96,13 +106,9 @@ public class ConversationListItem extends RelativeLayout private AvatarImageView contactPhotoImage; private ThumbnailView thumbnailView; - private int distributionType; + private final Debouncer subjectViewClearDebouncer = new Debouncer(150); - private final RecipientForeverObserver groupAddedByObserver = adder -> { - if (isAttachedToWindow() && subjectView != null && thread != null) { - subjectView.setText(getThreadDisplayBody(getContext(), thread)); - } - }; + private LiveData displayBody; public ConversationListItem(Context context) { this(context, null); @@ -152,16 +158,16 @@ public class ConversationListItem extends RelativeLayout @Nullable String highlightSubstring) { if (this.recipient != null) this.recipient.removeForeverObserver(this); - if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); + observeDisplayBody(null); + setSubjectViewText(null); - this.selectedThreads = selectedThreads; - this.recipient = thread.getRecipient().live(); - this.threadId = thread.getThreadId(); - this.glideRequests = glideRequests; - this.unreadCount = thread.getUnreadCount(); - this.distributionType = thread.getDistributionType(); - this.lastSeen = thread.getLastSeen(); - this.thread = thread; + this.selectedThreads = selectedThreads; + this.recipient = thread.getRecipient().live(); + this.threadId = thread.getThreadId(); + this.glideRequests = glideRequests; + this.unreadCount = thread.getUnreadCount(); + this.lastSeen = thread.getLastSeen(); + this.thread = thread; this.recipient.observeForever(this); if (highlightSubstring != null) { @@ -172,14 +178,10 @@ public class ConversationListItem extends RelativeLayout this.fromView.setText(recipient.get(), thread.isRead()); } + this.typingThreads = typingThreads; updateTypingIndicator(typingThreads); - this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread))); - - if (thread.getGroupAddedBy() != null) { - groupAddedBy = Recipient.live(thread.getGroupAddedBy()); - groupAddedBy.observeForever(groupAddedByObserver); - } + observeDisplayBody(getThreadDisplayBody(getContext(), thread)); this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); 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) { 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.recipient = contact.live(); @@ -242,7 +245,8 @@ public class ConversationListItem extends RelativeLayout @Nullable String highlightSubstring) { 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.recipient = messageResult.conversationRecipient.live(); @@ -274,10 +278,7 @@ public class ConversationListItem extends RelativeLayout contactPhotoImage.setAvatar(glideRequests, null, !batchMode); } - if (this.groupAddedBy != null) { - this.groupAddedBy.removeForeverObserver(groupAddedByObserver); - this.groupAddedBy = null; - } + observeDisplayBody(null); } @Override @@ -317,17 +318,30 @@ public class ConversationListItem extends RelativeLayout return unreadCount; } - public int getDistributionType() { - return distributionType; - } - public long getLastSeen() { return lastSeen; } - private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) { - return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet - : snippet.subSequence(0, MAX_SNIPPET_LENGTH); + private void observeDisplayBody(@Nullable LiveData displayBody) { + if (this.displayBody != null) { + 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) { @@ -371,7 +385,7 @@ public class ConversationListItem extends RelativeLayout } private void setRippleColor(Recipient recipient) { - if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + if (VERSION.SDK_INT >= 21) { ((RippleDrawable)(getBackground()).mutate()) .setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext()))); } @@ -394,16 +408,20 @@ public class ConversationListItem extends RelativeLayout setRippleColor(recipient); } - - private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) { + private static @NonNull LiveData getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) { if (thread.getGroupAddedBy() != null) { - return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group - : R.string.ThreadRecord_s_added_you_to_the_group, - Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context))); + 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.getDisplayName(context)))); } else if (!thread.isMessageRequestAccepted()) { return emphasisAdded(context.getString(R.string.ThreadRecord_message_request)); } else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) { - return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); + if (thread.getRecipient().isPushV2Group()) { + return emphasisAdded(MessageRecord.getGv2ChangeDescription(context, thread.getBody())); + } else { + return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); + } } else if (SmsDatabase.Types.isGroupQuit(thread.getType())) { return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group)); } 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)); } else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) { 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())) { - 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())) { - 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())) { - 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())) { - 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())) { int seconds = (int)(thread.getExpiresIn() / 1000); if (seconds <= 0) { @@ -438,7 +456,7 @@ public class ConversationListItem extends RelativeLayout if (thread.getRecipient().isGroup()) { return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed)); } 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())) { return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified)); @@ -449,11 +467,11 @@ public class ConversationListItem extends RelativeLayout } else { ThreadDatabase.Extra extra = thread.getExtra(); 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()) { - 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 { - 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) { - return emphasisAdded(sequence, 0, sequence.length()); + private static @NonNull LiveData emphasisAdded(@NonNull String string) { + return emphasisAdded(UpdateDescription.staticDescription(string)); } - private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) { - SpannableString spannable = new SpannableString(sequence); - spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), - start, - end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - return spannable; + private static @NonNull LiveData emphasisAdded(@NonNull UpdateDescription description) { + return emphasisAdded(LiveUpdateMessage.fromMessageDescription(description)); + } + + private static @NonNull LiveData emphasisAdded(@NonNull LiveData description) { + return Transformations.map(description, sequence -> { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), + 0, + sequence.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + }); } 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 final View thumbnailView; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index a4f25c1cd..98532eba9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; 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.util.UuidUtil; +import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.UUID; @@ -37,8 +40,7 @@ final class GroupsV2UpdateMessageProducer { */ GroupsV2UpdateMessageProducer(@NonNull Context context, @NonNull DescribeMemberStrategy descriptionStrategy, - @NonNull UUID selfUuid) - { + @NonNull UUID selfUuid) { this.context = context; this.descriptionStrategy = descriptionStrategy; this.selfUuid = selfUuid; @@ -50,10 +52,10 @@ final class GroupsV2UpdateMessageProducer { *

* 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 selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid); 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) { @@ -61,22 +63,22 @@ final class GroupsV2UpdateMessageProducer { if (foundingMember.isPresent()) { ByteString foundingMemberUuid = foundingMember.get().getUuid(); 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 { - 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()) { - return context.getString(R.string.MessageRecord_you_joined_the_group); + return updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)); } else { - return context.getString(R.string.MessageRecord_group_updated); + return updateDescription(context.getString(R.string.MessageRecord_group_updated)); } } - List describeChange(@NonNull DecryptedGroupChange change) { - List updates = new LinkedList<>(); + List describeChanges(@NonNull DecryptedGroupChange change) { + List updates = new LinkedList<>(); if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) { describeUnknownEditorMemberAdditions(change, updates); @@ -119,21 +121,21 @@ final class GroupsV2UpdateMessageProducer { /** * Handles case of future protocol versions where we don't know what has changed. */ - private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List updates) { + private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (editorIsYou) { - updates.add(context.getString(R.string.MessageRecord_you_updated_group)); + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_updated_group))); } 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 updates) { - updates.add(context.getString(R.string.MessageRecord_the_group_was_updated)); + private void describeUnknownEditorUnknownChange(@NonNull List updates) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_updated))); } - private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { + private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); for (DecryptedMember member : change.getNewMembersList()) { @@ -141,37 +143,37 @@ final class GroupsV2UpdateMessageProducer { if (editorIsYou) { 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 { - 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 { 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 { 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 { - 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 updates) { + private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { for (DecryptedMember member : change.getNewMembersList()) { boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes); 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 { - 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 updates) { + private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); for (ByteString member : change.getDeleteMembersList()) { @@ -179,98 +181,98 @@ final class GroupsV2UpdateMessageProducer { if (editorIsYou) { 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 { - 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 { 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 { 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 { - 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 updates) { + private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { for (ByteString member : change.getDeleteMembersList()) { boolean removedMemberIsYou = member.equals(selfUuidBytes); 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 { - 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 updates) { + private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { 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 { 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 { - 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 { 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 { 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 { - 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 updates) { + private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) { for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { 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 { - 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 { 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 { - 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 updates) { - boolean editorIsYou = change.getEditor().equals(selfUuidBytes); - int notYouInviteCount = 0; + private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + int notYouInviteCount = 0; for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); 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 { 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 { notYouInviteCount++; } @@ -278,39 +280,40 @@ final class GroupsV2UpdateMessageProducer { } 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 updates) { + private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { int notYouInviteCount = 0; for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); 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 { notYouInviteCount++; } } 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 updates) { - boolean editorIsYou = change.getEditor().equals(selfUuidBytes); - int notDeclineCount = 0; + private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + int notDeclineCount = 0; for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) { boolean decline = invitee.getUuid().equals(change.getEditor()); if (decline) { 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 { - 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 { notDeclineCount++; @@ -319,176 +322,201 @@ final class GroupsV2UpdateMessageProducer { if (notDeclineCount > 0) { 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 { - 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 updates) { + private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { int notDeclineCount = 0; for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) { boolean inviteeWasYou = invitee.getUuid().equals(selfUuidBytes); 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 { notDeclineCount++; } } 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 updates) { + private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); for (DecryptedMember newMember : change.getPromotePendingMembersList()) { - ByteString uuid = newMember.getUuid(); - boolean newMemberIsYou = uuid.equals(selfUuidBytes); + ByteString uuid = newMember.getUuid(); + boolean newMemberIsYou = uuid.equals(selfUuidBytes); if (editorIsYou) { if (newMemberIsYou) { - updates.add(context.getString(R.string.MessageRecord_you_accepted_invite)); + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_accepted_invite))); } 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 { 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 { 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 { - 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 updates) { + private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { for (DecryptedMember newMember : change.getPromotePendingMembersList()) { - ByteString uuid = newMember.getUuid(); - boolean newMemberIsYou = uuid.equals(selfUuidBytes); + ByteString uuid = newMember.getUuid(); + boolean newMemberIsYou = uuid.equals(selfUuidBytes); 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 { - 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 updates) { + private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.hasNewTitle()) { + String newTitle = change.getNewTitle().getValue(); 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 { - 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 updates) { + private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { 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 updates) { + private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.hasNewAvatar()) { 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 { - 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 updates) { + private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { 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 updates) { + private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.hasNewTimer()) { String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration()); 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 { - 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 updates) { + private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { if (change.hasNewTimer()) { 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 updates) { + private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess()); 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 { - 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 updates) { + private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { 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 updates) { + private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess()); 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 { - 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 updates) { + private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { 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 { /** * 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))); } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java new file mode 100644 index 000000000..ea6ee48ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java @@ -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 fromMessageDescription(@NonNull UpdateDescription updateDescription) { + if (updateDescription.isStringStatic()) { + return LiveDataUtil.just(updateDescription.getStaticString()); + } + + List> 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 recipientToStringAsync(@NonNull RecipientId recipientId, + @NonNull Function createStringInBackground) + { + return LiveDataUtil.mapAsync(Recipient.live(recipientId).getLiveData(), createStringInBackground); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index c5b40e0a1..cb484abc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -23,8 +23,8 @@ import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; 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.database.MmsSmsColumns; 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.ExpirationUtil; import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Function; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.UUID; @@ -109,78 +111,84 @@ public abstract class MessageRecord extends DisplayRecord { @Override public SpannableString getDisplayBody(@NonNull Context context) { - if (isGroupUpdate() && isGroupV2()) { - return new SpannableString(getGv2Description(context)); - } else if (isGroupUpdate() && isOutgoing()) { - return new SpannableString(context.getString(R.string.MessageRecord_you_updated_group)); - } 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)); + UpdateDescription updateDisplayBody = getUpdateDisplayBody(context); + + if (updateDisplayBody != null) { + return new SpannableString(updateDisplayBody.getString()); } return new SpannableString(getBody()); } - private @NonNull String getGv2Description(@NonNull Context context) { - if (!isGroupUpdate() || !isGroupV2()) { - throw new AssertionError(); + public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context) { + if (isGroupUpdate() && isGroupV2()) { + 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 { ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context); - byte[] decoded = Base64.decode(getBody()); + byte[] decoded = Base64.decode(body); DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded); GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get()); if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) { - DecryptedGroupChange change = decryptedGroupV2Context.getChange(); - List 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(); + return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange())); } else { return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState()); } } catch (IOException 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 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) { try { byte[] decoded = Base64.decode(getBody()); @@ -316,7 +324,7 @@ public abstract class MessageRecord extends DisplayRecord { return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure()); } - protected SpannableString emphasisAdded(String sequence) { + protected static SpannableString emphasisAdded(String sequence) { SpannableString spannable = new SpannableString(sequence); 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java new file mode 100644 index 000000000..8d86a1d5f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java @@ -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 mentioned; + private final StringFactory stringFactory; + private final String staticString; + + private UpdateDescription(@NonNull Collection 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 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 getMentioned() { + return mentioned; + } + + public static UpdateDescription concatWithNewLines(@NonNull List 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 allMentioned = new HashSet<>(); + + for (UpdateDescription updateDescription : updateDescriptions) { + allMentioned.addAll(updateDescription.getMentioned()); + } + + return UpdateDescription.mentioning(allMentioned, () -> concatLines(updateDescriptions)); + } + + private static boolean allAreStatic(@NonNull Collection updateDescriptions) { + for (UpdateDescription description : updateDescriptions) { + if (!description.isStringStatic()) { + return false; + } + } + + return true; + } + + @WorkerThread + private static String concatLines(@NonNull List 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 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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 419b2ed8e..eb90f45ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -67,13 +66,13 @@ public final class GroupUtil { 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) { return new GroupDescription(context, null); } try { - MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, isV2); + MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, false); return new GroupDescription(context, groupContext); } catch (IOException 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(); description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.getDisplayName(context))); @@ -142,22 +142,6 @@ public final class GroupUtil { 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 recipients) { StringBuilder result = new StringBuilder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index b9482e870..8e949ea2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -11,6 +11,10 @@ import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; 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; public final class LiveDataUtil { @@ -78,6 +82,34 @@ public final class LiveDataUtil { return new CombineLiveData<>(a, b, combine); } + /** + * Merges the supplied live data streams. + */ + public static LiveData merge(@NonNull List> liveDataList) { + Set> set = new LinkedHashSet<>(liveDataList); + + set.addAll(liveDataList); + + if (set.size() == 1) { + return liveDataList.get(0); + } + + MediatorLiveData mergedLiveData = new MediatorLiveData<>(); + + for (LiveData liveDataSource : set) { + mergedLiveData.addSource(liveDataSource, mergedLiveData::setValue); + } + + return mergedLiveData; + } + + /** + * @return Live data with just the initial value. + */ + public static LiveData just(@NonNull T item) { + return new MutableLiveData<>(item); + } + public interface Combine { @NonNull R apply(@NonNull A a, @NonNull B b); } diff --git a/app/src/main/res/layout/conversation_item_update.xml b/app/src/main/res/layout/conversation_item_update.xml index c64310db9..d366f0879 100644 --- a/app/src/main/res/layout/conversation_item_update.xml +++ b/app/src/main/res/layout/conversation_item_update.xml @@ -41,7 +41,7 @@ describeChange(@NonNull DecryptedGroupChange change) { + MainThreadUtil.setMainThread(false); + return Stream.of(producer.describeChanges(change)) + .map(UpdateDescription::getString) + .toList(); } - private GroupStateBuilder newGroupBy(UUID foundingMember, int revision) { + 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); } + private void assertSingleChangeMentioning(DecryptedGroupChange change, List expectedMentions) { + List 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 final DecryptedGroup.Builder builder; diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/UpdateDescriptionTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/model/UpdateDescriptionTest.java new file mode 100644 index 000000000..224dab9f9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/UpdateDescriptionTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/testutil/MainThreadUtil.java b/app/src/test/java/org/thoughtcrime/securesms/testutil/MainThreadUtil.java new file mode 100644 index 000000000..a83dd0682 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/testutil/MainThreadUtil.java @@ -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. + *

+ * 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(); + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java index f7515efc8..47ef23e35 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java @@ -14,7 +14,7 @@ public final class LiveDataTestUtil { *

* This will therefore only work in conjunction with {@link LiveDataRule}. */ - public static T getValue(final LiveData liveData) { + public static T observeAndGetOneValue(final LiveData liveData) { AtomicReference data = new AtomicReference<>(); Observer observer = data::set; diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_combineLatest.java similarity index 84% rename from app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java rename to app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_combineLatest.java index 01c726eab..11b7a8cd2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_combineLatest.java @@ -10,9 +10,9 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData; import static org.junit.Assert.assertEquals; 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 public TestRule rule = new LiveDataRule(); @@ -61,7 +61,7 @@ public final class LiveDataUtilTest { liveDataA.setValue("Hello, "); liveDataB.setValue("World!"); - assertEquals("Hello, World!", getValue(combined)); + assertEquals("Hello, World!", observeAndGetOneValue(combined)); } @Test @@ -74,10 +74,10 @@ public final class LiveDataUtilTest { liveDataA.setValue("Hello, "); liveDataB.setValue("World!"); - assertEquals("Hello, World!", getValue(combined)); + assertEquals("Hello, World!", observeAndGetOneValue(combined)); liveDataA.setValue("Welcome, "); - assertEquals("Welcome, World!", getValue(combined)); + assertEquals("Welcome, World!", observeAndGetOneValue(combined)); } @Test @@ -90,10 +90,10 @@ public final class LiveDataUtilTest { liveDataA.setValue("Hello, "); liveDataB.setValue("World!"); - assertEquals("Hello, World!", getValue(combined)); + assertEquals("Hello, World!", observeAndGetOneValue(combined)); liveDataB.setValue("Joe!"); - assertEquals("Hello, Joe!", getValue(combined)); + assertEquals("Hello, Joe!", observeAndGetOneValue(combined)); } @Test @@ -104,7 +104,7 @@ public final class LiveDataUtilTest { liveDataA.setValue("Echo! "); - assertEquals("Echo! Echo! ", getValue(combined)); + assertEquals("Echo! Echo! ", observeAndGetOneValue(combined)); } @Test @@ -118,7 +118,7 @@ public final class LiveDataUtilTest { liveDataB.setValue("World!"); - assertEquals("Hello, World!", getValue(combined)); + assertEquals("Hello, World!", observeAndGetOneValue(combined)); } @Test @@ -128,6 +128,6 @@ public final class LiveDataUtilTest { LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a * b); - assertEquals(Integer.valueOf(300), getValue(combined)); + assertEquals(Integer.valueOf(300), observeAndGetOneValue(combined)); } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_merge.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_merge.java new file mode 100644 index 000000000..dc4201d84 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest_merge.java @@ -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 combined = LiveDataUtil.merge(Collections.emptyList()); + + assertNoValue(combined); + } + + @Test + public void merge_one_is_a_no_op() { + MutableLiveData liveDataA = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Collections.singletonList(liveDataA)); + + assertSame(liveDataA, combined); + } + + @Test + public void initially_no_value() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB)); + + assertNoValue(combined); + } + + @Test + public void value_on_first() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB)); + + liveDataA.setValue("A"); + + assertEquals("A", observeAndGetOneValue(combined)); + } + + @Test + public void value_on_second() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB)); + + liveDataB.setValue("B"); + + assertEquals("B", observeAndGetOneValue(combined)); + } + + @Test + public void value_on_third() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + MutableLiveData liveDataC = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB, liveDataC)); + + liveDataC.setValue("C"); + + assertEquals("C", observeAndGetOneValue(combined)); + } + + @Test + public void several_values_merged() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + MutableLiveData liveDataC = new MutableLiveData<>(); + + LiveData 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 liveDataA = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataA)); + + liveDataA.setValue("Echo! "); + + assertSame(liveDataA, combined); + } + + @Test + public void combined_same_instances_repeated() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + MutableLiveData liveDataC = new MutableLiveData<>(); + + LiveData 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 liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + liveDataA.setValue("A"); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB)); + + assertEquals("A", observeAndGetOneValue(combined)); + } + + @Test + public void on_default_values() { + MutableLiveData liveDataA = new DefaultValueLiveData<>(10); + MutableLiveData liveDataB = new DefaultValueLiveData<>(30); + + LiveData combined = LiveDataUtil.merge(Arrays.asList(liveDataA, liveDataB)); + + assertEquals(Integer.valueOf(30), observeAndGetOneValue(combined)); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java index 89d634fb9..8abc29479 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java @@ -63,6 +63,11 @@ public final class UuidUtil { 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) { return byteArray != null && byteArray.length == 16 ? parseOrThrow(byteArray) : null; }