Add additional group terminate checks.

This commit is contained in:
Cody Henthorne
2026-03-24 16:12:45 -04:00
parent d97bde3959
commit c81f40eb74
20 changed files with 201 additions and 47 deletions
@@ -744,7 +744,8 @@ final class GroupManagerV2 {
throw new IOException(e);
}
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response));
RecipientId terminatorRecipientId = (decryptedGroupState.terminated && !previousGroupState.terminated) ? Recipient.self().getId() : null;
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId);
GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState);
RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange, sendToMembers);
@@ -986,7 +987,7 @@ final class GroupManagerV2 {
}
} else if (groupAlreadyExists && requestToJoin) {
Log.i(TAG, "Group already exists, but we are requesting to join, updating with new placeholder, alreadyPending: " + isAlreadyPendingApproval);
groupDatabase.update(groupMasterKey, decryptedGroup, null);
groupDatabase.update(groupMasterKey, decryptedGroup, null, null);
}
RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromGroupId(groupId);
@@ -1250,7 +1251,7 @@ final class GroupManagerV2 {
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, DecryptChangeVerificationMode.alreadyTrusted()).get();
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.revision), null);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.revision), null, null);
sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange, false);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException;
import java.io.IOException;
@@ -18,11 +19,13 @@ public enum GroupChangeFailureReason {
NOT_A_MEMBER,
BUSY,
NETWORK,
GROUP_TERMINATED,
OTHER;
@SuppressLint("SuspiciousIndentation")
public static @NonNull GroupChangeFailureReason fromException(@NonNull Throwable e) {
if (e instanceof MembershipNotSuitableForV2Exception) return GroupChangeFailureReason.NOT_GV2_CAPABLE;
if (e instanceof GroupTerminatedException) return GroupChangeFailureReason.GROUP_TERMINATED;
if (e instanceof IOException) return GroupChangeFailureReason.NETWORK;
if (e instanceof GroupNotAMemberException) return GroupChangeFailureReason.NOT_A_MEMBER;
if (e instanceof GroupChangeBusyException) return GroupChangeFailureReason.BUSY;
@@ -21,6 +21,7 @@ public final class GroupErrors {
case NOT_A_MEMBER : return R.string.ManageGroupActivity_youre_not_a_member_of_the_group;
case BUSY : return R.string.ManageGroupActivity_failed_to_update_the_group_please_retry_later;
case NETWORK : return R.string.ManageGroupActivity_failed_to_update_the_group_due_to_a_network_error_please_retry_later;
case GROUP_TERMINATED : return R.string.MessageRecord_the_group_was_terminated;
default : return R.string.ManageGroupActivity_failed_to_update_the_group;
}
}
@@ -3,5 +3,6 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
enum FetchGroupDetailsError {
GroupLinkNotActive,
BannedFromGroup,
NetworkError
NetworkError,
GroupTerminated
}
@@ -175,6 +175,10 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
groupName.setText(R.string.GroupJoinBottomSheetDialogFragment_link_error);
groupDetails.setText(R.string.GroupJoinBottomSheetDialogFragment_joining_via_this_link_failed_try_joining_again_later);
break;
case GroupTerminated:
groupName.setText(R.string.GroupJoinBottomSheetDialogFragment_cant_join_group);
groupDetails.setText(R.string.GroupJoinBottomSheetDialogFragment_this_group_has_been_ended);
break;
}
}
@@ -184,6 +188,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
case BANNED : return getString(R.string.GroupJoinBottomSheetDialogFragment_you_cant_join_this_group_via_the_group_link_because_an_admin_removed_you);
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
case LIMIT_REACHED : return getString(R.string.GroupJoinBottomSheetDialogFragment_group_limit_reached);
case GROUP_TERMINATED : return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_has_been_ended);
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
}
}
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException;
import java.io.IOException;
@@ -38,6 +39,8 @@ final class GroupJoinRepository {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
callback.onComplete(getGroupDetails());
} catch (GroupTerminatedException e) {
callback.onError(FetchGroupDetailsError.GroupTerminated);
} catch (IOException e) {
callback.onError(FetchGroupDetailsError.NetworkError);
} catch (GroupLinkNotActiveException e) {
@@ -60,6 +63,9 @@ final class GroupJoinRepository {
groupDetails.getAvatarBytes());
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
} catch (GroupTerminatedException e) {
Log.w(TAG, "Group is terminated", e);
callback.onError(JoinGroupError.GROUP_TERMINATED);
} catch (IOException e) {
Log.w(TAG, "Network error", e);
callback.onError(JoinGroupError.NETWORK_ERROR);
@@ -7,4 +7,5 @@ enum JoinGroupError {
FAILED,
LIMIT_REACHED,
NETWORK_ERROR,
GROUP_TERMINATED,
}
@@ -41,10 +41,12 @@ import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException
import org.whispersystems.signalservice.api.groupsv2.ReceivedGroupSendEndorsements
@@ -52,6 +54,7 @@ import org.whispersystems.signalservice.api.groupsv2.getChangedFields
import org.whispersystems.signalservice.api.groupsv2.isSilent
import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException
import java.io.IOException
import java.util.Optional
@@ -214,6 +217,21 @@ class GroupsV2StateProcessor private constructor(
if (currentLocalState != null && DecryptedGroupUtil.isPendingOrRequesting(currentLocalState, serviceIds)) {
Log.w(TAG, "$logPrefix Unable to query server for group. Server says we're not in group, but we think we are a pending or requesting member")
try {
groupsApi.getGroupJoinInfo(groupSecretParams, Optional.empty(), groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))
} catch (e: GroupTerminatedException) {
Log.i(TAG, "$logPrefix Group was terminated while join request was pending, marking locally")
profileAndMessageHelper.markTerminatedLocally()
} catch (e: GroupLinkNotActiveException) {
if (e.reason == GroupLinkNotActiveException.Reason.BANNED) {
Log.i(TAG, "$logPrefix Join request was rejected (banned) while pending, marking locally")
profileAndMessageHelper.markJoinRequestRejectedLocally()
} else {
Log.i(TAG, "$logPrefix Group link not active while checking pending join for group termination")
}
} catch (e: IOException) {
Log.w(TAG, "$logPrefix Network error while checking if group terminated", e)
}
} else {
Log.w(TAG, "$logPrefix Unable to query server for group $groupId server says we're not in group, we agree, inserting leave message")
profileAndMessageHelper.leaveGroupLocally(serviceIds)
@@ -525,20 +543,20 @@ class GroupsV2StateProcessor private constructor(
Log.i(TAG, "$logPrefix Local state (revision: ${currentLocalState?.revision}) does not match, updating to ${updatedGroupState.revision}")
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements)
if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
val terminatingChange = groupStateDiff.serverHistory
val terminatorRecipientId: RecipientId? = if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
groupStateDiff.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) }
?.let { RecipientId.from(it) }
} else {
null
}
if (terminatingChange != null) {
val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes)
if (editorServiceId != null) {
val terminatorRecipientId = RecipientId.from(editorServiceId)
SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId)
}
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
if (terminatorRecipientId != null) {
profileAndMessageHelper.stopAllTypingForGroup()
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
@@ -561,7 +579,7 @@ class GroupsV2StateProcessor private constructor(
return InternalUpdateResult.Updated(updatedGroupState)
}
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?) {
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
val previousGroupState = groupStateDiff.previousGroupState
if (groupSendEndorsements != null) {
@@ -573,12 +591,12 @@ class GroupsV2StateProcessor private constructor(
if (groupId == null) {
Log.w(TAG, "$logPrefix Group create failed, trying to update")
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
}
updatedGroupState.avatar.isNotEmpty()
} else {
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
updatedGroupState.avatar != previousGroupState.avatar
}
@@ -735,6 +753,89 @@ class GroupsV2StateProcessor private constructor(
SignalDatabase.groups.remove(groupId, Recipient.self().id)
}
fun markTerminatedLocally() {
val group = SignalDatabase.groups.getGroup(groupId).orNull()
if (group == null) {
Log.w(TAG, "Group not found when inserting terminated message for $groupId")
return
}
if (group.isTerminated) {
Log.w(TAG, "Group $groupId is already marked as terminated.")
return
}
val groupRecipient = Recipient.externalGroupExact(groupId)
val decryptedGroup = group.requireV2GroupProperties().decryptedGroup
val simulatedGroupState = decryptedGroup.copy(terminated = true)
val simulatedGroupChange = DecryptedGroupChange(terminateGroup = true)
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
val terminateMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis(), isSelfGroupAdd = false)
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(terminateMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert terminated group message for $groupId", e)
}
SignalDatabase.groups.update(masterKey, simulatedGroupState, null)
}
fun markJoinRequestRejectedLocally() {
val group = SignalDatabase.groups.getGroup(groupId).orNull()
if (group == null) {
Log.w(TAG, "Group not found when inserting join request rejection message for $groupId")
return
}
val decryptedGroup = group.requireV2GroupProperties().decryptedGroup
if (decryptedGroup.requestingMembers.none { ACI.parseOrNull(it.aciBytes) == aci }) {
Log.w(TAG, "Not a requesting member of $groupId, skipping rejection insert")
return
}
val groupRecipient = Recipient.externalGroupExact(groupId)
val simulatedGroupState = decryptedGroup.copy(
requestingMembers = decryptedGroup.requestingMembers.filter { ACI.parseOrNull(it.aciBytes) != aci }
)
val simulatedGroupChange = DecryptedGroupChange(
editorServiceIdBytes = ACI.UNKNOWN.toByteString(),
deleteRequestingMembers = listOf(aci.toByteString())
)
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
val rejectedMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis(), isSelfGroupAdd = false)
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(rejectedMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert rejected join request message for $groupId", e)
}
SignalDatabase.groups.update(masterKey, simulatedGroupState, null)
}
fun stopAllTypingForGroup() {
if (TextSecurePreferences.isTypingIndicatorsEnabled(AppDependencies.application)) {
val threadId = SignalDatabase.threads.getThreadIdFor(SignalDatabase.recipients.getOrInsertFromGroupId(groupId))
if (threadId != null) {
AppDependencies.typingStatusRepository.stopAllTypingForThread(threadId)
}
}
}
fun persistLearnedProfileKeys(groupStateDiff: GroupStateDiff) {
val profileKeys = ProfileKeySet()