Add group terminate support.

This commit is contained in:
Cody Henthorne
2026-03-19 16:10:26 -04:00
parent 0896718e5c
commit a0c0acb8fc
130 changed files with 1312 additions and 146 deletions

View File

@@ -50,4 +50,6 @@ public interface ChangeSetModifier {
void removeModifyMemberLabels(int i);
void clearModifyMemberLabelAccess();
void clearTerminateGroup();
}

View File

@@ -118,6 +118,10 @@ internal class DecryptedGroupChangeActionsBuilderChangeSetModifier(private val r
result.newMemberLabelAccess = AccessControl.AccessRequired.UNKNOWN
}
override fun clearTerminateGroup() {
result.terminateGroup = false
}
private fun <T> List<T>.removeIndex(i: Int): List<T> {
val modifiedList = this.toMutableList()
modifiedList.removeAt(i)

View File

@@ -89,6 +89,7 @@ fun DecryptedGroupChange.getChangedFields(): Set<GroupChangeField> {
if (newRequestingMembers.isNotEmpty()) add(GroupChangeField.REQUESTING_MEMBERS)
if (newTimer != null) add(GroupChangeField.TIMER)
if (newTitle != null) add(GroupChangeField.TITLE)
if (terminateGroup) add(GroupChangeField.TERMINATE_GROUP)
}
}
@@ -129,6 +130,7 @@ enum class GroupChangeField(val changeSilently: Boolean = false) {
REQUESTING_MEMBER_APPROVALS,
REQUESTING_MEMBER_REMOVALS,
REQUESTING_MEMBERS,
TERMINATE_GROUP,
TIMER,
TITLE;

View File

@@ -341,6 +341,8 @@ public final class DecryptedGroupUtil {
DecryptedGroupExtensions.setModifyMemberLabelActions(builder, change.modifyMemberLabels);
applyTerminateGroup(builder, change);
return builder.build();
}
@@ -535,6 +537,12 @@ public final class DecryptedGroupUtil {
}
}
private static void applyTerminateGroup(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
if (change.terminateGroup) {
builder.terminated(true);
}
}
private static void applyAddRequestingMembers(DecryptedGroup.Builder builder, List<DecryptedRequestingMember> newRequestingMembers) {
List<DecryptedRequestingMember> requestingMembers = new ArrayList<>(builder.requestingMembers);
requestingMembers.addAll(newRequestingMembers);

View File

@@ -109,6 +109,10 @@ internal class GroupChangeActionsBuilderChangeSetModifier(private val result: Gr
result.modifyMemberLabelAccess = null
}
override fun clearTerminateGroup() {
result.terminate_group = null
}
private fun <T> List<T>.removeIndex(i: Int): List<T> {
val modifiedList = this.toMutableList()
modifiedList.removeAt(i)

View File

@@ -186,6 +186,10 @@ public final class GroupChangeReconstruct {
})
.collect(Collectors.toList()));
if (!fromState.terminated && toState.terminated) {
builder.terminateGroup(true);
}
return builder.build();
}

View File

@@ -52,7 +52,8 @@ public final class GroupChangeUtil {
change.delete_members_banned.isEmpty() && // field 23
change.promote_members_pending_pni_aci_profile_key.isEmpty() && // field 24
change.modifyMemberLabels.isEmpty() && // field 26
change.modifyMemberLabelAccess == null; // field 27
change.modifyMemberLabelAccess == null && // field 27
change.terminate_group == null; // field 28
}
/**
@@ -157,6 +158,7 @@ public final class GroupChangeUtil {
resolveField24PromotePendingPniAciMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField26ModifyMemberLabels (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField27ModifyMemberLabelAccess (groupState, conflictingChange, changeSetModifier);
resolveField28TerminateGroup (groupState, conflictingChange, changeSetModifier);
}
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
@@ -403,4 +405,13 @@ public final class GroupChangeUtil {
result.clearModifyMemberLabelAccess();
}
}
private static void resolveField28TerminateGroup(@Nonnull DecryptedGroup groupState,
@Nonnull DecryptedGroupChange conflictingChange,
@Nonnull ChangeSetModifier result)
{
if (groupState.terminated && conflictingChange.terminateGroup) {
result.clearTerminateGroup();
}
}
}

View File

@@ -75,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 = 6;
public static final int HIGHEST_KNOWN_EPOCH = 7;
private final ServerPublicParams serverPublicParams;
private final ClientZkProfileOperations clientZkProfileOperations;
@@ -350,6 +350,12 @@ public final class GroupsV2Operations {
);
}
public GroupChange.Actions.Builder createTerminateGroup() {
return new GroupChange.Actions.Builder().terminate_group(
new GroupChange.Actions.TerminateGroupAction.Builder().build()
);
}
public GroupChange.Actions.Builder createAnnouncementGroupChange(boolean isAnnouncementGroup) {
return new GroupChange.Actions.Builder().modify_announcements_only(
new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder().announcements_only(isAnnouncementGroup).build()
@@ -493,6 +499,7 @@ public final class GroupsV2Operations {
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(decryptDisappearingMessagesTimer(group.disappearingMessagesTimer)).build())
.inviteLinkPassword(group.inviteLinkPassword)
.bannedMembers(decryptedBannedMembers)
.terminated(group.terminated)
.build();
}
@@ -781,6 +788,11 @@ public final class GroupsV2Operations {
builder.newMemberLabelAccess(actions.modifyMemberLabelAccess.memberLabelAccess);
}
// Field 28
if (actions.terminate_group != null) {
builder.terminateGroup(true);
}
if (editorServiceId instanceof ServiceId.PNI) {
if (actions.addMembers.size() == 1 && builder.newMembers.size() == 1) {
GroupChange.Actions.AddMemberAction addMemberAction = actions.addMembers.get(0);

View File

@@ -79,6 +79,7 @@ message DecryptedGroup {
string description = 11;
EnabledState isAnnouncementGroup = 12;
repeated DecryptedBannedMember bannedMembers = 13;
bool terminated = 14;
bool isPlaceholderGroup = 64;
}
@@ -112,6 +113,7 @@ message DecryptedGroupChange {
repeated DecryptedMember promotePendingPniAciMembers = 24;
repeated DecryptedModifyMemberLabel modifyMemberLabels = 26;
AccessControl.AccessRequired newMemberLabelAccess = 27;
bool terminateGroup = 28;
}
message DecryptedString {

View File

@@ -88,7 +88,8 @@ message Group {
bytes inviteLinkPassword = 10;
bool announcements_only = 12;
repeated MemberBanned members_banned = 13;
// next: 14
bool terminated = 14;
// next: 15
}
message GroupAttributeBlob {
@@ -238,6 +239,8 @@ message GroupChange {
bool announcements_only = 1;
}
message TerminateGroupAction {}
bytes sourceUserId = 1;
// clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group
// if clients set it during a request the server will respond with 400.
@@ -268,7 +271,8 @@ message GroupChange {
repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6;
ModifyMemberLabelAccessControlAction modifyMemberLabelAccess = 27; // change epoch = 6
// next: 28
TerminateGroupAction terminate_group = 28; // change epoch = 7
// next: 29
}
bytes actions = 1;

View File

@@ -51,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(),
27, maxFieldFound);
28, maxFieldFound);
}
@Test
@@ -1084,4 +1084,23 @@ public final class DecryptedGroupUtil_apply_Test {
assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange));
}
@Test
public void apply_terminate_group() throws NotAbleToApplyGroupV2ChangeException {
DecryptedGroup group = new DecryptedGroup.Builder()
.revision(10)
.build();
DecryptedGroupChange groupChange = new DecryptedGroupChange.Builder()
.revision(11)
.terminateGroup(true)
.build();
DecryptedGroup expectedResult = new DecryptedGroup.Builder()
.revision(11)
.terminated(true)
.build();
assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange));
}
}

View File

@@ -41,7 +41,7 @@ public final class DecryptedGroupUtil_empty_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
assertEquals("GroupChangeField and getChangedFields() need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
27, maxFieldFound);
28, maxFieldFound);
}
@Test
@@ -295,6 +295,16 @@ public final class DecryptedGroupUtil_empty_Test {
assertFalse(DecryptedGroupExtensions.isSilent(change));
}
@Test
public void not_empty_with_terminate_group_field_28() {
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
.terminateGroup(true)
.build();
assertFalse(DecryptedGroupExtensions.getChangedFields(change).isEmpty());
assertFalse(DecryptedGroupExtensions.isSilent(change));
}
@Test
public void silent_with_profile_keys_and_banned_members() {
DecryptedGroupChange change = new DecryptedGroupChange.Builder()

View File

@@ -45,7 +45,7 @@ public final class GroupChangeReconstructTest {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
assertEquals("GroupChangeReconstruct and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
13, maxFieldFound);
14, maxFieldFound);
}
@Test
@@ -487,4 +487,22 @@ public final class GroupChangeReconstructTest {
.build(),
decryptedGroupChange);
}
@Test
public void terminate_group() {
DecryptedGroup from = new DecryptedGroup.Builder()
.build();
DecryptedGroup to = new DecryptedGroup.Builder()
.terminated(true)
.build();
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
assertEquals(
new DecryptedGroupChange.Builder()
.terminateGroup(true)
.build(),
decryptedGroupChange);
}
}

View File

@@ -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(),
27, maxFieldFound);
28, maxFieldFound);
}
@Test
@@ -245,4 +245,13 @@ public final class GroupChangeUtil_changeIsEmpty_Test {
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
}
@Test
public void not_empty_with_terminate_group_field_28() {
GroupChange.Actions actions = new GroupChange.Actions.Builder()
.terminate_group(new GroupChange.Actions.TerminateGroupAction())
.build();
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
}
}

View File

@@ -53,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(),
27, maxFieldFound);
28, maxFieldFound);
}
/**
@@ -66,7 +66,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(),
27, maxFieldFound);
28, maxFieldFound);
}
/**
@@ -79,7 +79,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
13, maxFieldFound);
14, maxFieldFound);
}
@@ -993,4 +993,53 @@ public final class GroupChangeUtil_resolveConflict_Test {
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
}
@Test
public void field_28__terminate_group_preserved_when_group_not_terminated() {
DecryptedGroup groupState = new DecryptedGroup.Builder()
.revision(5)
.terminated(false)
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.revision(6)
.terminateGroup(true)
.build();
GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder()
.version(6)
.terminate_group(new GroupChange.Actions.TerminateGroupAction())
.build();
GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder()
.version(6)
.terminate_group(new GroupChange.Actions.TerminateGroupAction())
.build();
assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build());
}
@Test
public void field_28__terminate_group_removed_when_group_already_terminated() {
DecryptedGroup groupState = new DecryptedGroup.Builder()
.revision(5)
.terminated(true)
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.revision(6)
.terminateGroup(true)
.build();
GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder()
.version(6)
.terminate_group(new GroupChange.Actions.TerminateGroupAction())
.build();
GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder()
.version(6)
.build();
assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build());
}
}

View File

@@ -46,7 +46,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(),
27, maxFieldFound);
28, maxFieldFound);
}
/**
@@ -59,7 +59,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
13, maxFieldFound);
14, maxFieldFound);
}
@@ -785,4 +785,43 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
assertTrue(DecryptedGroupExtensions.getChangedFields(resolvedChanges).isEmpty());
}
@Test
public void field_28__terminate_group_preserved_when_group_not_terminated() {
DecryptedGroup groupState = new DecryptedGroup.Builder()
.revision(5)
.terminated(false)
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.revision(6)
.terminateGroup(true)
.build();
DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder()
.revision(6)
.terminateGroup(true)
.build();
assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build());
}
@Test
public void field_28__terminate_group_removed_when_group_already_terminated() {
DecryptedGroup groupState = new DecryptedGroup.Builder()
.revision(5)
.terminated(true)
.build();
DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder()
.revision(6)
.terminateGroup(true)
.build();
DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder()
.revision(6)
.build();
assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build());
}
}

View File

@@ -73,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(),
27,
28,
maxFieldFound);
}
@@ -544,6 +544,16 @@ public final class GroupsV2Operations_decrypt_change_Test {
assertDecryption(encryptedChange, expectedDecryptedChange);
}
@Test
public void can_pass_through_terminate_group_field_28() {
GroupChange.Actions.Builder encryptedChange = groupOperations.createTerminateGroup();
DecryptedGroupChange.Builder expectedDecryptedChange = new DecryptedGroupChange.Builder()
.terminateGroup(true);
assertDecryption(encryptedChange, expectedDecryptedChange);
}
private static ProfileKey newProfileKey() {
try {
return new ProfileKey(Util.getSecretBytes(32));

View File

@@ -58,7 +58,7 @@ public final class GroupsV2Operations_decrypt_group_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(Group.class);
assertEquals("GroupOperations and its tests need updating to account for new fields on " + Group.class.getName(),
13, maxFieldFound);
14, maxFieldFound);
}
@Test
@@ -310,6 +310,17 @@ public final class GroupsV2Operations_decrypt_group_Test {
assertEquals(new DecryptedBannedMember.Builder().serviceIdBytes(member1.toByteString()).build(), decryptedGroup.bannedMembers.get(0));
}
@Test
public void pass_through_terminated_field_14() throws VerificationFailedException, InvalidGroupStateException {
Group group = new Group.Builder()
.terminated(true)
.build();
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
assertEquals(true, decryptedGroup.terminated);
}
private ByteString encryptProfileKey(ACI aci, ProfileKey profileKey) {
return ByteString.of(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, aci.getLibSignalAci()).serialize());
}