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

@@ -75,6 +75,23 @@ public final class GroupManager {
}
}
@WorkerThread
public static void terminateGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws GroupChangeBusyException, GroupChangeFailedException, IOException
{
try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) {
edit.terminateGroup();
SignalDatabase.groups().setTerminatedBy(groupId, Recipient.self().getId());
Log.i(TAG, "Terminated group " + groupId);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, "Insufficient rights to terminate " + groupId, e);
throw new GroupChangeFailedException(e);
} catch (GroupNotAMemberException e) {
Log.w(TAG, "Not a member of " + groupId, e);
throw new GroupChangeFailedException(e);
}
}
@WorkerThread
public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId, boolean sendToMembers)
throws GroupChangeBusyException, GroupChangeFailedException, IOException
@@ -216,7 +233,7 @@ public final class GroupManager {
{
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.acceptInvite();
SignalDatabase.groups().setActive(groupId, true);
SignalDatabase.groups().setMember(groupId, true);
}
}

View File

@@ -435,6 +435,13 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(selfAci, change);
}
@WorkerThread
@NonNull GroupManager.GroupActionResult terminateGroup()
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(selfAci, groupOperations.createTerminateGroup());
}
@WorkerThread
void leaveGroup(boolean sendToMembers)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException

View File

@@ -137,6 +137,10 @@ public final class LiveGroup {
return recipient;
}
public LiveData<GroupRecord> getGroupRecord() {
return groupRecord;
}
public LiveData<Boolean> isSelfAdmin() {
return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self()));
}
@@ -149,6 +153,14 @@ public final class LiveGroup {
return Transformations.map(groupRecord, GroupRecord::isActive);
}
public LiveData<Boolean> isTerminated() {
return Transformations.map(groupRecord, GroupRecord::isTerminated);
}
public LiveData<Boolean> isMember() {
return Transformations.map(groupRecord, GroupRecord::isMember);
}
public LiveData<Boolean> getRecipientIsAdmin(@NonNull RecipientId recipientId) {
return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId)));
}
@@ -201,11 +213,13 @@ public final class LiveGroup {
}
public LiveData<Boolean> selfCanEditGroupAttributes() {
return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), isActive(),
(level, access, active) -> active && applyAccessControl(level, access));
}
public LiveData<Boolean> selfCanAddMembers() {
return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), isActive(),
(level, access, active) -> active && applyAccessControl(level, access));
}
/**

View File

@@ -98,6 +98,8 @@ class MemberLabelRepository private constructor(
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) {
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false
if (groupRecord.isTerminated) return@withContext false
val memberLevel = groupRecord.memberLevel(recipient)
if (groupRecord.memberLabelAccessControl == GroupAccessControl.ONLY_ADMINS) {
memberLevel == GroupTable.MemberLevel.ADMINISTRATOR

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.ui
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import java.io.IOException
/**
* Handles the end group flow for admins. Shows a two-step confirmation
* dialog before terminating the group.
*/
object EndGroupDialog {
private val TAG = Log.tag(EndGroupDialog::class.java)
@JvmStatic
fun show(activity: FragmentActivity, groupId: GroupId.V2, groupName: String) {
MaterialAlertDialogBuilder(activity)
.setTitle(activity.getString(R.string.EndGroupDialog__end_s, groupName))
.setMessage(R.string.EndGroupDialog__members_will_no_longer_be_able_to_send)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ ->
showFinalConfirmation(activity, groupId)
}
.show()
}
private fun showFinalConfirmation(activity: FragmentActivity, groupId: GroupId.V2) {
MaterialAlertDialogBuilder(activity)
.setMessage(R.string.EndGroupDialog__this_will_end_the_group_permanently)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ ->
performEndGroup(activity, groupId)
}
.show()
}
private fun performEndGroup(activity: FragmentActivity, groupId: GroupId.V2) {
val progressDialog = SignalProgressDialog.show(
context = activity,
message = activity.getString(R.string.EndGroupDialog__ending_group),
indeterminate = true
)
activity.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
GroupManager.terminateGroup(activity, groupId)
GroupChangeResult.SUCCESS
} catch (e: GroupChangeException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
} catch (e: GroupChangeBusyException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
} catch (e: IOException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
}
}
progressDialog.dismiss()
if (!result.isSuccess) {
showRetryDialog(activity, groupId)
}
}
}
private fun showRetryDialog(activity: FragmentActivity, groupId: GroupId.V2) {
MaterialAlertDialogBuilder(activity)
.setMessage(R.string.EndGroupDialog__ending_the_group_failed)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__try_again) { _, _ ->
performEndGroup(activity, groupId)
}
.show()
}
}

View File

@@ -116,7 +116,12 @@ class GroupsV2StateProcessor private constructor(
return GroupUpdateResult.CONSISTENT_OR_AHEAD
}
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isActive)) {
if (currentLocalState.terminated) {
Log.i(TAG, "$logPrefix Group is terminated, not updating")
return GroupUpdateResult.CONSISTENT_OR_AHEAD
}
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isMember)) {
InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState)
is InternalUpdateResult.NotAMember -> throw result.exception
@@ -232,6 +237,11 @@ class GroupsV2StateProcessor private constructor(
return false
}
if (currentLocalState.terminated) {
Log.w(TAG, "$logPrefix Ignoring P2P group change because group is terminated")
return false
}
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
return false
@@ -320,8 +330,8 @@ class GroupsV2StateProcessor private constructor(
val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision)
val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState
if (groupRecord.map { it.isActive }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.w(TAG, "$logPrefix Local state is not active, but server is returning state for us, apply regardless of revision")
if (groupRecord.map { it.isMember }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.w(TAG, "$logPrefix Local state is not a member, but server is returning state for us, apply regardless of revision")
} else if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}")
@@ -436,7 +446,7 @@ class GroupsV2StateProcessor private constructor(
}
private fun notInGroupAndNotBeingAdded(groupRecord: Optional<GroupRecord>, signedGroupChange: DecryptedGroupChange): Boolean {
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isMember
val addedAsMember = signedGroupChange
.newMembers
@@ -517,6 +527,20 @@ class GroupsV2StateProcessor private constructor(
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements)
if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
val terminatingChange = groupStateDiff.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
if (terminatingChange != null) {
val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes)
if (editorServiceId != null) {
val terminatorRecipientId = RecipientId.from(editorServiceId)
SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId)
}
}
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
@@ -707,7 +731,7 @@ class GroupsV2StateProcessor private constructor(
Log.w(TAG, "Failed to insert leave message for $groupId", e)
}
SignalDatabase.groups.setActive(groupId, false)
SignalDatabase.groups.setMember(groupId, false)
SignalDatabase.groups.remove(groupId, Recipient.self().id)
}
@@ -772,20 +796,24 @@ class GroupsV2StateProcessor private constructor(
}
} else {
try {
val isGroupAdd = updateDescription
.groupChangeUpdate!!
.updates
val updates = updateDescription.groupChangeUpdate!!.updates
val isGroupAdd = updates
.asSequence()
.mapNotNull { it.groupMemberAddedUpdate }
.any { serviceIds.matches(it.newMemberAci) }
val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isGroupAdd, serverGuid)
val isGroupTerminate = updates.any { it.groupTerminateChangeUpdate != null }
val isNotifiable = isGroupAdd || isGroupTerminate
val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isNotifiable, serverGuid)
val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage)
if (insertResult.isPresent) {
SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false)
if (isGroupAdd) {
if (isNotifiable) {
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
}
} else {