1 package org
.asamk
.signal
.manager
.helper
;
3 import org
.asamk
.signal
.manager
.api
.AttachmentInvalidException
;
4 import org
.asamk
.signal
.manager
.api
.GroupId
;
5 import org
.asamk
.signal
.manager
.api
.GroupIdV1
;
6 import org
.asamk
.signal
.manager
.api
.GroupIdV2
;
7 import org
.asamk
.signal
.manager
.api
.GroupInviteLinkUrl
;
8 import org
.asamk
.signal
.manager
.api
.GroupLinkState
;
9 import org
.asamk
.signal
.manager
.api
.GroupNotFoundException
;
10 import org
.asamk
.signal
.manager
.api
.GroupPermission
;
11 import org
.asamk
.signal
.manager
.api
.GroupSendingNotAllowedException
;
12 import org
.asamk
.signal
.manager
.api
.InactiveGroupLinkException
;
13 import org
.asamk
.signal
.manager
.api
.LastGroupAdminException
;
14 import org
.asamk
.signal
.manager
.api
.NotAGroupMemberException
;
15 import org
.asamk
.signal
.manager
.api
.Pair
;
16 import org
.asamk
.signal
.manager
.api
.PendingAdminApprovalException
;
17 import org
.asamk
.signal
.manager
.api
.SendGroupMessageResults
;
18 import org
.asamk
.signal
.manager
.api
.SendMessageResult
;
19 import org
.asamk
.signal
.manager
.config
.ServiceConfig
;
20 import org
.asamk
.signal
.manager
.groups
.GroupUtils
;
21 import org
.asamk
.signal
.manager
.internal
.SignalDependencies
;
22 import org
.asamk
.signal
.manager
.storage
.SignalAccount
;
23 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfo
;
24 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV1
;
25 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV2
;
26 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
27 import org
.asamk
.signal
.manager
.util
.AttachmentUtils
;
28 import org
.asamk
.signal
.manager
.util
.IOUtils
;
29 import org
.asamk
.signal
.manager
.util
.Utils
;
30 import org
.signal
.libsignal
.zkgroup
.InvalidInputException
;
31 import org
.signal
.libsignal
.zkgroup
.groups
.GroupMasterKey
;
32 import org
.signal
.libsignal
.zkgroup
.groups
.GroupSecretParams
;
33 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKey
;
34 import org
.signal
.storageservice
.protos
.groups
.GroupChange
;
35 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
36 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupChange
;
37 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupJoinInfo
;
38 import org
.slf4j
.Logger
;
39 import org
.slf4j
.LoggerFactory
;
40 import org
.whispersystems
.signalservice
.api
.groupsv2
.DecryptedGroupHistoryEntry
;
41 import org
.whispersystems
.signalservice
.api
.groupsv2
.GroupLinkNotActiveException
;
42 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachment
;
43 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachmentStream
;
44 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceDataMessage
;
45 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroup
;
46 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroupV2
;
47 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
48 import org
.whispersystems
.signalservice
.api
.push
.ServiceId
;
49 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.ConflictException
;
51 import java
.io
.IOException
;
52 import java
.io
.InputStream
;
53 import java
.io
.OutputStream
;
54 import java
.nio
.file
.Files
;
55 import java
.util
.Collection
;
56 import java
.util
.HashMap
;
57 import java
.util
.HashSet
;
58 import java
.util
.List
;
59 import java
.util
.Objects
;
60 import java
.util
.Optional
;
63 public class GroupHelper
{
65 private final static Logger logger
= LoggerFactory
.getLogger(GroupHelper
.class);
67 private final SignalAccount account
;
68 private final SignalDependencies dependencies
;
69 private final Context context
;
71 public GroupHelper(final Context context
) {
72 this.account
= context
.getAccount();
73 this.dependencies
= context
.getDependencies();
74 this.context
= context
;
77 public GroupInfo
getGroup(GroupId groupId
) {
78 return getGroup(groupId
, false);
81 public boolean isGroupBlocked(final GroupId groupId
) {
82 var group
= getGroup(groupId
);
83 return group
!= null && group
.isBlocked();
86 public void downloadGroupAvatar(GroupIdV1 groupId
, SignalServiceAttachment avatar
) {
88 context
.getAvatarStore()
89 .storeGroupAvatar(groupId
,
90 outputStream
-> context
.getAttachmentHelper().retrieveAttachment(avatar
, outputStream
));
91 } catch (IOException e
) {
92 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
96 public Optional
<SignalServiceAttachmentStream
> createGroupAvatarAttachment(GroupIdV1 groupId
) throws IOException
{
97 final var streamDetails
= context
.getAvatarStore().retrieveGroupAvatar(groupId
);
98 if (streamDetails
== null) {
99 return Optional
.empty();
102 return Optional
.of(AttachmentUtils
.createAttachmentStream(streamDetails
, Optional
.empty()));
105 public GroupInfoV2
getOrMigrateGroup(
106 final GroupMasterKey groupMasterKey
, final int revision
, final byte[] signedGroupChange
108 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupMasterKey
);
110 var groupId
= GroupUtils
.getGroupIdV2(groupSecretParams
);
111 var groupInfo
= getGroup(groupId
);
112 final GroupInfoV2 groupInfoV2
;
113 if (groupInfo
instanceof GroupInfoV1
) {
114 // Received a v2 group message for a v1 group, we need to locally migrate the group
115 account
.getGroupStore().deleteGroup(((GroupInfoV1
) groupInfo
).getGroupId());
116 groupInfoV2
= new GroupInfoV2(groupId
, groupMasterKey
, account
.getRecipientResolver());
117 groupInfoV2
.setBlocked(groupInfo
.isBlocked());
118 account
.getGroupStore().updateGroup(groupInfoV2
);
119 logger
.info("Locally migrated group {} to group v2, id: {}",
120 groupInfo
.getGroupId().toBase64(),
121 groupInfoV2
.getGroupId().toBase64());
122 } else if (groupInfo
instanceof GroupInfoV2
) {
123 groupInfoV2
= (GroupInfoV2
) groupInfo
;
125 groupInfoV2
= new GroupInfoV2(groupId
, groupMasterKey
, account
.getRecipientResolver());
128 if (groupInfoV2
.getGroup() == null || groupInfoV2
.getGroup().revision
< revision
) {
129 DecryptedGroup group
= null;
130 if (signedGroupChange
!= null
131 && groupInfoV2
.getGroup() != null
132 && groupInfoV2
.getGroup().revision
+ 1 == revision
) {
133 final var decryptedGroupChange
= context
.getGroupV2Helper()
134 .getDecryptedGroupChange(signedGroupChange
, groupMasterKey
);
136 if (decryptedGroupChange
!= null) {
137 storeProfileKeyFromChange(decryptedGroupChange
);
138 group
= context
.getGroupV2Helper()
139 .getUpdatedDecryptedGroup(groupInfoV2
.getGroup(), decryptedGroupChange
);
144 group
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
147 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, group
);
149 } catch (NotAGroupMemberException ignored
) {
153 storeProfileKeysFromMembers(group
);
154 final var avatar
= group
.avatar
;
155 if (!avatar
.isEmpty()) {
156 downloadGroupAvatar(groupId
, groupSecretParams
, avatar
);
159 groupInfoV2
.setGroup(group
);
160 account
.getGroupStore().updateGroup(groupInfoV2
);
166 public Pair
<GroupId
, SendGroupMessageResults
> createGroup(
167 String name
, Set
<RecipientId
> members
, String avatarFile
168 ) throws IOException
, AttachmentInvalidException
{
169 final var selfRecipientId
= account
.getSelfRecipientId();
170 if (members
!= null && members
.contains(selfRecipientId
)) {
171 members
= new HashSet
<>(members
);
172 members
.remove(selfRecipientId
);
175 final var avatarBytes
= readAvatarBytes(avatarFile
);
176 var gv2Pair
= context
.getGroupV2Helper()
177 .createGroup(name
== null ?
"" : name
, members
== null ? Set
.of() : members
, avatarBytes
);
179 if (gv2Pair
== null) {
180 // Failed to create v2 group, creating v1 group instead
181 var gv1
= new GroupInfoV1(GroupIdV1
.createRandom());
182 gv1
.addMembers(List
.of(selfRecipientId
));
183 final var result
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
184 return new Pair
<>(gv1
.getGroupId(), result
);
187 final var gv2
= gv2Pair
.first();
188 final var decryptedGroup
= gv2Pair
.second();
190 gv2
.setGroup(decryptedGroup
);
191 if (avatarBytes
!= null) {
192 context
.getAvatarStore()
193 .storeGroupAvatar(gv2
.getGroupId(), outputStream
-> outputStream
.write(avatarBytes
));
196 account
.getGroupStore().updateGroup(gv2
);
198 final var messageBuilder
= getGroupUpdateMessageBuilder(gv2
, null);
200 final var result
= sendGroupMessage(messageBuilder
,
201 gv2
.getMembersIncludingPendingWithout(selfRecipientId
),
202 gv2
.getDistributionId());
203 return new Pair
<>(gv2
.getGroupId(), result
);
206 public SendGroupMessageResults
updateGroup(
207 final GroupId groupId
,
209 final String description
,
210 final Set
<RecipientId
> members
,
211 final Set
<RecipientId
> removeMembers
,
212 final Set
<RecipientId
> admins
,
213 final Set
<RecipientId
> removeAdmins
,
214 final Set
<RecipientId
> banMembers
,
215 final Set
<RecipientId
> unbanMembers
,
216 final boolean resetGroupLink
,
217 final GroupLinkState groupLinkState
,
218 final GroupPermission addMemberPermission
,
219 final GroupPermission editDetailsPermission
,
220 final String avatarFile
,
221 final Integer expirationTimer
,
222 final Boolean isAnnouncementGroup
223 ) throws IOException
, GroupNotFoundException
, AttachmentInvalidException
, NotAGroupMemberException
, GroupSendingNotAllowedException
{
224 var group
= getGroupForUpdating(groupId
);
225 final var avatarBytes
= readAvatarBytes(avatarFile
);
227 if (group
instanceof GroupInfoV2
) {
229 return updateGroupV2((GroupInfoV2
) group
,
241 editDetailsPermission
,
244 isAnnouncementGroup
);
245 } catch (ConflictException e
) {
246 // Detected conflicting update, refreshing group and trying again
247 group
= getGroup(groupId
, true);
248 return updateGroupV2((GroupInfoV2
) group
,
260 editDetailsPermission
,
263 isAnnouncementGroup
);
267 final var gv1
= (GroupInfoV1
) group
;
268 final var result
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
269 if (expirationTimer
!= null) {
270 setExpirationTimer(gv1
, expirationTimer
);
275 public void updateGroupProfileKey(GroupIdV2 groupId
) throws GroupNotFoundException
, NotAGroupMemberException
, IOException
{
276 var group
= getGroupForUpdating(groupId
);
278 if (group
instanceof GroupInfoV2 groupInfoV2
) {
279 Pair
<DecryptedGroup
, GroupChange
> groupChangePair
;
281 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
282 } catch (ConflictException e
) {
283 // Detected conflicting update, refreshing group and trying again
284 groupInfoV2
= (GroupInfoV2
) getGroup(groupId
, true);
285 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
287 if (groupChangePair
!= null) {
288 sendUpdateGroupV2Message(groupInfoV2
, groupChangePair
.first(), groupChangePair
.second());
293 public Pair
<GroupId
, SendGroupMessageResults
> joinGroup(
294 GroupInviteLinkUrl inviteLinkUrl
295 ) throws IOException
, InactiveGroupLinkException
, PendingAdminApprovalException
{
296 final DecryptedGroupJoinInfo groupJoinInfo
;
298 groupJoinInfo
= context
.getGroupV2Helper()
299 .getDecryptedGroupJoinInfo(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword());
300 } catch (GroupLinkNotActiveException e
) {
301 throw new InactiveGroupLinkException("Group link inactive (reason: " + e
.getReason() + ")", e
);
303 if (groupJoinInfo
.pendingAdminApproval
) {
304 throw new PendingAdminApprovalException("You have already requested to join the group.");
306 final var groupChange
= context
.getGroupV2Helper()
307 .joinGroup(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword(), groupJoinInfo
);
308 final var group
= getOrMigrateGroup(inviteLinkUrl
.getGroupMasterKey(),
309 groupJoinInfo
.revision
+ 1,
310 groupChange
.encode());
312 if (group
.getGroup() == null) {
313 // Only requested member, can't send update to group members
314 return new Pair
<>(group
.getGroupId(), new SendGroupMessageResults(0, List
.of()));
317 final var result
= sendUpdateGroupV2Message(group
, group
.getGroup(), groupChange
);
319 return new Pair
<>(group
.getGroupId(), result
);
322 public SendGroupMessageResults
quitGroup(
323 final GroupId groupId
, final Set
<RecipientId
> newAdmins
324 ) throws IOException
, LastGroupAdminException
, NotAGroupMemberException
, GroupNotFoundException
{
325 var group
= getGroupForUpdating(groupId
);
326 if (group
instanceof GroupInfoV1
) {
327 return quitGroupV1((GroupInfoV1
) group
);
331 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
332 } catch (ConflictException e
) {
333 // Detected conflicting update, refreshing group and trying again
334 group
= getGroup(groupId
, true);
335 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
339 public void deleteGroup(GroupId groupId
) throws IOException
{
340 account
.getGroupStore().deleteGroup(groupId
);
341 context
.getAvatarStore().deleteGroupAvatar(groupId
);
344 public void setGroupBlocked(final GroupId groupId
, final boolean blocked
) throws GroupNotFoundException
{
345 var group
= getGroup(groupId
);
347 throw new GroupNotFoundException(groupId
);
350 group
.setBlocked(blocked
);
351 account
.getGroupStore().updateGroup(group
);
354 public SendGroupMessageResults
sendGroupInfoRequest(
355 GroupIdV1 groupId
, RecipientId recipientId
356 ) throws IOException
{
357 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.REQUEST_INFO
).withId(groupId
.serialize());
359 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
.build());
361 // Send group info request message to the recipient who sent us a message with this groupId
362 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
365 public SendGroupMessageResults
sendGroupInfoMessage(
366 GroupIdV1 groupId
, RecipientId recipientId
367 ) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, AttachmentInvalidException
{
369 var group
= getGroupForUpdating(groupId
);
370 if (!(group
instanceof GroupInfoV1
)) {
371 throw new IOException("Received an invalid group request for a v2 group!");
373 g
= (GroupInfoV1
) group
;
375 if (!g
.isMember(recipientId
)) {
376 throw new NotAGroupMemberException(groupId
, g
.name
);
379 var messageBuilder
= getGroupUpdateMessageBuilder(g
);
381 // Send group message only to the recipient who requested it
382 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
385 private GroupInfo
getGroup(GroupId groupId
, boolean forceUpdate
) {
386 final var group
= account
.getGroupStore().getGroup(groupId
);
387 if (group
instanceof GroupInfoV2 groupInfoV2
) {
388 if (forceUpdate
|| (!groupInfoV2
.isPermissionDenied() && groupInfoV2
.getGroup() == null)) {
389 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
390 DecryptedGroup decryptedGroup
;
392 decryptedGroup
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
393 } catch (NotAGroupMemberException e
) {
394 groupInfoV2
.setPermissionDenied(true);
395 decryptedGroup
= null;
397 if (decryptedGroup
!= null) {
399 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, decryptedGroup
);
400 } catch (NotAGroupMemberException ignored
) {
402 storeProfileKeysFromMembers(decryptedGroup
);
403 final var avatar
= decryptedGroup
.avatar
;
404 if (!avatar
.isEmpty()) {
405 downloadGroupAvatar(groupInfoV2
.getGroupId(), groupSecretParams
, avatar
);
408 groupInfoV2
.setGroup(decryptedGroup
);
409 account
.getGroupStore().updateGroup(group
);
415 private void downloadGroupAvatar(GroupIdV2 groupId
, GroupSecretParams groupSecretParams
, String cdnKey
) {
417 context
.getAvatarStore()
418 .storeGroupAvatar(groupId
,
419 outputStream
-> retrieveGroupV2Avatar(groupSecretParams
, cdnKey
, outputStream
));
420 } catch (IOException e
) {
421 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
425 private void retrieveGroupV2Avatar(
426 GroupSecretParams groupSecretParams
, String cdnKey
, OutputStream outputStream
427 ) throws IOException
{
428 var groupOperations
= dependencies
.getGroupsV2Operations().forGroup(groupSecretParams
);
430 var tmpFile
= IOUtils
.createTempFile();
431 try (InputStream input
= dependencies
.getMessageReceiver()
432 .retrieveGroupsV2ProfileAvatar(cdnKey
, tmpFile
, ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
433 var encryptedData
= IOUtils
.readFully(input
);
435 var decryptedData
= groupOperations
.decryptAvatar(encryptedData
);
436 outputStream
.write(decryptedData
);
439 Files
.delete(tmpFile
.toPath());
440 } catch (IOException e
) {
441 logger
.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
448 private void storeProfileKeysFromMembers(final DecryptedGroup group
) {
449 for (var member
: group
.members
) {
450 final var serviceId
= ServiceId
.parseOrThrow(member
.aciBytes
);
451 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
452 final var profileStore
= account
.getProfileStore();
453 if (profileStore
.getProfileKey(recipientId
) != null) {
454 // We already have a profile key, not updating it from a non-authoritative source
458 profileStore
.storeProfileKey(recipientId
, new ProfileKey(member
.profileKey
.toByteArray()));
459 } catch (InvalidInputException ignored
) {
464 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange
) {
465 final var profileKeyFromChange
= context
.getGroupV2Helper()
466 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange
);
468 if (profileKeyFromChange
!= null) {
469 final var serviceId
= profileKeyFromChange
.first();
470 final var profileKey
= profileKeyFromChange
.second();
471 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
472 account
.getProfileStore().storeProfileKey(recipientId
, profileKey
);
476 private void storeProfileKeysFromHistory(
477 final GroupSecretParams groupSecretParams
,
478 final GroupInfoV2 localGroup
,
479 final DecryptedGroup newDecryptedGroup
480 ) throws NotAGroupMemberException
{
481 final var revisionWeWereAdded
= context
.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup
);
482 final var localRevision
= localGroup
.getGroup() == null ?
0 : localGroup
.getGroup().revision
;
483 var fromRevision
= Math
.max(revisionWeWereAdded
, localRevision
);
484 final var newProfileKeys
= new HashMap
<RecipientId
, ProfileKey
>();
486 final var page
= context
.getGroupV2Helper().getDecryptedGroupHistoryPage(groupSecretParams
, fromRevision
);
489 .map(DecryptedGroupHistoryEntry
::getChange
)
490 .filter(Optional
::isPresent
)
492 .map(context
.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange
)
493 .filter(Objects
::nonNull
)
495 final var serviceId
= p
.first();
496 final var profileKey
= p
.second();
497 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
498 newProfileKeys
.put(recipientId
, profileKey
);
500 if (!page
.getPagingData().hasMorePages()) {
503 fromRevision
= page
.getPagingData().getNextPageRevision();
506 newProfileKeys
.forEach(account
.getProfileStore()::storeProfileKey
);
509 private GroupInfo
getGroupForUpdating(GroupId groupId
) throws GroupNotFoundException
, NotAGroupMemberException
{
510 var g
= getGroup(groupId
);
512 throw new GroupNotFoundException(groupId
);
514 if (!g
.isMember(account
.getSelfRecipientId()) && !g
.isPendingMember(account
.getSelfRecipientId())) {
515 throw new NotAGroupMemberException(groupId
, g
.getTitle());
517 if (groupId
instanceof GroupIdV2
) {
518 // Refresh group before updating
519 return getGroup(groupId
, true);
524 private SendGroupMessageResults
updateGroupV1(
525 final GroupInfoV1 gv1
, final String name
, final Set
<RecipientId
> members
, final byte[] avatarFile
526 ) throws IOException
, AttachmentInvalidException
{
527 updateGroupV1Details(gv1
, name
, members
, avatarFile
);
529 account
.getGroupStore().updateGroup(gv1
);
531 var messageBuilder
= getGroupUpdateMessageBuilder(gv1
);
532 return sendGroupMessage(messageBuilder
,
533 gv1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
534 gv1
.getDistributionId());
537 private void updateGroupV1Details(
538 final GroupInfoV1 g
, final String name
, final Collection
<RecipientId
> members
, final byte[] avatarFile
539 ) throws IOException
{
544 if (members
!= null) {
545 g
.addMembers(members
);
548 if (avatarFile
!= null) {
549 context
.getAvatarStore().storeGroupAvatar(g
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
554 * Change the expiration timer for a group
556 private void setExpirationTimer(
557 GroupInfoV1 groupInfoV1
, int messageExpirationTimer
558 ) throws NotAGroupMemberException
, GroupNotFoundException
, IOException
, GroupSendingNotAllowedException
{
559 groupInfoV1
.messageExpirationTime
= messageExpirationTimer
;
560 account
.getGroupStore().updateGroup(groupInfoV1
);
561 sendExpirationTimerUpdate(groupInfoV1
.getGroupId());
564 private void sendExpirationTimerUpdate(GroupIdV1 groupId
) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, GroupSendingNotAllowedException
{
565 final var messageBuilder
= SignalServiceDataMessage
.newBuilder().asExpirationUpdate();
566 context
.getSendHelper().sendAsGroupMessage(messageBuilder
, groupId
, Optional
.empty());
569 private SendGroupMessageResults
updateGroupV2(
570 final GroupInfoV2 group
,
572 final String description
,
573 final Set
<RecipientId
> members
,
574 final Set
<RecipientId
> removeMembers
,
575 final Set
<RecipientId
> admins
,
576 final Set
<RecipientId
> removeAdmins
,
577 final Set
<RecipientId
> banMembers
,
578 final Set
<RecipientId
> unbanMembers
,
579 final boolean resetGroupLink
,
580 final GroupLinkState groupLinkState
,
581 final GroupPermission addMemberPermission
,
582 final GroupPermission editDetailsPermission
,
583 final byte[] avatarFile
,
584 final Integer expirationTimer
,
585 final Boolean isAnnouncementGroup
586 ) throws IOException
{
587 SendGroupMessageResults result
= null;
588 final var groupV2Helper
= context
.getGroupV2Helper();
589 if (group
.isPendingMember(account
.getSelfRecipientId())) {
590 var groupGroupChangePair
= groupV2Helper
.acceptInvite(group
);
591 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
594 if (members
!= null) {
595 final var requestingMembers
= new HashSet
<>(members
);
596 requestingMembers
.retainAll(group
.getRequestingMembers());
597 if (requestingMembers
.size() > 0) {
598 var groupGroupChangePair
= groupV2Helper
.approveJoinRequestMembers(group
, requestingMembers
);
599 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
601 final var newMembers
= new HashSet
<>(members
);
602 newMembers
.removeAll(group
.getMembers());
603 newMembers
.removeAll(group
.getRequestingMembers());
604 if (newMembers
.size() > 0) {
605 var groupGroupChangePair
= groupV2Helper
.addMembers(group
, newMembers
);
606 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
610 if (removeMembers
!= null) {
611 var existingRemoveMembers
= new HashSet
<>(removeMembers
);
612 if (banMembers
!= null) {
613 existingRemoveMembers
.addAll(banMembers
);
615 existingRemoveMembers
.retainAll(group
.getMembers());
616 if (members
!= null) {
617 existingRemoveMembers
.removeAll(members
);
619 existingRemoveMembers
.remove(account
.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
620 if (existingRemoveMembers
.size() > 0) {
621 var groupGroupChangePair
= groupV2Helper
.removeMembers(group
, existingRemoveMembers
);
622 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
625 var pendingRemoveMembers
= new HashSet
<>(removeMembers
);
626 pendingRemoveMembers
.retainAll(group
.getPendingMembers());
627 if (pendingRemoveMembers
.size() > 0) {
628 var groupGroupChangePair
= groupV2Helper
.revokeInvitedMembers(group
, pendingRemoveMembers
);
629 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
631 var requestingRemoveMembers
= new HashSet
<>(removeMembers
);
632 requestingRemoveMembers
.retainAll(group
.getRequestingMembers());
633 if (requestingRemoveMembers
.size() > 0) {
634 var groupGroupChangePair
= groupV2Helper
.refuseJoinRequestMembers(group
, requestingRemoveMembers
);
635 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
639 if (admins
!= null) {
640 final var newAdmins
= new HashSet
<>(admins
);
641 newAdmins
.retainAll(group
.getMembers());
642 newAdmins
.removeAll(group
.getAdminMembers());
643 if (newAdmins
.size() > 0) {
644 for (var admin
: newAdmins
) {
645 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, true);
646 result
= sendUpdateGroupV2Message(group
,
647 groupGroupChangePair
.first(),
648 groupGroupChangePair
.second());
653 if (removeAdmins
!= null) {
654 final var existingRemoveAdmins
= new HashSet
<>(removeAdmins
);
655 existingRemoveAdmins
.retainAll(group
.getAdminMembers());
656 if (existingRemoveAdmins
.size() > 0) {
657 for (var admin
: existingRemoveAdmins
) {
658 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, false);
659 result
= sendUpdateGroupV2Message(group
,
660 groupGroupChangePair
.first(),
661 groupGroupChangePair
.second());
666 if (banMembers
!= null) {
667 final var newlyBannedMembers
= new HashSet
<>(banMembers
);
668 newlyBannedMembers
.removeAll(group
.getBannedMembers());
669 if (newlyBannedMembers
.size() > 0) {
670 var groupGroupChangePair
= groupV2Helper
.banMembers(group
, newlyBannedMembers
);
671 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
675 if (unbanMembers
!= null) {
676 var existingUnbanMembers
= new HashSet
<>(unbanMembers
);
677 existingUnbanMembers
.retainAll(group
.getBannedMembers());
678 if (existingUnbanMembers
.size() > 0) {
679 var groupGroupChangePair
= groupV2Helper
.unbanMembers(group
, existingUnbanMembers
);
680 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
684 if (resetGroupLink
) {
685 var groupGroupChangePair
= groupV2Helper
.resetGroupLinkPassword(group
);
686 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
689 if (groupLinkState
!= null) {
690 var groupGroupChangePair
= groupV2Helper
.setGroupLinkState(group
, groupLinkState
);
691 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
694 if (addMemberPermission
!= null) {
695 var groupGroupChangePair
= groupV2Helper
.setAddMemberPermission(group
, addMemberPermission
);
696 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
699 if (editDetailsPermission
!= null) {
700 var groupGroupChangePair
= groupV2Helper
.setEditDetailsPermission(group
, editDetailsPermission
);
701 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
704 if (expirationTimer
!= null) {
705 var groupGroupChangePair
= groupV2Helper
.setMessageExpirationTimer(group
, expirationTimer
);
706 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
709 if (isAnnouncementGroup
!= null) {
710 var groupGroupChangePair
= groupV2Helper
.setIsAnnouncementGroup(group
, isAnnouncementGroup
);
711 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
714 if (name
!= null || description
!= null || avatarFile
!= null) {
715 var groupGroupChangePair
= groupV2Helper
.updateGroup(group
, name
, description
, avatarFile
);
716 if (avatarFile
!= null) {
717 context
.getAvatarStore()
718 .storeGroupAvatar(group
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
720 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
726 private SendGroupMessageResults
quitGroupV1(final GroupInfoV1 groupInfoV1
) throws IOException
{
727 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.QUIT
)
728 .withId(groupInfoV1
.getGroupId().serialize())
731 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
);
732 groupInfoV1
.removeMember(account
.getSelfRecipientId());
733 account
.getGroupStore().updateGroup(groupInfoV1
);
734 return sendGroupMessage(messageBuilder
,
735 groupInfoV1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
736 groupInfoV1
.getDistributionId());
739 private SendGroupMessageResults
quitGroupV2(
740 final GroupInfoV2 groupInfoV2
, final Set
<RecipientId
> newAdmins
741 ) throws LastGroupAdminException
, IOException
{
742 final var currentAdmins
= groupInfoV2
.getAdminMembers();
743 newAdmins
.removeAll(currentAdmins
);
744 newAdmins
.retainAll(groupInfoV2
.getMembers());
745 if (currentAdmins
.contains(account
.getSelfRecipientId())
746 && currentAdmins
.size() == 1
747 && groupInfoV2
.getMembers().size() > 1
748 && newAdmins
.size() == 0) {
749 // Last admin can't leave the group, unless she's also the last member
750 throw new LastGroupAdminException(groupInfoV2
.getGroupId(), groupInfoV2
.getTitle());
752 final var groupGroupChangePair
= context
.getGroupV2Helper().leaveGroup(groupInfoV2
, newAdmins
);
753 groupInfoV2
.setGroup(groupGroupChangePair
.first());
754 account
.getGroupStore().updateGroup(groupInfoV2
);
756 var messageBuilder
= getGroupUpdateMessageBuilder(groupInfoV2
, groupGroupChangePair
.second().encode());
757 return sendGroupMessage(messageBuilder
,
758 groupInfoV2
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
759 groupInfoV2
.getDistributionId());
762 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV1 g
) throws AttachmentInvalidException
{
763 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.UPDATE
)
764 .withId(g
.getGroupId().serialize())
766 .withMembers(g
.getMembers()
768 .map(context
.getRecipientHelper()::resolveSignalServiceAddress
)
772 final var attachment
= createGroupAvatarAttachment(g
.getGroupId());
773 attachment
.ifPresent(group
::withAvatar
);
774 } catch (IOException e
) {
775 throw new AttachmentInvalidException(g
.getGroupId().toBase64(), e
);
778 return SignalServiceDataMessage
.newBuilder()
779 .asGroupMessage(group
.build())
780 .withExpiration(g
.getMessageExpirationTimer());
783 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV2 g
, byte[] signedGroupChange
) {
784 var group
= SignalServiceGroupV2
.newBuilder(g
.getMasterKey())
785 .withRevision(g
.getGroup().revision
)
786 .withSignedGroupChange(signedGroupChange
);
787 return SignalServiceDataMessage
.newBuilder()
788 .asGroupMessage(group
.build())
789 .withExpiration(g
.getMessageExpirationTimer());
792 private SendGroupMessageResults
sendUpdateGroupV2Message(
793 GroupInfoV2 group
, DecryptedGroup newDecryptedGroup
, GroupChange groupChange
794 ) throws IOException
{
795 final var selfRecipientId
= account
.getSelfRecipientId();
796 final var members
= group
.getMembersIncludingPendingWithout(selfRecipientId
);
797 group
.setGroup(newDecryptedGroup
);
798 members
.addAll(group
.getMembersIncludingPendingWithout(selfRecipientId
));
799 account
.getGroupStore().updateGroup(group
);
801 final var messageBuilder
= getGroupUpdateMessageBuilder(group
, groupChange
.encode());
802 return sendGroupMessage(messageBuilder
, members
, group
.getDistributionId());
805 private SendGroupMessageResults
sendGroupMessage(
806 final SignalServiceDataMessage
.Builder messageBuilder
,
807 final Set
<RecipientId
> members
,
808 final DistributionId distributionId
809 ) throws IOException
{
810 final var timestamp
= System
.currentTimeMillis();
811 messageBuilder
.withTimestamp(timestamp
);
812 final var results
= context
.getSendHelper().sendGroupMessage(messageBuilder
.build(), members
, distributionId
);
813 return new SendGroupMessageResults(timestamp
,
815 .map(sendMessageResult
-> SendMessageResult
.from(sendMessageResult
,
816 account
.getRecipientResolver(),
817 account
.getRecipientAddressResolver()))
821 private byte[] readAvatarBytes(final String avatarFile
) throws IOException
{
822 if (avatarFile
== null) {
825 try (final var avatar
= Utils
.createStreamDetails(avatarFile
).first().getStream()) {
826 return IOUtils
.readFully(avatar
);