diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 8f59df083c..9ec668941d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -21,6 +21,7 @@ import org.signal.core.util.exists import org.signal.core.util.isAbsent import org.signal.core.util.logging.Log import org.signal.core.util.optionalString +import org.signal.core.util.orNull import org.signal.core.util.readToList import org.signal.core.util.readToMap import org.signal.core.util.readToSingleInt @@ -54,6 +55,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.BadGroupIdException import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId.Push +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -1271,6 +1273,17 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : .sortedBy { it.toString() } .toList() } + + /** + * Gets the member label for a specific member in the group, or null if the member is not found. + */ + fun memberLabel(aci: ACI): MemberLabel? { + return decryptedGroup + .members + .findMemberByAci(aci) + .orNull() + ?.let { member -> MemberLabel(member.labelEmoji, member.labelString) } + } } @Throws(BadGroupIdException::class) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 3208697938..8946ceb911 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import org.signal.core.models.ServiceId; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; @@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.signal.core.models.ServiceId; import java.io.IOException; import java.util.Collection; @@ -233,6 +233,19 @@ public final class GroupManager { } } + @WorkerThread + public static void updateMemberLabel(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull String labelString, @NonNull String labelEmoji) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + if (!groupId.isV2()) { + throw new GroupChangeFailedException("Not gv2"); + } + + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId)) { + editor.updateMemberLabel(labelString, labelEmoji); + } + } + @WorkerThread public static void revokeInvites(@NonNull Context context, @NonNull ServiceId authServiceId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index afa9bb84b8..e6a27849d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -358,6 +358,14 @@ final class GroupManagerV2 { } } + @WorkerThread + @NonNull + GroupManager.GroupActionResult updateMemberLabel(@NonNull String labelString, @NonNull String labelEmoji) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(selfAci, groupOperations.createChangeMemberLabel(selfAci, labelString, labelEmoji)); + } + @WorkerThread @NonNull GroupManager.GroupActionResult revokeInvites(@NonNull ServiceId authServiceId, @NonNull Collection uuidCipherTexts, boolean sendToMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt new file mode 100644 index 0000000000..e206a168f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.signal.core.models.ServiceId +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Handles the retrieval and modification of group member labels. + */ +class MemberLabelRepository( + private val groupId: GroupId.V2, + private val context: Context = AppDependencies.application, + private val groupsTable: GroupTable = SignalDatabase.groups +) { + /** + * Gets the member label for a specific recipient in the group. + */ + suspend fun getLabel(recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) { + val recipient = Recipient.resolved(recipientId) + val aci = recipient.serviceId.orNull() as? ServiceId.ACI ?: return@withContext null + val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null + + return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci) + } + + /** + * Sets the group member label for the current user. + */ + suspend fun setLabel(label: MemberLabel): Unit = withContext(Dispatchers.IO) { + GroupManager.updateMemberLabel(context, groupId, label.text, label.emoji ?: "") + } + + /** + * Clears the group member label for the current user. + */ + suspend fun removeLabel(): Unit = withContext(Dispatchers.IO) { + GroupManager.updateMemberLabel(context, groupId, "", "") + } +} + +/** + * A member's custom label within a group. + */ +data class MemberLabel( + val emoji: String?, + val text: String +) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java index ec05ad584b..3088adc76d 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java @@ -46,4 +46,6 @@ public interface ChangeSetModifier { void removeDeleteBannedMembers(int i); void removePromotePendingPniAciMembers(int i); + + void removeModifyMemberLabels(int i); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt index e77474f2cc..eaba5279ad 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt @@ -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 List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt index fa1f877573..5ab5355706 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt @@ -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.findRequestingByAci(aci: ACI): Optiona fun Collection.findPendingByServiceId(serviceId: ServiceId): Optional { return DecryptedGroupUtil.findPendingByServiceId(this, serviceId) } + +@Throws(NotAbleToApplyGroupV2ChangeException::class) +fun DecryptedGroup.Builder.setModifyMemberLabelActions( + actions: List +) { + 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 +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 1019159255..0f4ad4efe9 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -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 findMemberByAci(Collection members, ACI aci) { + public static Optional findMemberByAci(@Nonnull Collection 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) { diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt index de3e090067..6c3e209081 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt @@ -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 List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java index 374bd039dc..53cbd33714 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java @@ -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 membersAciMap = mapByAci(fromState.members); Map bannedMembersServiceIdMap = bannedServiceIdMap(toState.bannedMembers); - List modifiedMemberRoles = new ArrayList<>(changedMembers.size()); - List modifiedProfileKeys = new ArrayList<>(changedMembers.size()); + List modifiedMemberRoles = new ArrayList<>(changedMembers.size()); + List modifiedProfileKeys = new ArrayList<>(changedMembers.size()); + List 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) { diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java index 05e10b801e..b6d4af7f16 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java @@ -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 fullMembersByUuid, HashMap pendingMembersByServiceId) { @@ -366,4 +372,22 @@ public final class GroupChangeUtil { } } } + + private static void resolveField26ModifyMemberLabels( + @Nonnull DecryptedGroupChange conflictingChange, + @Nonnull ChangeSetModifier result, + @Nonnull Map fullMembersByAci + ) + { + List 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); + } + } + } } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index 540397b25b..99ccc760fc 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -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 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 decryptAddMembers(List addMembers) throws InvalidGroupStateException, InvalidInputException, VerificationFailedException { List ids = new ArrayList<>(addMembers.size()); for (GroupChange.Actions.AddMemberAction addMember : addMembers) { diff --git a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto index 80c2442bee..0ac4c53890 100644 --- a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto +++ b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto @@ -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; } - diff --git a/lib/libsignal-service/src/main/protowire/Groups.proto b/lib/libsignal-service/src/main/protowire/Groups.proto index e0c612dce6..f3208e22bc 100644 --- a/lib/libsignal-service/src/main/protowire/Groups.proto +++ b/lib/libsignal-service/src/main/protowire/Groups.proto @@ -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; diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java index 9ca11d3718..1f0f27d861 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java @@ -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. *

@@ -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 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()); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java index a2ed0a1225..7370e105ac 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java @@ -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. *

@@ -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)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java index 9a2c88b716..87d4fb3126 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java @@ -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); } -} \ No newline at end of file + + @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); + } +} diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java index 6f0c29b02a..afc8235f44 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java @@ -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. *

@@ -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)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java index 3df8f4fe2e..587f2efb09 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java @@ -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. *

@@ -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()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java index b80ef09781..ceed104d27 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java @@ -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. *

@@ -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()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java index 73a946c8a1..8b6dfefee5 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java @@ -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));