From 4c5822ac67e86883c085307321ef0d3dee856512 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Thu, 14 May 2020 13:59:34 -0300 Subject: [PATCH] GV2 Update message description. --- .../model/GroupsV2UpdateMessageProducer.java | 77 +++++++++++++----- .../database/model/MessageRecord.java | 67 +++++++++++++++- .../GroupsV2UpdateMessageProducerTest.java | 79 +++++++++++++++++++ 3 files changed, 200 insertions(+), 23 deletions(-) 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;