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 45ad4fb648..9fcff9449f 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
@@ -8,6 +8,7 @@ import com.google.protobuf.ByteString;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.Member;
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
@@ -16,6 +17,8 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemov
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.LinkedList;
@@ -26,18 +29,50 @@ final class GroupsV2UpdateMessageProducer {
@NonNull private final Context context;
@NonNull private final DescribeMemberStrategy descriptionStrategy;
- @NonNull private final ByteString youUuid;
+ @NonNull private final UUID selfUuid;
+ @NonNull private final ByteString selfUuidBytes;
/**
* @param descriptionStrategy Strategy for member description.
*/
GroupsV2UpdateMessageProducer(@NonNull Context context,
@NonNull DescribeMemberStrategy descriptionStrategy,
- @NonNull UUID you)
+ @NonNull UUID selfUuid)
{
this.context = context;
this.descriptionStrategy = descriptionStrategy;
- this.youUuid = UuidUtil.toByteString(you);
+ this.selfUuid = selfUuid;
+ this.selfUuidBytes = UuidUtil.toByteString(selfUuid);
+ }
+
+ /**
+ * Describes a group that is new to you, use this when there is no available change record.
+ *
+ * Invitation and groups you create are the most common cases where no change is available.
+ */
+ String 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()));
+ }
+
+ if (group.getVersion() == 0) {
+ Optional foundingMember = DecryptedGroupUtil.firstMember(group.getMembersList());
+ if (foundingMember.isPresent()) {
+ ByteString foundingMemberUuid = foundingMember.get().getUuid();
+ if (selfUuidBytes.equals(foundingMemberUuid)) {
+ return context.getString(R.string.MessageRecord_you_created_the_group);
+ } else {
+ return context.getString(R.string.MessageRecord_s_added_you, describe(foundingMemberUuid));
+ }
+ }
+ }
+
+ if (DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid).isPresent()) {
+ return context.getString(R.string.MessageRecord_you_joined_the_group);
+ } else {
+ return context.getString(R.string.MessageRecord_group_updated);
+ }
}
List describeChange(@NonNull DecryptedGroupChange change) {
@@ -66,7 +101,7 @@ 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) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_updated_group));
@@ -76,10 +111,10 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember member : change.getNewMembersList()) {
- boolean newMemberIsYou = member.getUuid().equals(youUuid);
+ boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes);
if (editorIsYou) {
if (newMemberIsYou) {
@@ -102,10 +137,10 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString member : change.getDeleteMembersList()) {
- boolean newMemberIsYou = member.equals(youUuid);
+ boolean newMemberIsYou = member.equals(selfUuidBytes);
if (editorIsYou) {
if (newMemberIsYou) {
@@ -128,11 +163,11 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) {
if (roleChange.getRole() == Member.Role.ADMINISTRATOR) {
- boolean newMemberIsYou = roleChange.getUuid().equals(youUuid);
+ boolean newMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_made_s_an_admin, describe(roleChange.getUuid())));
} else {
@@ -144,7 +179,7 @@ final class GroupsV2UpdateMessageProducer {
}
}
} else {
- boolean newMemberIsYou = roleChange.getUuid().equals(youUuid);
+ boolean newMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, describe(roleChange.getUuid())));
} else {
@@ -159,11 +194,11 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notYouInviteCount = 0;
for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) {
- boolean newMemberIsYou = invitee.getUuid().equals(youUuid);
+ boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(change.getEditor())));
@@ -182,7 +217,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notDeclineCount = 0;
for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) {
@@ -208,11 +243,11 @@ final class GroupsV2UpdateMessageProducer {
}
private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember newMember : change.getPromotePendingMembersList()) {
ByteString uuid = newMember.getUuid();
- boolean newMemberIsYou = uuid.equals(youUuid);
+ boolean newMemberIsYou = uuid.equals(selfUuidBytes);
if (editorIsYou) {
if (newMemberIsYou) {
@@ -235,7 +270,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTitle()) {
if (editorIsYou) {
@@ -247,7 +282,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewAvatar()) {
if (editorIsYou) {
@@ -259,7 +294,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTimer()) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration());
@@ -272,7 +307,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess());
@@ -285,7 +320,7 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) {
- boolean editorIsYou = change.getEditor().equals(youUuid);
+ boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess());
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 d803f6dcd0..1119c84ecc 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
@@ -17,22 +17,31 @@
package org.thoughtcrime.securesms.database.model;
import android.content.Context;
-import androidx.annotation.NonNull;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
+import androidx.annotation.NonNull;
+
+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;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
+import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
+import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
+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.signalservice.api.util.UuidUtil;
+import java.io.IOException;
import java.util.List;
+import java.util.UUID;
/**
* The base class for message record models that are displayed in
@@ -44,6 +53,8 @@ import java.util.List;
*/
public abstract class MessageRecord extends DisplayRecord {
+ private static final String TAG = Log.tag(MessageRecord.class);
+
private final Recipient individualRecipient;
private final int recipientDeviceId;
private final long id;
@@ -96,7 +107,9 @@ public abstract class MessageRecord extends DisplayRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
- if (isGroupUpdate() && isOutgoing()) {
+ 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()));
@@ -134,6 +147,56 @@ public abstract class MessageRecord extends DisplayRecord {
return new SpannableString(getBody());
}
+ private @NonNull String getGv2Description(@NonNull Context context) {
+ if (!isGroupUpdate() || !isGroupV2()) {
+ throw new AssertionError();
+ }
+ try {
+ ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context);
+ byte[] decoded = Base64.decode(getBody());
+ DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
+ GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
+
+ if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getVersion() > 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();
+ } 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);
+ }
+ }
+
+ /**
+ * Describes a UUID by it's corresponding recipient's {@link Recipient#toShortString}.
+ */
+ private static class ShortStringDescriptionStrategy implements GroupsV2UpdateMessageProducer.DescribeMemberStrategy {
+
+ private final Context context;
+
+ ShortStringDescriptionStrategy(@NonNull Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public @NonNull String describe(@NonNull UUID uuid) {
+ if (UuidUtil.UNKNOWN_UUID.equals(uuid)) {
+ return context.getString(R.string.MessageRecord_unknown);
+ }
+ return Recipient.resolved(RecipientId.from(uuid, null)).toShortString(context);
+ }
+ }
+
public long getId() {
return id;
}
diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java
index 2ac6749223..fcacf954ef 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java
+++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java
@@ -14,6 +14,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.Member;
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
@@ -491,6 +492,84 @@ public final class GroupsV2UpdateMessageProducerTest {
"Alice changed who can edit group membership to \"All members\".")));
}
+ // Group state without a change record
+
+ @Test
+ public void you_created_a_group() {
+ DecryptedGroup group = newGroupBy(you, 0)
+ .build();
+
+ assertThat(producer.describeNewGroup(group), is("You created the group."));
+ }
+
+ @Test
+ public void alice_created_a_group() {
+ DecryptedGroup group = newGroupBy(alice, 0)
+ .member(you)
+ .build();
+
+ assertThat(producer.describeNewGroup(group), is("Alice added you to the group."));
+ }
+
+ @Test
+ public void alice_created_a_group_above_zero() {
+ DecryptedGroup group = newGroupBy(alice, 1)
+ .member(you)
+ .build();
+
+ assertThat(producer.describeNewGroup(group), is("You joined the group."));
+ }
+
+ @Test
+ public void you_were_invited_to_a_group() {
+ DecryptedGroup group = newGroupBy(alice, 0)
+ .invite(bob, you)
+ .build();
+
+ assertThat(producer.describeNewGroup(group), is("Bob invited you to the group."));
+ }
+
+ @Test
+ public void describe_a_group_you_are_not_in() {
+ DecryptedGroup group = newGroupBy(alice, 1)
+ .build();
+
+ assertThat(producer.describeNewGroup(group), is("Group updated."));
+ }
+
+ private GroupStateBuilder newGroupBy(UUID foundingMember, int revision) {
+ return new GroupStateBuilder(foundingMember, revision);
+ }
+
+ private static class GroupStateBuilder {
+
+ private final DecryptedGroup.Builder builder;
+
+ GroupStateBuilder(@NonNull UUID foundingMember, int version) {
+ builder = DecryptedGroup.newBuilder()
+ .setVersion(version)
+ .addMembers(DecryptedMember.newBuilder()
+ .setUuid(UuidUtil.toByteString(foundingMember)));
+ }
+
+ GroupStateBuilder invite(@NonNull UUID inviter, @NonNull UUID invitee) {
+ builder.addPendingMembers(DecryptedPendingMember.newBuilder()
+ .setUuid(UuidUtil.toByteString(invitee))
+ .setAddedByUuid(UuidUtil.toByteString(inviter)));
+ return this;
+ }
+
+ GroupStateBuilder member(@NonNull UUID member) {
+ builder.addMembers(DecryptedMember.newBuilder()
+ .setUuid(UuidUtil.toByteString(member)));
+ return this;
+ }
+
+ public DecryptedGroup build() {
+ return builder.build();
+ }
+ }
+
private static class ChangeBuilder {
private final DecryptedGroupChange.Builder builder;