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/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..5dec030b 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 @@ -912,6 +912,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..8d8c94a0 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 diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index d127d693..2fa10457 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) 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/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index c67ee297..b93d0920 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -871,7 +871,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..7c67ece2 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),