mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-03 23:15:44 +01:00
Add group terminate support.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user