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 new file mode 100644 index 0000000000..2ba161f0fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -0,0 +1,310 @@ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; + +import androidx.annotation.NonNull; + +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.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +final class GroupsV2UpdateMessageProducer { + + @NonNull private final Context context; + @NonNull private final DescribeMemberStrategy descriptionStrategy; + @NonNull private final ByteString youUuid; + + /** + * @param descriptionStrategy Strategy for member description. + */ + GroupsV2UpdateMessageProducer(@NonNull Context context, + @NonNull DescribeMemberStrategy descriptionStrategy, + @NonNull UUID you) + { + this.context = context; + this.descriptionStrategy = descriptionStrategy; + this.youUuid = UuidUtil.toByteString(you); + } + + List describeChange(@NonNull DecryptedGroupChange change) { + List updates = new LinkedList<>(); + + describeMemberAdditions(change, updates); + describeMemberRemovals(change, updates); + describeModifyMemberRoles(change, updates); + describeInvitations(change, updates); + describeRevokedInvitations(change, updates); + describePromotePending(change, updates); + describeNewTitle(change, updates); + describeNewAvatar(change, updates); + describeNewTimer(change, updates); + describeNewAttributeAccess(change, updates); + describeNewMembershipAccess(change, updates); + + if (updates.isEmpty()) { + describeUnknownChange(change, updates); + } + + return updates; + } + + /** + * 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); + + if (editorIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_updated_group)); + } else { + updates.add(context.getString(R.string.MessageRecord_s_updated_group, describe(change.getEditor()))); + } + } + + private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + for (DecryptedMember member : change.getNewMembersList()) { + boolean newMemberIsYou = member.getUuid().equals(youUuid); + + if (editorIsYou) { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_joined_the_group)); + } else { + updates.add(context.getString(R.string.MessageRecord_you_added_s, describe(member.getUuid()))); + } + } else { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor()))); + } else { + if (member.getUuid().equals(change.getEditor())) { + updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(member.getUuid()))); + } else { + updates.add(context.getString(R.string.MessageRecord_s_added_s, describe(change.getEditor()), describe(member.getUuid()))); + } + } + } + } + } + + private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + for (ByteString member : change.getDeleteMembersList()) { + boolean newMemberIsYou = member.equals(youUuid); + + if (editorIsYou) { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_left_the_group)); + } else { + updates.add(context.getString(R.string.MessageRecord_you_removed_s, describe(member))); + } + } else { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_removed_you_from_the_group, describe(change.getEditor()))); + } else { + if (member.equals(change.getEditor())) { + updates.add(context.getString(R.string.MessageRecord_s_left_the_group, describe(member))); + } else { + updates.add(context.getString(R.string.MessageRecord_s_removed_s, describe(change.getEditor()), describe(member))); + } + } + } + } + } + + private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { + if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { + boolean newMemberIsYou = roleChange.getUuid().equals(youUuid); + if (editorIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_made_s_an_admin, describe(roleChange.getUuid()))); + } else { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_made_you_an_admin, describe(change.getEditor()))); + } else { + updates.add(context.getString(R.string.MessageRecord_s_made_s_an_admin, describe(change.getEditor()), describe(roleChange.getUuid()))); + + } + } + } else { + boolean newMemberIsYou = roleChange.getUuid().equals(youUuid); + if (editorIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, describe(roleChange.getUuid()))); + } else { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, describe(change.getEditor()))); + } else { + updates.add(context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, describe(change.getEditor()), describe(roleChange.getUuid()))); + } + } + } + } + } + + private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + int notYouInviteCount = 0; + + for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { + boolean newMemberIsYou = invitee.getUuid().equals(youUuid); + + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(change.getEditor()))); + } else { + if (editorIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_invited_s_to_the_group, describe(invitee.getUuid()))); + } else { + notYouInviteCount++; + } + } + } + + if (notYouInviteCount > 0) { + updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCount, describe(change.getEditor()), notYouInviteCount)); + } + } + + private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + 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)); + } else { + updates.add(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group)); + } + } else { + notDeclineCount++; + } + } + + if (notDeclineCount > 0) { + if (editorIsYou) { + updates.add(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)); + } + } + } + + private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + for (ByteString member : change.getPromotePendingMembersList()) { + boolean newMemberIsYou = member.equals(youUuid); + + if (editorIsYou) { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_accepted_invite)); + } else { + updates.add(context.getString(R.string.MessageRecord_you_added_invited_member_s, describe(member))); + } + } else { + if (newMemberIsYou) { + updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor()))); + } else { + if (member.equals(change.getEditor())) { + updates.add(context.getString(R.string.MessageRecord_s_accepted_invite, describe(member))); + } else { + updates.add(context.getString(R.string.MessageRecord_s_added_invited_member_s, describe(change.getEditor()), describe(member))); + } + } + } + } + } + + private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + if (change.hasNewTitle()) { + if (editorIsYou) { + updates.add(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, change.getNewTitle().getValue())); + } else { + updates.add(context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, describe(change.getEditor()), change.getNewTitle().getValue())); + } + } + } + + private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + if (change.hasNewAvatar()) { + if (editorIsYou) { + updates.add(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()))); + } + } + } + + private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + 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)); + } else { + updates.add(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, describe(change.getEditor()), time)); + } + } + } + + private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + 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)); + } else { + updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, describe(change.getEditor()), accessLevel)); + } + } + } + + private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(youUuid); + + 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)); + } else { + updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, describe(change.getEditor()), 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); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java new file mode 100644 index 0000000000..38f9277d3c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.storageservice.protos.groups.AccessControl; +import org.thoughtcrime.securesms.R; + +public final class GV2AccessLevelUtil { + + private GV2AccessLevelUtil() { + } + + public static String toString(@NonNull Context context, @NonNull AccessControl.AccessRequired attributeAccess) { + switch (attributeAccess) { + case ANY : return context.getString(R.string.GroupManagement_access_level_anyone); + case MEMBER : return context.getString(R.string.GroupManagement_access_level_all_members); + case ADMINISTRATOR : return context.getString(R.string.GroupManagement_access_level_only_admins); + default : return context.getString(R.string.GroupManagement_access_level_unknown); + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ffed60457..128e8f09e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,6 +442,12 @@ You + + Anyone + All members + Only admins + Unknown + Pending group invites People you invited @@ -614,6 +620,74 @@ %1$s disabled disappearing messages. You set the disappearing message timer to %1$s. %1$s set the disappearing message timer to %2$s. + + + + You added %1$s. + %1$s added %2$s. + %1$s added you to the group. + You joined the group. + %1$s joined the group. + + + You removed %1$s. + %1$s removed %2$s. + %1$s removed you from the group. + You left the group. + %1$s left the group. + + + You made %1$s an admin. + %1$s made %2$s an admin. + %1$s made you an admin. + You revoked admin privileges from %1$s. + %1$s revoked your admin privileges." + %1$s revoked admin privileges from %2$s. + + + You invited %1$s to the group. + %1$s invited you to the group. + + %1$s invited 1 person to the group. + %1$s invited %2$d people to the group. + + + + + You revoked an invitation to the group. + You revoked %1$d invitations to the group. + + + %1$s revoked an invitation to the group. + %1$s revoked %2$d invitations to the group. + + Someone declined an invitation to the group. + You declined the invitation to the group. + + + You accepted the invitation to the group. + %1$s accepted an invitation to the group. + You added invited member %1$s. + %1$s added invited member %2$s. + + + You changed the group name to \"%1$s\". + %1$s changed the group name to \"%2$s\". + + + You changed the group avatar. + %1$s changed the group avatar. + + + You changed who can edit group info to \"%1$s\". + %1$s changed who can edit group info to \"%2$s\". + + + You changed who can edit group membership to \"%1$s\". + %1$s changed who can edit group membership to \"%2$s\". + + + Your safety number with %s has changed. You marked your safety number with %s verified You marked your safety number with %s verified from another device 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 new file mode 100644 index 0000000000..620649873a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java @@ -0,0 +1,589 @@ +package org.thoughtcrime.securesms.database.model; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; + +import com.google.common.collect.ImmutableMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.DisappearingMessagesTimer; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; + +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE, application = Application.class) +public final class GroupsV2UpdateMessageProducerTest { + + private UUID you; + private UUID alice; + private UUID bob; + + private GroupsV2UpdateMessageProducer producer; + + @Before + public void setup() { + you = UUID.randomUUID(); + alice = UUID.randomUUID(); + bob = UUID.randomUUID(); + GroupsV2UpdateMessageProducer.DescribeMemberStrategy describeMember = createDescriber(ImmutableMap.of(alice, "Alice", bob, "Bob")); + producer = new GroupsV2UpdateMessageProducer(ApplicationProvider.getApplicationContext(), describeMember, you); + } + + @Test + public void empty_change() { + DecryptedGroupChange change = changeBy(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice updated the group."))); + } + + @Test + public void empty_change_by_you() { + DecryptedGroupChange change = changeBy(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You updated the group."))); + } + + // Member additions + + @Test + public void member_added_member() { + DecryptedGroupChange change = changeBy(alice) + .addMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice added Bob."))); + } + + @Test + public void you_added_member() { + DecryptedGroupChange change = changeBy(you) + .addMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You added Bob."))); + } + + @Test + public void member_added_you() { + DecryptedGroupChange change = changeBy(alice) + .addMember(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice added you to the group."))); + } + + @Test + public void you_added_you() { + DecryptedGroupChange change = changeBy(you) + .addMember(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You joined the group."))); + } + + @Test + public void member_added_themselves() { + DecryptedGroupChange change = changeBy(bob) + .addMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob joined the group."))); + } + + // Member removals + + @Test + public void member_removed_member() { + DecryptedGroupChange change = changeBy(alice) + .deleteMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice removed Bob."))); + } + + @Test + public void you_removed_member() { + DecryptedGroupChange change = changeBy(you) + .deleteMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You removed Bob."))); + } + + @Test + public void member_removed_you() { + DecryptedGroupChange change = changeBy(alice) + .deleteMember(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice removed you from the group."))); + } + + @Test + public void you_removed_you() { + DecryptedGroupChange change = changeBy(you) + .deleteMember(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You left the group."))); + } + + @Test + public void member_removed_themselves() { + DecryptedGroupChange change = changeBy(bob) + .deleteMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob left the group."))); + } + + // Member role modifications + + @Test + public void you_make_member_admin() { + DecryptedGroupChange change = changeBy(you) + .promoteToAdmin(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You made Alice an admin."))); + } + + @Test + public void member_makes_member_admin() { + DecryptedGroupChange change = changeBy(bob) + .promoteToAdmin(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob made Alice an admin."))); + } + + @Test + public void member_makes_you_admin() { + DecryptedGroupChange change = changeBy(alice) + .promoteToAdmin(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice made you an admin."))); + } + + @Test + public void you_revoked_member_admin() { + DecryptedGroupChange change = changeBy(you) + .demoteToMember(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You revoked admin privileges from Bob."))); + } + + @Test + public void member_revokes_member_admin() { + DecryptedGroupChange change = changeBy(bob) + .demoteToMember(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob revoked admin privileges from Alice."))); + } + + @Test + public void member_revokes_your_admin() { + DecryptedGroupChange change = changeBy(alice) + .demoteToMember(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice revoked your admin privileges."))); + } + + // Member invitation + + @Test + public void you_invited_member() { + DecryptedGroupChange change = changeBy(you) + .invite(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You invited Alice to the group."))); + } + + @Test + public void member_invited_you() { + DecryptedGroupChange change = changeBy(alice) + .invite(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice invited you to the group."))); + } + + @Test + public void member_invited_1_person() { + DecryptedGroupChange change = changeBy(alice) + .invite(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice invited 1 person to the group."))); + } + + @Test + public void member_invited_2_persons() { + DecryptedGroupChange change = changeBy(alice) + .invite(bob) + .invite(UUID.randomUUID()) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice invited 2 people to the group."))); + } + + @Test + public void member_invited_3_persons_and_you() { + DecryptedGroupChange change = changeBy(bob) + .invite(alice) + .invite(you) + .invite(UUID.randomUUID()) + .invite(UUID.randomUUID()) + .build(); + + assertThat(producer.describeChange(change), is(Arrays.asList("Bob invited you to the group.", "Bob invited 3 people to the group."))); + } + + // Member invitation revocation + + @Test + public void member_uninvited_1_person() { + DecryptedGroupChange change = changeBy(alice) + .uninvite(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice revoked an invitation to the group."))); + } + + @Test + public void member_uninvited_2_people() { + DecryptedGroupChange change = changeBy(alice) + .uninvite(bob) + .uninvite(UUID.randomUUID()) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice revoked 2 invitations to the group."))); + } + + @Test + public void you_uninvited_1_person() { + DecryptedGroupChange change = changeBy(you) + .uninvite(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You revoked an invitation to the group."))); + } + + @Test + public void you_uninvited_2_people() { + DecryptedGroupChange change = changeBy(you) + .uninvite(bob) + .uninvite(UUID.randomUUID()) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You revoked 2 invitations to the group."))); + } + + @Test + public void pending_member_declines_invite() { + DecryptedGroupChange change = changeBy(bob) + .uninvite(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Someone declined an invitation to the group."))); + } + + @Test + public void you_decline_invite() { + DecryptedGroupChange change = changeBy(you) + .uninvite(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You declined the invitation to the group."))); + } + + // Promote pending members + + @Test + public void member_accepts_invite() { + DecryptedGroupChange change = changeBy(bob) + .promote(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob accepted an invitation to the group."))); + } + + @Test + public void you_accept_invite() { + DecryptedGroupChange change = changeBy(you) + .promote(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You accepted the invitation to the group."))); + } + + @Test + public void member_promotes_pending_member() { + DecryptedGroupChange change = changeBy(bob) + .promote(alice) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob added invited member Alice."))); + } + + @Test + public void you_promote_pending_member() { + DecryptedGroupChange change = changeBy(you) + .promote(bob) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You added invited member Bob."))); + } + + @Test + public void member_promotes_you() { + DecryptedGroupChange change = changeBy(bob) + .promote(you) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob added you to the group."))); + } + + // Title change + + @Test + public void member_changes_title() { + DecryptedGroupChange change = changeBy(alice) + .title("New title") + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice changed the group name to \"New title\"."))); + } + + @Test + public void you_change_title() { + DecryptedGroupChange change = changeBy(you) + .title("Title 2") + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You changed the group name to \"Title 2\"."))); + } + + // Avatar change + + @Test + public void member_changes_avatar() { + DecryptedGroupChange change = changeBy(alice) + .avatar("Avatar1") + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice changed the group avatar."))); + } + + @Test + public void you_change_avatar() { + DecryptedGroupChange change = changeBy(you) + .avatar("Avatar2") + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You changed the group avatar."))); + } + + // Timer change + + @Test + public void member_changes_timer() { + DecryptedGroupChange change = changeBy(bob) + .timer(10) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob set the disappearing message timer to 10 seconds."))); + } + + @Test + public void you_change_timer() { + DecryptedGroupChange change = changeBy(you) + .timer(60) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You set the disappearing message timer to 1 minute."))); + } + + // Attribute access change + + @Test + public void member_changes_attribute_access() { + DecryptedGroupChange change = changeBy(bob) + .attributeAccess(AccessControl.AccessRequired.MEMBER) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Bob changed who can edit group info to \"All members\"."))); + } + + @Test + public void you_changed_attribute_access() { + DecryptedGroupChange change = changeBy(you) + .attributeAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You changed who can edit group info to \"Only admins\"."))); + } + + // Membership access change + + @Test + public void member_changes_membership_access() { + DecryptedGroupChange change = changeBy(alice) + .membershipAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("Alice changed who can edit group membership to \"Only admins\"."))); + } + + @Test + public void you_changed_membership_access() { + DecryptedGroupChange change = changeBy(you) + .membershipAccess(AccessControl.AccessRequired.MEMBER) + .build(); + + assertThat(producer.describeChange(change), is(singletonList("You changed who can edit group membership to \"All members\"."))); + } + + // Multiple changes + + @Test + public void multiple_changes() { + DecryptedGroupChange change = changeBy(alice) + .addMember(bob) + .membershipAccess(AccessControl.AccessRequired.MEMBER) + .title("Title") + .timer(300) + .build(); + + assertThat(producer.describeChange(change), is(Arrays.asList( + "Alice added Bob.", + "Alice changed the group name to \"Title\".", + "Alice set the disappearing message timer to 5 minutes.", + "Alice changed who can edit group membership to \"All members\"."))); + } + + private static class ChangeBuilder { + + private final DecryptedGroupChange.Builder builder; + + ChangeBuilder(@NonNull UUID editor) { + builder = DecryptedGroupChange.newBuilder() + .setEditor(UuidUtil.toByteString(editor)); + } + + ChangeBuilder addMember(@NonNull UUID newMember) { + builder.addNewMembers(DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(newMember))); + return this; + } + + ChangeBuilder deleteMember(@NonNull UUID removedMember) { + builder.addDeleteMembers(UuidUtil.toByteString(removedMember)); + return this; + } + + ChangeBuilder promoteToAdmin(@NonNull UUID member) { + builder.addModifyMemberRoles(DecryptedModifyMemberRole.newBuilder() + .setRole(Member.Role.ADMINISTRATOR) + .setUuid(UuidUtil.toByteString(member))); + return this; + } + + ChangeBuilder demoteToMember(@NonNull UUID member) { + builder.addModifyMemberRoles(DecryptedModifyMemberRole.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUuid(UuidUtil.toByteString(member))); + return this; + } + + ChangeBuilder invite(@NonNull UUID potentialMember) { + builder.addNewPendingMembers(DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(potentialMember))); + return this; + } + + ChangeBuilder uninvite(@NonNull UUID pendingMember) { + builder.addDeletePendingMembers(DecryptedPendingMemberRemoval.newBuilder() + .setUuid(UuidUtil.toByteString(pendingMember))); + return this; + } + + ChangeBuilder promote(@NonNull UUID pendingMember) { + builder.addPromotePendingMembers(UuidUtil.toByteString(pendingMember)); + return this; + } + + ChangeBuilder title(@NonNull String newTitle) { + builder.setNewTitle(DecryptedString.newBuilder() + .setValue(newTitle)); + return this; + } + + ChangeBuilder avatar(@NonNull String newAvatar) { + builder.setNewAvatar(DecryptedString.newBuilder() + .setValue(newAvatar)); + return this; + } + + ChangeBuilder timer(int duration) { + builder.setNewTimer(DisappearingMessagesTimer.newBuilder() + .setDuration(duration)); + return this; + } + + ChangeBuilder attributeAccess(@NonNull AccessControl.AccessRequired accessRequired) { + builder.setNewAttributeAccess(accessRequired); + return this; + } + + ChangeBuilder membershipAccess(@NonNull AccessControl.AccessRequired accessRequired) { + builder.setNewMemberAccess(accessRequired); + return this; + } + + DecryptedGroupChange build() { + return builder.build(); + } + } + + private ChangeBuilder changeBy(@NonNull UUID groupEditor) { + return new ChangeBuilder(groupEditor); + } + + private @NonNull GroupsV2UpdateMessageProducer.DescribeMemberStrategy createDescriber(@NonNull Map map) { + return uuid -> { + String name = map.get(uuid); + assertNotNull(name); + return name; + }; + } +}