Add send and receive support for group member labels.

This commit is contained in:
jeffrey-signal
2026-01-28 12:43:28 -05:00
committed by Greyson Parrelli
parent ce46c44b5d
commit 0a572153f0
21 changed files with 593 additions and 41 deletions

View File

@@ -46,4 +46,6 @@ public interface ChangeSetModifier {
void removeDeleteBannedMembers(int i);
void removePromotePendingPniAciMembers(int i);
void removeModifyMemberLabels(int i);
}

View File

@@ -110,6 +110,10 @@ internal class DecryptedGroupChangeActionsBuilderChangeSetModifier(private val r
result.promotePendingPniAciMembers = result.promotePendingPniAciMembers.removeIndex(i)
}
override fun removeModifyMemberLabels(i: Int) {
result.modifyMemberLabel = result.modifyMemberLabel.removeIndex(i)
}
private fun <T> List<T>.removeIndex(i: Int): List<T> {
val modifiedList = this.toMutableList()
modifiedList.removeAt(i)

View File

@@ -7,7 +7,9 @@ package org.whispersystems.signalservice.api.groupsv2
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember
import org.signal.storageservice.storage.protos.groups.local.DecryptedRequestingMember
import java.util.Optional
@@ -31,3 +33,24 @@ fun Collection<DecryptedRequestingMember>.findRequestingByAci(aci: ACI): Optiona
fun Collection<DecryptedPendingMember>.findPendingByServiceId(serviceId: ServiceId): Optional<DecryptedPendingMember> {
return DecryptedGroupUtil.findPendingByServiceId(this, serviceId)
}
@Throws(NotAbleToApplyGroupV2ChangeException::class)
fun DecryptedGroup.Builder.setModifyMemberLabelActions(
actions: List<DecryptedModifyMemberLabel>
) {
val updatedMembers = members.toMutableList()
actions.forEach { action ->
val modifiedMemberIndex = updatedMembers.indexOfFirst { it.aciBytes == action.aciBytes }
if (modifiedMemberIndex < 0) {
throw NotAbleToApplyGroupV2ChangeException()
}
updatedMembers[modifiedMemberIndex] = updatedMembers[modifiedMemberIndex]
.newBuilder()
.labelEmoji(action.labelEmoji)
.labelString(action.labelString)
.build()
}
members = updatedMembers
}

View File

@@ -8,6 +8,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMemb
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -26,6 +27,8 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nonnull;
import okio.ByteString;
public final class DecryptedGroupUtil {
@@ -159,7 +162,7 @@ public final class DecryptedGroupUtil {
return Optional.ofNullable(change != null ? ServiceId.parseOrNull(change.editorServiceIdBytes) : null);
}
public static Optional<DecryptedMember> findMemberByAci(Collection<DecryptedMember> members, ACI aci) {
public static Optional<DecryptedMember> findMemberByAci(@Nonnull Collection<DecryptedMember> members, @Nonnull ACI aci) {
ByteString aciBytes = aci.toByteString();
for (DecryptedMember member : members) {
@@ -335,6 +338,8 @@ public final class DecryptedGroupUtil {
applyPromotePendingPniAciMemberActions(builder, change.promotePendingPniAciMembers);
DecryptedGroupExtensionsKt.setModifyMemberLabelActions(builder, change.modifyMemberLabel);
return builder.build();
}
@@ -747,7 +752,8 @@ public final class DecryptedGroupUtil {
isEmpty(change.newIsAnnouncementGroup) && // field 21
change.newBannedMembers.size() == 0 && // field 22
change.deleteBannedMembers.size() == 0 && // field 23
change.promotePendingPniAciMembers.size() == 0; // field 24
change.promotePendingPniAciMembers.size() == 0 && // field 24
change.modifyMemberLabel.isEmpty(); // field 26
}
public static boolean changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(DecryptedGroupChange change) {
@@ -770,7 +776,8 @@ public final class DecryptedGroupUtil {
change.newInviteLinkPassword.size() == 0 && // field 19
change.newDescription == null && // field 20
isEmpty(change.newIsAnnouncementGroup) && // field 21
change.promotePendingPniAciMembers.size() == 0; // field 24
change.promotePendingPniAciMembers.size() == 0 && // field 24
change.modifyMemberLabel.isEmpty(); // field 26
}
static boolean isEmpty(AccessControl.AccessRequired newAttributeAccess) {

View File

@@ -101,6 +101,10 @@ internal class GroupChangeActionsBuilderChangeSetModifier(private val result: Gr
result.promote_members_pending_pni_aci_profile_key = result.promote_members_pending_pni_aci_profile_key.removeIndex(i)
}
override fun removeModifyMemberLabels(i: Int) {
result.modifyMemberLabel = result.modifyMemberLabel.removeIndex(i)
}
private fun <T> List<T>.removeIndex(i: Int): List<T> {
val modifiedList = this.toMutableList()
modifiedList.removeAt(i)

View File

@@ -5,6 +5,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMemb
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -116,8 +117,9 @@ public final class GroupChangeReconstruct {
Map<ByteString, DecryptedMember> membersAciMap = mapByAci(fromState.members);
Map<ByteString, DecryptedBannedMember> bannedMembersServiceIdMap = bannedServiceIdMap(toState.bannedMembers);
List<DecryptedModifyMemberRole> modifiedMemberRoles = new ArrayList<>(changedMembers.size());
List<DecryptedMember> modifiedProfileKeys = new ArrayList<>(changedMembers.size());
List<DecryptedModifyMemberRole> modifiedMemberRoles = new ArrayList<>(changedMembers.size());
List<DecryptedMember> modifiedProfileKeys = new ArrayList<>(changedMembers.size());
List<DecryptedModifyMemberLabel> modifiedMemberLabels = new ArrayList<>(changedMembers.size());
for (DecryptedMember newState : changedMembers) {
DecryptedMember oldState = membersAciMap.get(newState.aciBytes);
if (oldState.role != newState.role) {
@@ -130,9 +132,18 @@ public final class GroupChangeReconstruct {
if (!oldState.profileKey.equals(newState.profileKey)) {
modifiedProfileKeys.add(newState);
}
if (!oldState.labelEmoji.equals(newState.labelEmoji) || !oldState.labelString.equals(newState.labelString)) {
modifiedMemberLabels.add(new DecryptedModifyMemberLabel.Builder()
.aciBytes(newState.aciBytes)
.labelEmoji(newState.labelEmoji)
.labelString(newState.labelString)
.build());
}
}
builder.modifyMemberRoles(modifiedMemberRoles);
builder.modifiedProfileKeys(modifiedProfileKeys);
builder.modifyMemberLabel(modifiedMemberLabels);
if (fromState.accessControl == null || (toState.accessControl != null && !fromState.accessControl.addFromInviteLink.equals(toState.accessControl.addFromInviteLink))) {
if (toState.accessControl != null) {

View File

@@ -6,6 +6,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMemb
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -13,6 +14,9 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedRequesting
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Nonnull;
import okio.ByteString;
@@ -46,7 +50,8 @@ public final class GroupChangeUtil {
change.modify_announcements_only == null && // field 21
change.add_members_banned.size() == 0 && // field 22
change.delete_members_banned.size() == 0 && // field 23
change.promote_members_pending_pni_aci_profile_key.size() == 0; // field 24
change.promote_members_pending_pni_aci_profile_key.size() == 0 && // field 24
change.modifyMemberLabel.isEmpty(); // field 26
}
/**
@@ -149,6 +154,7 @@ public final class GroupChangeUtil {
resolveField22AddBannedMembers (conflictingChange, changeSetModifier, bannedMembersByServiceId);
resolveField23DeleteBannedMembers (conflictingChange, changeSetModifier, bannedMembersByServiceId);
resolveField24PromotePendingPniAciMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField26ModifyMemberLabels (conflictingChange, changeSetModifier, fullMembersByUuid);
}
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
@@ -366,4 +372,22 @@ public final class GroupChangeUtil {
}
}
}
private static void resolveField26ModifyMemberLabels(
@Nonnull DecryptedGroupChange conflictingChange,
@Nonnull ChangeSetModifier result,
@Nonnull Map<ByteString, DecryptedMember> fullMembersByAci
)
{
List<DecryptedModifyMemberLabel> actions = conflictingChange.modifyMemberLabel;
for (int i = actions.size() - 1; i >= 0; i--) {
DecryptedModifyMemberLabel action = actions.get(i);
DecryptedMember member = fullMembersByAci.get(action.aciBytes);
if (member == null || (action.labelEmoji.equals(member.labelEmoji) && action.labelString.equals(member.labelString))) {
result.removeModifyMemberLabels(i);
}
}
}
}

View File

@@ -31,6 +31,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -44,6 +45,7 @@ import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
@@ -55,6 +57,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.Objects;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -72,7 +75,7 @@ public final class GroupsV2Operations {
public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID;
/** Highest change epoch this class knows now to decrypt */
public static final int HIGHEST_KNOWN_EPOCH = 5;
public static final int HIGHEST_KNOWN_EPOCH = 6;
private final ServerPublicParams serverPublicParams;
private final ClientZkProfileOperations clientZkProfileOperations;
@@ -754,6 +757,19 @@ public final class GroupsV2Operations {
}
builder.promotePendingPniAciMembers(promotePendingPniAciMembers);
// Field 26
List<DecryptedModifyMemberLabel> modifyMemberLabels = new ArrayList<>(actions.modifyMemberLabel.size());
for (GroupChange.Actions.ModifyMemberLabelAction action : actions.modifyMemberLabel) {
modifyMemberLabels.add(
new DecryptedModifyMemberLabel.Builder()
.aciBytes(decryptAciToBinary(action.userId))
.labelEmoji(Objects.requireNonNullElse(decryptString(action.labelEmoji), ""))
.labelString(Objects.requireNonNullElse(decryptString(action.labelString), ""))
.build()
);
}
builder.modifyMemberLabel(modifyMemberLabels);
if (editorServiceId instanceof ServiceId.PNI) {
if (actions.addMembers.size() == 1 && builder.newMembers.size() == 1) {
GroupChange.Actions.AddMemberAction addMemberAction = actions.addMembers.get(0);
@@ -790,14 +806,19 @@ public final class GroupsV2Operations {
private DecryptedMember.Builder decryptMember(Member member)
throws InvalidGroupStateException, VerificationFailedException, InvalidInputException
{
String labelEmoji = Objects.requireNonNullElse(decryptString(member.labelEmoji), "");
String labelString = Objects.requireNonNullElse(decryptString(member.labelString), "");
if (member.presentation.size() == 0) {
ACI aci = decryptAci(member.userId);
return new DecryptedMember.Builder()
.aciBytes(aci.toByteString())
.joinedAtRevision(member.joinedAtVersion)
.profileKey(decryptProfileKeyToByteString(member.profileKey, aci))
.role(member.role);
.aciBytes(aci.toByteString())
.joinedAtRevision(member.joinedAtVersion)
.profileKey(decryptProfileKeyToByteString(member.profileKey, aci))
.role(member.role)
.labelEmoji(labelEmoji)
.labelString(labelString);
} else {
ProfileKeyCredentialPresentation profileKeyCredentialPresentation = new ProfileKeyCredentialPresentation(member.presentation.toByteArray());
@@ -810,10 +831,12 @@ public final class GroupsV2Operations {
ProfileKey profileKey = clientZkGroupCipher.decryptProfileKey(profileKeyCredentialPresentation.getProfileKeyCiphertext(), aci.getLibSignalAci());
return new DecryptedMember.Builder()
.aciBytes(aci.toByteString())
.joinedAtRevision(member.joinedAtVersion)
.profileKey(ByteString.of(profileKey.serialize()))
.role(member.role);
.aciBytes(aci.toByteString())
.joinedAtRevision(member.joinedAtVersion)
.profileKey(ByteString.of(profileKey.serialize()))
.role(member.role)
.labelEmoji(labelEmoji)
.labelString(labelString);
}
}
@@ -1010,6 +1033,34 @@ public final class GroupsV2Operations {
}
}
/**
* Encrypts a string as raw UTF-8 bytes for member-specific attributes.
*/
private ByteString encryptString(@Nullable String value) {
if (value == null || value.isEmpty()) {
return ByteString.EMPTY;
}
try {
return ByteString.of(clientZkGroupCipher.encryptBlob(value.getBytes(StandardCharsets.UTF_8)));
} catch (VerificationFailedException e) {
throw new AssertionError(e);
}
}
/**
* Decrypts a string from raw UTF-8 bytes for member-specific attributes.
*/
@Nullable
private String decryptString(@Nullable ByteString cipherText) throws VerificationFailedException {
if (cipherText == null || cipherText.size() == 0) {
return null;
}
byte[] decryptedBytes = clientZkGroupCipher.decryptBlob(cipherText.toByteArray());
return new String(decryptedBytes, StandardCharsets.UTF_8);
}
/**
* Verifies signature and parses actions on a group change.
*/
@@ -1046,6 +1097,18 @@ public final class GroupsV2Operations {
));
}
public GroupChange.Actions.Builder createChangeMemberLabel(@Nonnull ACI memberAci, @Nonnull String labelString, @Nullable String labelEmoji) {
return new GroupChange.Actions.Builder().modifyMemberLabel(
Collections.singletonList(
new GroupChange.Actions.ModifyMemberLabelAction.Builder()
.userId(encryptServiceId(memberAci))
.labelEmoji(encryptString(labelEmoji))
.labelString(encryptString(labelString))
.build()
)
);
}
public List<ServiceId> decryptAddMembers(List<GroupChange.Actions.AddMemberAction> addMembers) throws InvalidGroupStateException, InvalidInputException, VerificationFailedException {
List<ServiceId> ids = new ArrayList<>(addMembers.size());
for (GroupChange.Actions.AddMemberAction addMember : addMembers) {

View File

@@ -20,6 +20,8 @@ message DecryptedMember {
bytes profileKey = 3;
uint32 joinedAtRevision = 5;
bytes pniBytes = 6;
string labelEmoji = 7;
string labelString = 8;
}
message DecryptedPendingMember {
@@ -56,6 +58,12 @@ message DecryptedModifyMemberRole {
Member.Role role = 2;
}
message DecryptedModifyMemberLabel {
bytes aciBytes = 1;
string labelEmoji = 2;
string labelString = 3;
}
// Decrypted version of message Group
// Keep field numbers in step
message DecryptedGroup {
@@ -102,6 +110,7 @@ message DecryptedGroupChange {
repeated DecryptedBannedMember newBannedMembers = 22;
repeated DecryptedBannedMember deleteBannedMembers = 23;
repeated DecryptedMember promotePendingPniAciMembers = 24;
repeated DecryptedModifyMemberLabel modifyMemberLabel = 26;
}
message DecryptedString {
@@ -128,4 +137,3 @@ enum EnabledState {
ENABLED = 1;
DISABLED = 2;
}

View File

@@ -35,6 +35,8 @@ message Member {
bytes profileKey = 3;
bytes presentation = 4;
uint32 joinedAtVersion = 5;
bytes labelEmoji = 6; // decrypts to a UTF-8 string
bytes labelString = 7; // decrypts to a UTF-8 string
}
message MemberPendingProfileKey {
@@ -141,6 +143,12 @@ message GroupChange {
Member.Role role = 2;
}
message ModifyMemberLabelAction {
bytes userId = 1;
bytes labelEmoji = 2; // decrypts to a UTF-8 string
bytes labelString = 3; // decrypts to a UTF-8 string
}
message ModifyMemberProfileKeyAction {
bytes presentation = 1;
bytes user_id = 2;
@@ -253,7 +261,8 @@ message GroupChange {
repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4
repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4
repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5
// next: 26
repeated ModifyMemberLabelAction modifyMemberLabel = 26; // change epoch = 6;
// next: 27
}
bytes actions = 1;

View File

@@ -1,6 +1,7 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Test;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.Member;
@@ -9,6 +10,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMemb
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -16,7 +18,6 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedRequesting
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.List;
@@ -38,8 +39,8 @@ import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.reque
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class DecryptedGroupUtil_apply_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
@@ -50,7 +51,7 @@ public final class DecryptedGroupUtil_apply_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
24, maxFieldFound);
26, maxFieldFound);
}
@Test
@@ -956,4 +957,99 @@ public final class DecryptedGroupUtil_apply_Test {
.build(),
newGroup);
}
@Test
public void apply_modify_member_label() throws NotAbleToApplyGroupV2ChangeException {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember existingMember = member(memberUuid);
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(memberUuid))
.labelEmoji("🎉")
.labelString("Test Label")
.build();
DecryptedGroup actualResult = DecryptedGroupUtil.apply(
new DecryptedGroup.Builder()
.revision(10)
.members(List.of(existingMember))
.build(),
new DecryptedGroupChange.Builder()
.revision(11)
.modifyMemberLabel(List.of(modifyLabelAction))
.build()
);
List<DecryptedMember> expectedMembers = List.of(
existingMember.newBuilder()
.labelEmoji("🎉")
.labelString("Test Label")
.build()
);
DecryptedGroup expectedResult = new DecryptedGroup.Builder()
.revision(11)
.members(expectedMembers)
.build();
assertEquals(expectedResult, actualResult);
}
@Test
public void apply_modify_member_label_clear() throws NotAbleToApplyGroupV2ChangeException {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember member = member(memberUuid)
.newBuilder()
.labelEmoji("🎉")
.labelString("Test Label")
.build();
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(memberUuid))
.labelEmoji("")
.labelString("")
.build();
DecryptedGroup actualResult = DecryptedGroupUtil.apply(
new DecryptedGroup.Builder()
.revision(10)
.members(List.of(member))
.build(),
new DecryptedGroupChange.Builder()
.revision(11)
.modifyMemberLabel(List.of(modifyLabelAction))
.build());
DecryptedGroup expectedResult = new DecryptedGroup.Builder()
.revision(11)
.members(List.of(member(memberUuid)))
.build();
assertEquals(expectedResult, actualResult);
}
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
public void apply_modify_member_label_for_non_member() throws NotAbleToApplyGroupV2ChangeException {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
UUID nonMemberUuid = UUID.fromString("d2d2d2d2-0000-4000-8000-000000000002");
DecryptedMember member1 = member(memberUuid);
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(nonMemberUuid))
.labelEmoji("🎉")
.labelString("Test Label")
.build();
DecryptedGroupUtil.apply(
new DecryptedGroup.Builder()
.revision(10)
.members(List.of(member1))
.build(),
new DecryptedGroupChange.Builder()
.revision(11)
.modifyMemberLabel(List.of(modifyLabelAction))
.build());
}
}

View File

@@ -1,15 +1,16 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Test;
import org.signal.core.util.UuidUtil;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.local.DecryptedApproveMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedRequestingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.util.UuidUtil;
import java.util.List;
import java.util.UUID;
@@ -27,8 +28,8 @@ import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promo
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class DecryptedGroupUtil_empty_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
@@ -39,7 +40,7 @@ public final class DecryptedGroupUtil_empty_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
24, maxFieldFound);
26, maxFieldFound);
}
@Test
@@ -266,4 +267,21 @@ public final class DecryptedGroupUtil_empty_Test {
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
}
@Test
public void not_empty_with_modify_member_label_field_26() {
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(UUID.randomUUID()))
.labelEmoji("🔥")
.labelString("Test")
.build();
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
.modifyMemberLabel(List.of(modifyLabelAction))
.build();
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(change));
}
}

View File

@@ -1,14 +1,15 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Test;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.List;
@@ -408,4 +409,57 @@ public final class GroupChangeReconstructTest {
assertEquals(new DecryptedGroupChange.Builder().deleteBannedMembers(List.of(bannedMember(uuidOld))).build(), decryptedGroupChange);
}
}
@Test
public void member_label_change() {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember existingMember = member(memberUuid);
DecryptedMember updatedMember = member(memberUuid)
.newBuilder()
.labelEmoji("🎉")
.labelString("New Label")
.build();
DecryptedGroup from = new DecryptedGroup.Builder()
.members(List.of(existingMember))
.build();
DecryptedGroup to = new DecryptedGroup.Builder()
.members(List.of(updatedMember))
.build();
DecryptedGroupChange change = GroupChangeReconstruct.reconstructGroupChange(from, to);
assertEquals(1, change.modifyMemberLabel.size());
assertEquals(UuidUtil.toByteString(memberUuid), change.modifyMemberLabel.get(0).aciBytes);
assertEquals("🎉", change.modifyMemberLabel.get(0).labelEmoji);
assertEquals("New Label", change.modifyMemberLabel.get(0).labelString);
}
@Test
public void member_label_clear() {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember memberWithLabel = member(memberUuid)
.newBuilder()
.labelEmoji("🎉")
.labelString("existing label")
.build();
DecryptedGroup from = new DecryptedGroup.Builder()
.members(List.of(memberWithLabel))
.build();
DecryptedGroup to = new DecryptedGroup.Builder()
.members(List.of(member(memberUuid)))
.build();
DecryptedGroupChange change = GroupChangeReconstruct.reconstructGroupChange(from, to);
assertEquals(1, change.modifyMemberLabel.size());
assertEquals(UuidUtil.toByteString(memberUuid), change.modifyMemberLabel.get(0).aciBytes);
assertEquals("", change.modifyMemberLabel.get(0).labelEmoji);
assertEquals("", change.modifyMemberLabel.get(0).labelString);
}
}

View File

@@ -10,8 +10,8 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class GroupChangeUtil_changeIsEmpty_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
@@ -22,7 +22,7 @@ public final class GroupChangeUtil_changeIsEmpty_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class);
assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(),
25, maxFieldFound);
26, maxFieldFound);
}
@Test
@@ -227,4 +227,13 @@ public final class GroupChangeUtil_changeIsEmpty_Test {
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
}
@Test
public void not_empty_with_modify_member_label_field_26() {
GroupChange.Actions actions = new GroupChange.Actions.Builder()
.modifyMemberLabel(List.of(new GroupChange.Actions.ModifyMemberLabelAction()))
.build();
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
}
}

View File

@@ -1,19 +1,20 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Test;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.MemberBanned;
import org.signal.storageservice.storage.protos.groups.GroupChange;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.MemberBanned;
import org.signal.storageservice.storage.protos.groups.MemberPendingProfileKey;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.List;
@@ -40,8 +41,8 @@ import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.rando
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class GroupChangeUtil_resolveConflict_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
@@ -52,7 +53,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
24, maxFieldFound);
26, maxFieldFound);
}
/**
@@ -65,7 +66,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(),
24, maxFieldFound);
26, maxFieldFound);
}
/**
@@ -854,4 +855,57 @@ public final class GroupChangeUtil_resolveConflict_Test {
.build();
assertEquals(expected, resolvedActions);
}
@Test
public void field_26__modify_member_label__remove_if_label_already_matches() {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember existingMember = member(memberUuid)
.newBuilder()
.labelEmoji("🔥")
.labelString("matching label")
.build();
DecryptedGroup existingGroup = new DecryptedGroup.Builder()
.revision(10)
.members(List.of(existingMember))
.build();
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(memberUuid))
.labelEmoji("🔥")
.labelString("matching label")
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.modifyMemberLabel(List.of(modifyLabelAction))
.build();
DecryptedGroupChange.Builder resolvedActions = GroupChangeUtil.resolveConflict(existingGroup, conflictingChange);
assertTrue(resolvedActions.build().modifyMemberLabel.isEmpty());
}
@Test
public void field_26__modify_member_label__remove_if_member_not_in_group() {
UUID memberUuuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
UUID nonMemberUuid = UUID.fromString("d2d2d2d2-0000-4000-8000-000000000002");
DecryptedGroup existingGroup = new DecryptedGroup.Builder()
.revision(10)
.members(List.of(member(memberUuuid)))
.build();
DecryptedModifyMemberLabel modifyLabelAction = new org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(nonMemberUuid))
.labelEmoji("🔥")
.labelString("foo bar")
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.modifyMemberLabel(List.of(modifyLabelAction))
.build();
DecryptedGroupChange.Builder resolved = GroupChangeUtil.resolveConflict(existingGroup, conflictingChange);
assertTrue(resolved.build().modifyMemberLabel.isEmpty());
}
}

View File

@@ -1,15 +1,16 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Test;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.List;
@@ -32,8 +33,8 @@ import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.rando
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
@@ -44,7 +45,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
24, maxFieldFound);
26, maxFieldFound);
}
/**
@@ -673,4 +674,57 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
assertEquals(expected, resolvedChanges);
}
@Test
public void field_26__modify_member_label__remove_if_label_already_matches() {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
DecryptedMember existingMember = member(memberUuid)
.newBuilder()
.labelEmoji("🔥")
.labelString("Already Set")
.build();
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(memberUuid))
.labelEmoji("🔥")
.labelString("Already Set")
.build();
DecryptedGroup existingGroup = new DecryptedGroup.Builder()
.revision(10)
.members(List.of(existingMember))
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.modifyMemberLabel(List.of(modifyLabelAction))
.build();
DecryptedGroupChange.Builder resolved = GroupChangeUtil.resolveConflict(existingGroup, conflictingChange);
assertTrue(resolved.build().modifyMemberLabel.isEmpty());
}
@Test
public void field_26__modify_member_label__remove_if_member_not_in_group() {
UUID memberUuid = UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001");
UUID notInGroupUuid = UUID.fromString("d2d2d2d2-0000-4000-8000-000000000002");
DecryptedGroup existingGroup = new DecryptedGroup.Builder()
.revision(10)
.members(List.of(member(memberUuid)))
.build();
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(UuidUtil.toByteString(notInGroupUuid))
.labelEmoji("🔥")
.labelString("Test")
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.modifyMemberLabel(List.of(modifyLabelAction))
.build();
DecryptedGroupChange.Builder resolved = GroupChangeUtil.resolveConflict(existingGroup, conflictingChange);
assertTrue(resolved.build().modifyMemberLabel.isEmpty());
}
}

View File

@@ -2,6 +2,9 @@ package org.whispersystems.signalservice.api.groupsv2;
import org.junit.Before;
import org.junit.Test;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
@@ -23,6 +26,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedApproveMem
import org.signal.storageservice.storage.protos.groups.local.DecryptedBannedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberLabel;
import org.signal.storageservice.storage.protos.groups.local.DecryptedModifyMemberRole;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMemberRemoval;
@@ -30,9 +34,6 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedRequesting
import org.signal.storageservice.storage.protos.groups.local.DecryptedString;
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer;
import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil;
@@ -50,8 +51,8 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
@SuppressWarnings("NewClassNamingConvention")
public final class GroupsV2Operations_decrypt_change_Test {
private GroupSecretParams groupSecretParams;
private GroupsV2Operations.GroupOperations groupOperations;
private ClientZkOperations clientZkOperations;
@@ -72,7 +73,7 @@ public final class GroupsV2Operations_decrypt_change_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("GroupV2Operations#decryptChange and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
24,
26,
maxFieldFound);
}
@@ -459,6 +460,22 @@ public final class GroupsV2Operations_decrypt_change_Test {
.build())));
}
@Test
public void can_decrypt_modify_member_label_field26() {
ACI aci = ACI.from(UUID.fromString("d1d1d1d1-0000-4000-8000-000000000001"));
DecryptedModifyMemberLabel modifyLabelAction = new DecryptedModifyMemberLabel.Builder()
.aciBytes(aci.toByteString())
.labelString("Label Text")
.labelEmoji("🔥")
.build();
assertDecryption(
groupOperations.createChangeMemberLabel(aci, "Label Text", "🔥"),
new DecryptedGroupChange.Builder().modifyMemberLabel(List.of(modifyLabelAction))
);
}
private static ProfileKey newProfileKey() {
try {
return new ProfileKey(Util.getSecretBytes(32));