From 13819c8f0992eed4773c821260e7dbebabddaf12 Mon Sep 17 00:00:00 2001 From: Javair Ratliff <9100196+FailSpy@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:16:49 -0400 Subject: [PATCH] Support the GroupsV2 "end group" (terminate) feature (#2098) * Show and enforce the terminated state of a group A group admin can permanently terminate a group. Afterwards the group is read-only for everyone, not even an admin can send messages or start calls; members keep access to the existing history. Other clients silently drop messages sent to a terminated group. Read the DecryptedGroup.terminated flag through GroupInfo.isTerminated() and: - surface it in listGroups (json and plain text) and as the DBus IsTerminated group property - refuse to send to a terminated group locally (messages, reactions, typing and group stories) rather than send one that other clients ignore - drop incoming messages addressed to a terminated group * Add terminateGroup command to terminate a group for everyone Let a group admin permanently terminate a GroupV2 group via the TerminateGroupAction (Groups.proto field 28, change epoch 7) the pinned library already exposes as GroupsV2Operations.createTerminateGroup(). - GroupV2Helper.terminateGroup builds and commits the change - GroupHelper.terminateGroup resolves/refreshes the group (v2 only), sends the update to members and syncs storage, with the same conflict-retry as updateGroup - exposed through Manager.terminateGroup, the terminateGroup CLI/JSON-RPC command and the DBus Group.terminateGroup method --------- Co-authored-by: FailSpy --- .../org/asamk/signal/manager/Manager.java | 4 + .../org/asamk/signal/manager/api/Group.java | 6 +- .../signal/manager/helper/GroupHelper.java | 22 ++++ .../signal/manager/helper/GroupV2Helper.java | 8 ++ .../helper/IncomingMessageHandler.java | 9 ++ .../signal/manager/helper/SendHelper.java | 6 +- .../signal/manager/internal/ManagerImpl.java | 9 ++ .../manager/storage/groups/GroupInfo.java | 2 + .../manager/storage/groups/GroupInfoV1.java | 5 + .../manager/storage/groups/GroupInfoV2.java | 5 + .../groups/GroupInfoTerminatedTest.java | 104 ++++++++++++++++++ man/signal-cli-dbus.5.adoc | 6 + man/signal-cli.1.adoc | 10 ++ src/main/java/org/asamk/Signal.java | 3 + .../org/asamk/signal/commands/Commands.java | 1 + .../signal/commands/ListGroupsCommand.java | 14 ++- .../commands/TerminateGroupCommand.java | 54 +++++++++ .../asamk/signal/dbus/DbusManagerImpl.java | 18 ++- .../org/asamk/signal/dbus/DbusSignalImpl.java | 14 +++ 19 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/storage/groups/GroupInfoTerminatedTest.java create mode 100644 src/main/java/org/asamk/signal/commands/TerminateGroupCommand.java diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 80f24e39..7d0c73af 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -184,6 +184,10 @@ public interface Manager extends Closeable { void deleteGroup(GroupId groupId) throws IOException; + SendGroupMessageResults terminateGroup( + GroupId groupId + ) throws IOException, GroupNotFoundException, NotAGroupMemberException; + Pair createGroup( String name, Set members, diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Group.java b/lib/src/main/java/org/asamk/signal/manager/api/Group.java index fc668684..8a7d9a62 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Group.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Group.java @@ -22,7 +22,8 @@ public record Group( GroupPermission permissionEditDetails, GroupPermission permissionSendMessage, boolean isMember, - boolean isAdmin + boolean isAdmin, + boolean isTerminated ) { public static Group from( @@ -59,6 +60,7 @@ public record Group( groupInfo.getPermissionEditDetails(), groupInfo.getPermissionSendMessage(), groupInfo.isMember(selfRecipientId), - groupInfo.isAdmin(selfRecipientId)); + groupInfo.isAdmin(selfRecipientId), + groupInfo.isTerminated()); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 1c750358..7d689c34 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -363,6 +363,28 @@ public class GroupHelper { return results; } + public SendGroupMessageResults terminateGroup(final GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException { + final var group = getGroupForUpdating(groupId); + if (!(group instanceof GroupInfoV2)) { + throw new IOException("Terminating a group is only supported for Signal group v2 groups."); + } + + SendGroupMessageResults results; + try { + results = terminateGroupV2((GroupInfoV2) group); + } catch (ConflictException e) { + // Detected conflicting update, refreshing group and trying again + results = terminateGroupV2((GroupInfoV2) getGroup(groupId, true)); + } + context.getJobExecutor().enqueueJob(new SyncStorageJob()); + return results; + } + + private SendGroupMessageResults terminateGroupV2(final GroupInfoV2 group) throws IOException { + final var groupGroupChangePair = context.getGroupV2Helper().terminateGroup(group); + return sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException { var group = getGroupForUpdating(groupId); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index 888dbb1c..83e949b5 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -543,6 +543,14 @@ class GroupV2Helper { return commitChange(groupInfoV2, change); } + Pair terminateGroup( + GroupInfoV2 groupInfoV2 + ) throws IOException { + final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2); + final var change = groupOperations.createTerminateGroup(); + return commitChange(groupInfoV2, change); + } + Pair setMemberLabels( GroupInfoV2 groupInfoV2, String labelEmoji, diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 8ded3a8f..e33f0fbd 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -837,6 +837,15 @@ public final class IncomingMessageHandler { return true; } + if (group.isTerminated()) { + return message == null + || message.getBody().isPresent() + || message.getAttachments().isPresent() + || message.getQuote().isPresent() + || message.getPreviews().isPresent() + || message.getMentions().isPresent() + || message.getSticker().isPresent(); + } if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) { return message == null || message.getBody().isPresent() diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 5ea52e4c..86e73c85 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -567,7 +567,7 @@ public class SendHelper { return results; } - private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { + private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException { var g = context.getGroupHelper().getGroup(groupId); if (g == null) { throw new GroupNotFoundException(groupId); @@ -575,6 +575,10 @@ public class SendHelper { if (!g.isMember(account.getSelfRecipientId())) { throw new NotAGroupMemberException(groupId, g.getTitle()); } + if (g.isTerminated()) { + // Other clients drop messages sent to a terminated group. + throw new GroupSendingNotAllowedException(groupId, g.getTitle()); + } if (!g.isProfileSharingEnabled()) { g.setProfileSharingEnabled(true); account.getGroupStore().updateGroup(g); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index b1c02d7d..34ba3586 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -610,6 +610,11 @@ public class ManagerImpl implements Manager { context.getGroupHelper().deleteGroup(groupId); } + @Override + public SendGroupMessageResults terminateGroup(GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException { + return context.getGroupHelper().terminateGroup(groupId); + } + @Override public Pair createGroup( String name, @@ -912,6 +917,10 @@ public class ManagerImpl implements Manager { if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { throw new IOException("Stories are only supported for V2 groups"); } + if (groupInfoV2.isTerminated()) { + // Other clients drop messages sent to a terminated group. + throw new IOException("Cannot send a story to a group that has been terminated"); + } final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java index 25142c26..102cff85 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java @@ -66,6 +66,8 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 { public abstract boolean isAnnouncementGroup(); + public abstract boolean isTerminated(); + public abstract GroupPermission getPermissionAddMember(); public abstract GroupPermission getPermissionEditDetails(); diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java index 2b601ed8..0fb21868 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java @@ -113,6 +113,11 @@ public final class GroupInfoV1 extends GroupInfo { return false; } + @Override + public boolean isTerminated() { + return false; + } + @Override public GroupPermission getPermissionAddMember() { return GroupPermission.EVERY_MEMBER; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java index 3616dceb..b884cd2a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java @@ -211,6 +211,11 @@ public final class GroupInfoV2 extends GroupInfo { return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED; } + @Override + public boolean isTerminated() { + return this.group != null && Boolean.TRUE.equals(this.group.terminated); + } + @Override public GroupPermission getPermissionAddMember() { final var accessControl = getAccessControl(); diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/groups/GroupInfoTerminatedTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/groups/GroupInfoTerminatedTest.java new file mode 100644 index 00000000..95ecb20b --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/groups/GroupInfoTerminatedTest.java @@ -0,0 +1,104 @@ +package org.asamk.signal.manager.storage.groups; + +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupIdV2; +import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.RecipientResolver; +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.junit.jupiter.api.Test; +import org.signal.core.models.ServiceId; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.groups.GroupMasterKey; +import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup; +import org.whispersystems.signalservice.api.push.DistributionId; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GroupInfoTerminatedTest { + + private static final RecipientResolver UNUSED_RESOLVER = new RecipientResolver() { + @Override + public RecipientId resolveRecipient(final RecipientAddress address) { + throw new UnsupportedOperationException(); + } + + @Override + public RecipientId resolveRecipient(final long recipientId) { + throw new UnsupportedOperationException(); + } + + @Override + public RecipientId resolveRecipient(final String identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public RecipientId resolveRecipient(final ServiceId serviceId) { + throw new UnsupportedOperationException(); + } + }; + + private static GroupMasterKey masterKey() { + final var bytes = new byte[32]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) (i + 1); + } + try { + return new GroupMasterKey(bytes); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } + + private static GroupInfoV2 groupV2(final DecryptedGroup group) { + final var masterKey = masterKey(); + return new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey), + masterKey, + group, + DistributionId.create(), + false, + false, + false, + null, + UNUSED_RESOLVER); + } + + @Test + void v1GroupsAreNeverTerminated() { + final var group = new GroupInfoV1(GroupId.v1(new byte[16])); + assertFalse(group.isTerminated()); + } + + @Test + void v2ReadsTerminatedFlagFromDecryptedGroup() { + assertTrue(groupV2(new DecryptedGroup.Builder().terminated(true).build()).isTerminated()); + assertFalse(groupV2(new DecryptedGroup.Builder().terminated(false).build()).isTerminated()); + assertFalse(groupV2(new DecryptedGroup.Builder().build()).isTerminated()); + } + + @Test + void v2IsNotTerminatedWhenGroupStateMissing() { + final var masterKey = masterKey(); + final var group = new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey), masterKey, UNUSED_RESOLVER); + assertFalse(group.isTerminated()); + } + + @Test + void groupApiRecordCarriesTerminatedFromModel() { + final RecipientId self = TestRecipientId.createTestId(1); + + final var terminated = Group.from(groupV2(new DecryptedGroup.Builder().terminated(true).build()), + recipientId -> null, + self); + assertTrue(terminated.isTerminated()); + + final var live = Group.from(groupV2(new DecryptedGroup.Builder().terminated(false).build()), + recipientId -> null, + self); + assertFalse(live.isTerminated()); + } +} diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index 9246e9ed..34d102b7 100644 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -452,6 +452,7 @@ Groups have the following (case-sensitive) properties: * IsBlocked : true=member will not receive group messages; false=not blocked * IsMember (read-only) : always true (object path exists only for group members) * IsAdmin (read-only) : true=member has admin privileges; false=not admin +* IsTerminated (read-only) : true=group was permanently ended by an admin; nobody (not even an admin) can send messages or start calls, sending is rejected locally * MessageExpirationTimer : int32 representing message expiration time for group * Members (read-only) : String array of group members' phone numbers * PendingMembers (read-only) : String array of pending members' phone numbers @@ -502,6 +503,11 @@ Exceptions: Failure quitGroup() -> <>:: Exceptions: Failure, LastGroupAdmin +terminateGroup() -> <>:: +Permanently terminate the group for all members (requires admin privileges). Afterwards nobody can send messages or start calls; members keep access to the existing history. + +Exceptions: Failure + removeAdmins(recipients) -> <>:: * recipients : String array of phone numbers diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 217f9f69..6fcdfee1 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -786,6 +786,16 @@ Specify the recipient group ID in base64 encoding. *--delete*:: Delete local group data completely after quitting group. +=== terminateGroup + +Permanently terminate a group for all members. +This requires admin privileges. +Afterwards no member (not even an admin) can send messages or start calls in the group; members keep access to the existing message history. +Only supported for Signal group v2 groups. + +*-g* GROUP, *--group-id* GROUP:: +Specify the group ID in base64 encoding. + === listGroups Show a list of known groups and related information. diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index d127d693..728ff4c6 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -595,6 +595,7 @@ public interface Signal extends DBusInterface { @DBusProperty(name = "IsBlocked", type = Boolean.class) @DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ) @DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ) + @DBusProperty(name = "IsTerminated", type = Boolean.class, access = DBusProperty.Access.READ) @DBusProperty(name = "MessageExpirationTimer", type = Integer.class) @DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ) @DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ) @@ -611,6 +612,8 @@ public interface Signal extends DBusInterface { void deleteGroup() throws Error.Failure; + void terminateGroup() throws Error.Failure; + void addMembers(List recipients) throws Error.Failure; void removeMembers(List recipients) throws Error.Failure; diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 4787c7d1..a57c8c97 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -61,6 +61,7 @@ public class Commands { addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new StartChangeNumberCommand()); addCommand(new StartLinkCommand()); + addCommand(new TerminateGroupCommand()); addCommand(new TrustCommand()); addCommand(new UnblockCommand()); addCommand(new UnregisterCommand()); diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index 9084e907..b8bd04bf 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -79,12 +79,13 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { final var groupInviteLink = group.groupInviteLinkUrl(); writer.println( - "Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}", + "Id: {} Name: {} Description: {} Active: {} Blocked: {} Terminated: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}", group.groupId().toBase64(), group.title(), group.description(), group.isMember(), group.isBlocked(), + group.isTerminated(), resolveMembers(group.members()), resolveMemberAddress(group.pendingMembers()), resolveMemberAddress(group.requestingMembers()), @@ -92,11 +93,12 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s", groupInviteLink == null ? '-' : groupInviteLink.getUrl()); } else { - writer.println("Id: {} Name: {} Active: {} Blocked: {}", + writer.println("Id: {} Name: {} Active: {} Blocked: {} Terminated: {}", group.groupId().toBase64(), group.title(), group.isMember(), - group.isBlocked()); + group.isBlocked(), + group.isTerminated()); } } @@ -133,7 +135,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { group.permissionAddMember().name(), group.permissionEditDetails().name(), group.permissionSendMessage().name(), - groupInviteLink == null ? null : groupInviteLink.getUrl()); + groupInviteLink == null ? null : groupInviteLink.getUrl(), + group.isTerminated()); }).toList(); jsonWriter.write(jsonGroups); } @@ -161,7 +164,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { String permissionAddMember, String permissionEditDetails, String permissionSendMessage, - String groupInviteLink + String groupInviteLink, + boolean isTerminated ) {} private record JsonGroupMemberAddress(String number, String uuid) {} diff --git a/src/main/java/org/asamk/signal/commands/TerminateGroupCommand.java b/src/main/java/org/asamk/signal/commands/TerminateGroupCommand.java new file mode 100644 index 00000000..8161e614 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/TerminateGroupCommand.java @@ -0,0 +1,54 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.GroupNotFoundException; +import org.asamk.signal.manager.api.NotAGroupMemberException; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.util.CommandUtil; + +import java.io.IOException; + +import static org.asamk.signal.util.SendMessageResultUtils.outputResult; + +public class TerminateGroupCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "terminateGroup"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help( + "Permanently terminate a group for all members. Requires admin privileges; afterwards nobody can send messages or start calls."); + subparser.addArgument("-g", "--group-id", "--group").required(true).help("Specify the group ID."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var groupId = CommandUtil.getGroupId(ns.getString("group-id")); + + try { + final var results = m.terminateGroup(groupId); + outputResult(outputWriter, results); + } catch (IOException e) { + throw new IOErrorException("Failed to send message: " + + e.getMessage() + + " (" + + e.getClass().getSimpleName() + + ")", e); + } catch (GroupNotFoundException | NotAGroupMemberException e) { + throw new UserErrorException("Failed to terminate group: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index c67ee297..8467c996 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -327,6 +327,21 @@ public class DbusManagerImpl implements Manager { group.deleteGroup(); } + @Override + public SendGroupMessageResults terminateGroup( + final GroupId groupId + ) throws IOException, GroupNotFoundException, NotAGroupMemberException { + final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class); + try { + group.terminateGroup(); + } catch (Signal.Error.GroupNotFound e) { + throw new GroupNotFoundException(groupId); + } catch (Signal.Error.NotAGroupMember e) { + throw new NotAGroupMemberException(groupId, group.Get("org.asamk.Signal.Group", "Name")); + } + return new SendGroupMessageResults(0, List.of()); + } + @Override public Pair createGroup( final String name, @@ -871,7 +886,8 @@ public class DbusManagerImpl implements Manager { GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()), GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()), (boolean) group.get("IsMember").getValue(), - (boolean) group.get("IsAdmin").getValue()); + (boolean) group.get("IsAdmin").getValue(), + group.get("IsTerminated") != null && (boolean) group.get("IsTerminated").getValue()); } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { throw new AssertionError(e); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index f975f5f0..1d53fe7d 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -1308,6 +1308,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked), new DbusProperty<>("IsMember", () -> getGroup().isMember()), new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()), + new DbusProperty<>("IsTerminated", () -> getGroup().isTerminated()), new DbusProperty<>("MessageExpirationTimer", () -> getGroup().messageExpirationTimer(), this::setMessageExpirationTime), @@ -1375,6 +1376,19 @@ public class DbusSignalImpl implements Signal, AutoCloseable { updateGroups(); } + @Override + public void terminateGroup() throws Error.Failure { + try { + m.terminateGroup(groupId); + } catch (GroupNotFoundException e) { + throw new Error.GroupNotFound(e.getMessage()); + } catch (NotAGroupMemberException e) { + throw new Error.NotAGroupMember(e.getMessage()); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + @Override public void addMembers(final List recipients) throws Error.Failure { final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());