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
.jobs
.SyncStorageJob
;
23 import org
.asamk
.signal
.manager
.storage
.SignalAccount
;
24 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfo
;
25 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV1
;
26 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV2
;
27 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
28 import org
.asamk
.signal
.manager
.util
.AttachmentUtils
;
29 import org
.asamk
.signal
.manager
.util
.IOUtils
;
30 import org
.asamk
.signal
.manager
.util
.Utils
;
31 import org
.signal
.libsignal
.zkgroup
.InvalidInputException
;
32 import org
.signal
.libsignal
.zkgroup
.groups
.GroupMasterKey
;
33 import org
.signal
.libsignal
.zkgroup
.groups
.GroupSecretParams
;
34 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKey
;
35 import org
.signal
.storageservice
.protos
.groups
.GroupChange
;
36 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
37 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupChange
;
38 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupJoinInfo
;
39 import org
.slf4j
.Logger
;
40 import org
.slf4j
.LoggerFactory
;
41 import org
.whispersystems
.signalservice
.api
.groupsv2
.DecryptedGroupChangeLog
;
42 import org
.whispersystems
.signalservice
.api
.groupsv2
.GroupLinkNotActiveException
;
43 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachment
;
44 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachmentStream
;
45 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceDataMessage
;
46 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroup
;
47 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroupV2
;
48 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
49 import org
.whispersystems
.signalservice
.api
.push
.ServiceId
;
50 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.ConflictException
;
52 import java
.io
.IOException
;
53 import java
.io
.InputStream
;
54 import java
.io
.OutputStream
;
55 import java
.nio
.file
.Files
;
56 import java
.util
.Collection
;
57 import java
.util
.HashMap
;
58 import java
.util
.HashSet
;
59 import java
.util
.List
;
60 import java
.util
.Objects
;
61 import java
.util
.Optional
;
64 public class GroupHelper
{
66 private static final Logger logger
= LoggerFactory
.getLogger(GroupHelper
.class);
68 private final SignalAccount account
;
69 private final SignalDependencies dependencies
;
70 private final Context context
;
72 public GroupHelper(final Context context
) {
73 this.account
= context
.getAccount();
74 this.dependencies
= context
.getDependencies();
75 this.context
= context
;
78 public GroupInfo
getGroup(GroupId groupId
) {
79 return getGroup(groupId
, false);
82 public List
<GroupInfo
> getGroups() {
83 final var groups
= account
.getGroupStore().getGroups();
84 groups
.forEach(group
-> fillOrUpdateGroup(group
, false));
88 public boolean isGroupBlocked(final GroupId groupId
) {
89 var group
= getGroup(groupId
);
90 return group
!= null && group
.isBlocked();
93 public void downloadGroupAvatar(GroupIdV1 groupId
, SignalServiceAttachment avatar
) {
95 context
.getAvatarStore()
96 .storeGroupAvatar(groupId
,
97 outputStream
-> context
.getAttachmentHelper().retrieveAttachment(avatar
, outputStream
));
98 } catch (IOException e
) {
99 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
103 public Optional
<SignalServiceAttachmentStream
> createGroupAvatarAttachment(GroupIdV1 groupId
) throws IOException
{
104 final var streamDetails
= context
.getAvatarStore().retrieveGroupAvatar(groupId
);
105 if (streamDetails
== null) {
106 return Optional
.empty();
109 return Optional
.of(AttachmentUtils
.createAttachmentStream(streamDetails
, Optional
.empty()));
112 public GroupInfoV2
getOrMigrateGroup(
113 final GroupMasterKey groupMasterKey
, final int revision
, final byte[] signedGroupChange
115 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupMasterKey
);
117 final var groupId
= GroupUtils
.getGroupIdV2(groupSecretParams
);
118 final var groupInfoV2
= account
.getGroupStore().getGroupOrPartialMigrate(groupMasterKey
, groupId
);
120 if (groupInfoV2
.getGroup() == null || groupInfoV2
.getGroup().revision
< revision
) {
121 DecryptedGroup group
= null;
122 if (signedGroupChange
!= null
123 && groupInfoV2
.getGroup() != null
124 && groupInfoV2
.getGroup().revision
+ 1 == revision
) {
125 final var decryptedGroupChange
= context
.getGroupV2Helper()
126 .getDecryptedGroupChange(signedGroupChange
, groupMasterKey
);
128 if (decryptedGroupChange
!= null) {
129 storeProfileKeyFromChange(decryptedGroupChange
);
130 group
= context
.getGroupV2Helper()
131 .getUpdatedDecryptedGroup(groupInfoV2
.getGroup(), decryptedGroupChange
);
136 group
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
139 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, group
);
141 } catch (NotAGroupMemberException ignored
) {
145 storeProfileKeysFromMembers(group
);
146 final var avatar
= group
.avatar
;
147 if (!avatar
.isEmpty()) {
148 downloadGroupAvatar(groupId
, groupSecretParams
, avatar
);
151 groupInfoV2
.setGroup(group
);
152 account
.getGroupStore().updateGroup(groupInfoV2
);
153 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
159 public Pair
<GroupId
, SendGroupMessageResults
> createGroup(
160 String name
, Set
<RecipientId
> members
, String avatarFile
161 ) throws IOException
, AttachmentInvalidException
{
162 final var selfRecipientId
= account
.getSelfRecipientId();
163 if (members
!= null && members
.contains(selfRecipientId
)) {
164 members
= new HashSet
<>(members
);
165 members
.remove(selfRecipientId
);
168 final var avatarBytes
= readAvatarBytes(avatarFile
);
169 var gv2Pair
= context
.getGroupV2Helper()
170 .createGroup(name
== null ?
"" : name
, members
== null ? Set
.of() : members
, avatarBytes
);
172 if (gv2Pair
== null) {
173 // Failed to create v2 group, creating v1 group instead
174 var gv1
= new GroupInfoV1(GroupIdV1
.createRandom());
175 gv1
.setProfileSharingEnabled(true);
176 gv1
.addMembers(List
.of(selfRecipientId
));
177 final var result
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
178 return new Pair
<>(gv1
.getGroupId(), result
);
181 final var gv2
= gv2Pair
.first();
182 final var decryptedGroup
= gv2Pair
.second();
184 gv2
.setGroup(decryptedGroup
);
185 gv2
.setProfileSharingEnabled(true);
186 if (avatarBytes
!= null) {
187 context
.getAvatarStore()
188 .storeGroupAvatar(gv2
.getGroupId(), outputStream
-> outputStream
.write(avatarBytes
));
191 account
.getGroupStore().updateGroup(gv2
);
193 final var messageBuilder
= getGroupUpdateMessageBuilder(gv2
, null);
195 final var result
= sendGroupMessage(messageBuilder
,
196 gv2
.getMembersIncludingPendingWithout(selfRecipientId
),
197 gv2
.getDistributionId());
198 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
199 return new Pair
<>(gv2
.getGroupId(), result
);
202 public SendGroupMessageResults
updateGroup(
203 final GroupId groupId
,
205 final String description
,
206 final Set
<RecipientId
> members
,
207 final Set
<RecipientId
> removeMembers
,
208 final Set
<RecipientId
> admins
,
209 final Set
<RecipientId
> removeAdmins
,
210 final Set
<RecipientId
> banMembers
,
211 final Set
<RecipientId
> unbanMembers
,
212 final boolean resetGroupLink
,
213 final GroupLinkState groupLinkState
,
214 final GroupPermission addMemberPermission
,
215 final GroupPermission editDetailsPermission
,
216 final String avatarFile
,
217 final Integer expirationTimer
,
218 final Boolean isAnnouncementGroup
219 ) throws IOException
, GroupNotFoundException
, AttachmentInvalidException
, NotAGroupMemberException
, GroupSendingNotAllowedException
{
220 var group
= getGroupForUpdating(groupId
);
221 final var avatarBytes
= readAvatarBytes(avatarFile
);
223 SendGroupMessageResults results
;
225 case GroupInfoV2 gv2
-> {
227 results
= updateGroupV2(gv2
,
239 editDetailsPermission
,
242 isAnnouncementGroup
);
243 } catch (ConflictException e
) {
244 // Detected conflicting update, refreshing group and trying again
245 group
= getGroup(groupId
, true);
246 results
= updateGroupV2((GroupInfoV2
) group
,
258 editDetailsPermission
,
261 isAnnouncementGroup
);
265 case GroupInfoV1 gv1
-> {
266 results
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
267 if (expirationTimer
!= null) {
268 setExpirationTimer(gv1
, expirationTimer
);
272 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
276 public void updateGroupProfileKey(GroupIdV2 groupId
) throws GroupNotFoundException
, NotAGroupMemberException
, IOException
{
277 var group
= getGroupForUpdating(groupId
);
279 if (group
instanceof GroupInfoV2 groupInfoV2
) {
280 Pair
<DecryptedGroup
, GroupChange
> groupChangePair
;
282 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
283 } catch (ConflictException e
) {
284 // Detected conflicting update, refreshing group and trying again
285 groupInfoV2
= (GroupInfoV2
) getGroup(groupId
, true);
286 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
288 if (groupChangePair
!= null) {
289 sendUpdateGroupV2Message(groupInfoV2
, groupChangePair
.first(), groupChangePair
.second());
294 public Pair
<GroupId
, SendGroupMessageResults
> joinGroup(
295 GroupInviteLinkUrl inviteLinkUrl
296 ) throws IOException
, InactiveGroupLinkException
, PendingAdminApprovalException
{
297 final DecryptedGroupJoinInfo groupJoinInfo
;
299 groupJoinInfo
= context
.getGroupV2Helper()
300 .getDecryptedGroupJoinInfo(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword());
301 } catch (GroupLinkNotActiveException e
) {
302 throw new InactiveGroupLinkException("Group link inactive (reason: " + e
.getReason() + ")", e
);
304 if (groupJoinInfo
.pendingAdminApproval
) {
305 throw new PendingAdminApprovalException("You have already requested to join the group.");
307 final var groupChange
= context
.getGroupV2Helper()
308 .joinGroup(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword(), groupJoinInfo
);
309 final var group
= getOrMigrateGroup(inviteLinkUrl
.getGroupMasterKey(),
310 groupJoinInfo
.revision
+ 1,
311 groupChange
.encode());
313 if (group
.getGroup() == null) {
314 // Only requested member, can't send update to group members
315 return new Pair
<>(group
.getGroupId(), new SendGroupMessageResults(0, List
.of()));
318 final var result
= sendUpdateGroupV2Message(group
, group
.getGroup(), groupChange
);
320 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
321 return new Pair
<>(group
.getGroupId(), result
);
324 public SendGroupMessageResults
quitGroup(
325 final GroupId groupId
, final Set
<RecipientId
> newAdmins
326 ) throws IOException
, LastGroupAdminException
, NotAGroupMemberException
, GroupNotFoundException
{
327 var group
= getGroupForUpdating(groupId
);
328 if (group
instanceof GroupInfoV1
) {
329 return quitGroupV1((GroupInfoV1
) group
);
333 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
334 } catch (ConflictException e
) {
335 // Detected conflicting update, refreshing group and trying again
336 group
= getGroup(groupId
, true);
337 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
341 public void deleteGroup(GroupId groupId
) throws IOException
{
342 account
.getGroupStore().deleteGroup(groupId
);
343 context
.getAvatarStore().deleteGroupAvatar(groupId
);
344 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
347 public void setGroupBlocked(final GroupId groupId
, final boolean blocked
) throws GroupNotFoundException
{
348 var group
= getGroup(groupId
);
350 throw new GroupNotFoundException(groupId
);
353 group
.setBlocked(blocked
);
354 account
.getGroupStore().updateGroup(group
);
355 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
358 public SendGroupMessageResults
sendGroupInfoRequest(
359 GroupIdV1 groupId
, RecipientId recipientId
360 ) throws IOException
{
361 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.REQUEST_INFO
).withId(groupId
.serialize());
363 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
.build());
365 // Send group info request message to the recipient who sent us a message with this groupId
366 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
369 public SendGroupMessageResults
sendGroupInfoMessage(
370 GroupIdV1 groupId
, RecipientId recipientId
371 ) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, AttachmentInvalidException
{
373 var group
= getGroupForUpdating(groupId
);
374 if (!(group
instanceof GroupInfoV1
)) {
375 throw new IOException("Received an invalid group request for a v2 group!");
377 g
= (GroupInfoV1
) group
;
379 if (!g
.isMember(recipientId
)) {
380 throw new NotAGroupMemberException(groupId
, g
.name
);
383 var messageBuilder
= getGroupUpdateMessageBuilder(g
);
385 // Send group message only to the recipient who requested it
386 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
389 private GroupInfo
getGroup(GroupId groupId
, boolean forceUpdate
) {
390 final var group
= account
.getGroupStore().getGroup(groupId
);
391 fillOrUpdateGroup(group
, forceUpdate
);
395 private void fillOrUpdateGroup(final GroupInfo group
, final boolean forceUpdate
) {
396 if (!(group
instanceof GroupInfoV2 groupInfoV2
)) {
400 if (!forceUpdate
&& (groupInfoV2
.isPermissionDenied() || groupInfoV2
.getGroup() != null)) {
404 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
405 DecryptedGroup decryptedGroup
;
407 decryptedGroup
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
408 } catch (NotAGroupMemberException e
) {
409 groupInfoV2
.setPermissionDenied(true);
410 account
.getGroupStore().updateGroup(group
);
414 if (decryptedGroup
== null) {
419 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, decryptedGroup
);
420 } catch (NotAGroupMemberException ignored
) {
422 storeProfileKeysFromMembers(decryptedGroup
);
423 final var avatar
= decryptedGroup
.avatar
;
424 if (!avatar
.isEmpty()) {
425 downloadGroupAvatar(groupInfoV2
.getGroupId(), groupSecretParams
, avatar
);
427 groupInfoV2
.setGroup(decryptedGroup
);
428 account
.getGroupStore().updateGroup(group
);
431 private void downloadGroupAvatar(GroupIdV2 groupId
, GroupSecretParams groupSecretParams
, String cdnKey
) {
433 context
.getAvatarStore()
434 .storeGroupAvatar(groupId
,
435 outputStream
-> retrieveGroupV2Avatar(groupSecretParams
, cdnKey
, outputStream
));
436 } catch (IOException e
) {
437 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
441 private void retrieveGroupV2Avatar(
442 GroupSecretParams groupSecretParams
, String cdnKey
, OutputStream outputStream
443 ) throws IOException
{
444 var groupOperations
= dependencies
.getGroupsV2Operations().forGroup(groupSecretParams
);
446 var tmpFile
= IOUtils
.createTempFile();
447 try (InputStream input
= dependencies
.getMessageReceiver()
448 .retrieveGroupsV2ProfileAvatar(cdnKey
, tmpFile
, ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
449 var encryptedData
= IOUtils
.readFully(input
);
451 var decryptedData
= groupOperations
.decryptAvatar(encryptedData
);
452 outputStream
.write(decryptedData
);
455 Files
.delete(tmpFile
.toPath());
456 } catch (IOException e
) {
457 logger
.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
464 private void storeProfileKeysFromMembers(final DecryptedGroup group
) {
465 for (var member
: group
.members
) {
466 final var serviceId
= ServiceId
.parseOrThrow(member
.aciBytes
);
467 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
468 final var profileStore
= account
.getProfileStore();
469 if (profileStore
.getProfileKey(recipientId
) != null) {
470 // We already have a profile key, not updating it from a non-authoritative source
474 profileStore
.storeProfileKey(recipientId
, new ProfileKey(member
.profileKey
.toByteArray()));
475 } catch (InvalidInputException ignored
) {
480 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange
) {
481 final var profileKeyFromChange
= context
.getGroupV2Helper()
482 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange
);
484 if (profileKeyFromChange
!= null) {
485 final var serviceId
= profileKeyFromChange
.first();
486 final var profileKey
= profileKeyFromChange
.second();
487 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
488 account
.getProfileStore().storeProfileKey(recipientId
, profileKey
);
492 private void storeProfileKeysFromHistory(
493 final GroupSecretParams groupSecretParams
,
494 final GroupInfoV2 localGroup
,
495 final DecryptedGroup newDecryptedGroup
496 ) throws NotAGroupMemberException
{
497 final var revisionWeWereAdded
= context
.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup
);
498 final var localRevision
= localGroup
.getGroup() == null ?
0 : localGroup
.getGroup().revision
;
499 var fromRevision
= Math
.max(revisionWeWereAdded
, localRevision
);
500 final var newProfileKeys
= new HashMap
<RecipientId
, ProfileKey
>();
502 final var page
= context
.getGroupV2Helper().getDecryptedGroupHistoryPage(groupSecretParams
, fromRevision
);
505 .map(DecryptedGroupChangeLog
::getChange
)
506 .filter(Objects
::nonNull
)
507 .map(context
.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange
)
508 .filter(Objects
::nonNull
)
510 final var serviceId
= p
.first();
511 final var profileKey
= p
.second();
512 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
513 newProfileKeys
.put(recipientId
, profileKey
);
515 if (!page
.getPagingData().getHasMorePages()) {
518 fromRevision
= page
.getPagingData().getNextPageRevision();
521 newProfileKeys
.entrySet()
523 .filter(entry
-> account
.getProfileStore().getProfileKey(entry
.getKey()) == null)
524 .forEach(entry
-> account
.getProfileStore().storeProfileKey(entry
.getKey(), entry
.getValue()));
527 private GroupInfo
getGroupForUpdating(GroupId groupId
) throws GroupNotFoundException
, NotAGroupMemberException
{
528 var g
= getGroup(groupId
);
530 throw new GroupNotFoundException(groupId
);
532 if (!g
.isMember(account
.getSelfRecipientId()) && !g
.isPendingMember(account
.getSelfRecipientId())) {
533 throw new NotAGroupMemberException(groupId
, g
.getTitle());
535 if (groupId
instanceof GroupIdV2
) {
536 // Refresh group before updating
537 return getGroup(groupId
, true);
542 private SendGroupMessageResults
updateGroupV1(
543 final GroupInfoV1 gv1
, final String name
, final Set
<RecipientId
> members
, final byte[] avatarFile
544 ) throws IOException
, AttachmentInvalidException
{
545 updateGroupV1Details(gv1
, name
, members
, avatarFile
);
547 account
.getGroupStore().updateGroup(gv1
);
549 var messageBuilder
= getGroupUpdateMessageBuilder(gv1
);
550 return sendGroupMessage(messageBuilder
,
551 gv1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
552 gv1
.getDistributionId());
555 private void updateGroupV1Details(
556 final GroupInfoV1 g
, final String name
, final Collection
<RecipientId
> members
, final byte[] avatarFile
557 ) throws IOException
{
562 if (members
!= null) {
563 g
.addMembers(members
);
566 if (avatarFile
!= null) {
567 context
.getAvatarStore().storeGroupAvatar(g
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
572 * Change the expiration timer for a group
574 private void setExpirationTimer(
575 GroupInfoV1 groupInfoV1
, int messageExpirationTimer
576 ) throws NotAGroupMemberException
, GroupNotFoundException
, IOException
, GroupSendingNotAllowedException
{
577 groupInfoV1
.messageExpirationTime
= messageExpirationTimer
;
578 account
.getGroupStore().updateGroup(groupInfoV1
);
579 sendExpirationTimerUpdate(groupInfoV1
.getGroupId());
582 private void sendExpirationTimerUpdate(GroupIdV1 groupId
) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, GroupSendingNotAllowedException
{
583 final var messageBuilder
= SignalServiceDataMessage
.newBuilder().asExpirationUpdate();
584 context
.getSendHelper().sendAsGroupMessage(messageBuilder
, groupId
, false, Optional
.empty());
587 private SendGroupMessageResults
updateGroupV2(
588 final GroupInfoV2 group
,
590 final String description
,
591 final Set
<RecipientId
> members
,
592 final Set
<RecipientId
> removeMembers
,
593 final Set
<RecipientId
> admins
,
594 final Set
<RecipientId
> removeAdmins
,
595 final Set
<RecipientId
> banMembers
,
596 final Set
<RecipientId
> unbanMembers
,
597 final boolean resetGroupLink
,
598 final GroupLinkState groupLinkState
,
599 final GroupPermission addMemberPermission
,
600 final GroupPermission editDetailsPermission
,
601 final byte[] avatarFile
,
602 final Integer expirationTimer
,
603 final Boolean isAnnouncementGroup
604 ) throws IOException
{
605 SendGroupMessageResults result
= null;
606 final var groupV2Helper
= context
.getGroupV2Helper();
607 if (group
.isPendingMember(account
.getSelfRecipientId())) {
608 var groupGroupChangePair
= groupV2Helper
.acceptInvite(group
);
609 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
612 if (members
!= null) {
613 final var requestingMembers
= new HashSet
<>(members
);
614 requestingMembers
.retainAll(group
.getRequestingMembers());
615 if (!requestingMembers
.isEmpty()) {
616 var groupGroupChangePair
= groupV2Helper
.approveJoinRequestMembers(group
, requestingMembers
);
617 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
619 final var newMembers
= new HashSet
<>(members
);
620 newMembers
.removeAll(group
.getMembers());
621 newMembers
.removeAll(group
.getRequestingMembers());
622 if (!newMembers
.isEmpty()) {
623 var groupGroupChangePair
= groupV2Helper
.addMembers(group
, newMembers
);
624 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
628 if (removeMembers
!= null) {
629 var existingRemoveMembers
= new HashSet
<>(removeMembers
);
630 if (banMembers
!= null) {
631 existingRemoveMembers
.addAll(banMembers
);
633 existingRemoveMembers
.retainAll(group
.getMembers());
634 if (members
!= null) {
635 existingRemoveMembers
.removeAll(members
);
637 existingRemoveMembers
.remove(account
.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
638 if (!existingRemoveMembers
.isEmpty()) {
639 var groupGroupChangePair
= groupV2Helper
.removeMembers(group
, existingRemoveMembers
);
640 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
643 var pendingRemoveMembers
= new HashSet
<>(removeMembers
);
644 pendingRemoveMembers
.retainAll(group
.getPendingMembers());
645 if (!pendingRemoveMembers
.isEmpty()) {
646 var groupGroupChangePair
= groupV2Helper
.revokeInvitedMembers(group
, pendingRemoveMembers
);
647 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
649 var requestingRemoveMembers
= new HashSet
<>(removeMembers
);
650 requestingRemoveMembers
.retainAll(group
.getRequestingMembers());
651 if (!requestingRemoveMembers
.isEmpty()) {
652 var groupGroupChangePair
= groupV2Helper
.refuseJoinRequestMembers(group
, requestingRemoveMembers
);
653 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
657 if (admins
!= null) {
658 final var newAdmins
= new HashSet
<>(admins
);
659 newAdmins
.retainAll(group
.getMembers());
660 newAdmins
.removeAll(group
.getAdminMembers());
661 if (!newAdmins
.isEmpty()) {
662 for (var admin
: newAdmins
) {
663 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, true);
664 result
= sendUpdateGroupV2Message(group
,
665 groupGroupChangePair
.first(),
666 groupGroupChangePair
.second());
671 if (removeAdmins
!= null) {
672 final var existingRemoveAdmins
= new HashSet
<>(removeAdmins
);
673 existingRemoveAdmins
.retainAll(group
.getAdminMembers());
674 if (!existingRemoveAdmins
.isEmpty()) {
675 for (var admin
: existingRemoveAdmins
) {
676 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, false);
677 result
= sendUpdateGroupV2Message(group
,
678 groupGroupChangePair
.first(),
679 groupGroupChangePair
.second());
684 if (banMembers
!= null) {
685 final var newlyBannedMembers
= new HashSet
<>(banMembers
);
686 newlyBannedMembers
.removeAll(group
.getBannedMembers());
687 if (!newlyBannedMembers
.isEmpty()) {
688 var groupGroupChangePair
= groupV2Helper
.banMembers(group
, newlyBannedMembers
);
689 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
693 if (unbanMembers
!= null) {
694 var existingUnbanMembers
= new HashSet
<>(unbanMembers
);
695 existingUnbanMembers
.retainAll(group
.getBannedMembers());
696 if (!existingUnbanMembers
.isEmpty()) {
697 var groupGroupChangePair
= groupV2Helper
.unbanMembers(group
, existingUnbanMembers
);
698 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
702 if (resetGroupLink
) {
703 var groupGroupChangePair
= groupV2Helper
.resetGroupLinkPassword(group
);
704 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
707 if (groupLinkState
!= null) {
708 var groupGroupChangePair
= groupV2Helper
.setGroupLinkState(group
, groupLinkState
);
709 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
712 if (addMemberPermission
!= null) {
713 var groupGroupChangePair
= groupV2Helper
.setAddMemberPermission(group
, addMemberPermission
);
714 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
717 if (editDetailsPermission
!= null) {
718 var groupGroupChangePair
= groupV2Helper
.setEditDetailsPermission(group
, editDetailsPermission
);
719 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
722 if (expirationTimer
!= null) {
723 var groupGroupChangePair
= groupV2Helper
.setMessageExpirationTimer(group
, expirationTimer
);
724 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
727 if (isAnnouncementGroup
!= null) {
728 var groupGroupChangePair
= groupV2Helper
.setIsAnnouncementGroup(group
, isAnnouncementGroup
);
729 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
732 if (name
!= null || description
!= null || avatarFile
!= null) {
733 var groupGroupChangePair
= groupV2Helper
.updateGroup(group
, name
, description
, avatarFile
);
734 if (avatarFile
!= null) {
735 context
.getAvatarStore()
736 .storeGroupAvatar(group
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
738 result
= sendUpdateGroupV2Message(group
, groupGroupChangePair
.first(), groupGroupChangePair
.second());
744 private SendGroupMessageResults
quitGroupV1(final GroupInfoV1 groupInfoV1
) throws IOException
{
745 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.QUIT
)
746 .withId(groupInfoV1
.getGroupId().serialize())
749 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
);
750 groupInfoV1
.removeMember(account
.getSelfRecipientId());
751 account
.getGroupStore().updateGroup(groupInfoV1
);
752 return sendGroupMessage(messageBuilder
,
753 groupInfoV1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
754 groupInfoV1
.getDistributionId());
757 private SendGroupMessageResults
quitGroupV2(
758 final GroupInfoV2 groupInfoV2
, final Set
<RecipientId
> newAdmins
759 ) throws LastGroupAdminException
, IOException
{
760 final var currentAdmins
= groupInfoV2
.getAdminMembers();
761 newAdmins
.removeAll(currentAdmins
);
762 newAdmins
.retainAll(groupInfoV2
.getMembers());
763 if (currentAdmins
.contains(account
.getSelfRecipientId())
764 && currentAdmins
.size() == 1
765 && groupInfoV2
.getMembers().size() > 1
766 && newAdmins
.isEmpty()) {
767 // Last admin can't leave the group, unless she's also the last member
768 throw new LastGroupAdminException(groupInfoV2
.getGroupId(), groupInfoV2
.getTitle());
770 final var groupGroupChangePair
= context
.getGroupV2Helper().leaveGroup(groupInfoV2
, newAdmins
);
771 groupInfoV2
.setGroup(groupGroupChangePair
.first());
772 account
.getGroupStore().updateGroup(groupInfoV2
);
774 var messageBuilder
= getGroupUpdateMessageBuilder(groupInfoV2
, groupGroupChangePair
.second().encode());
775 return sendGroupMessage(messageBuilder
,
776 groupInfoV2
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
777 groupInfoV2
.getDistributionId());
780 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV1 g
) throws AttachmentInvalidException
{
781 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.UPDATE
)
782 .withId(g
.getGroupId().serialize())
784 .withMembers(g
.getMembers()
786 .map(context
.getRecipientHelper()::resolveSignalServiceAddress
)
790 final var attachment
= createGroupAvatarAttachment(g
.getGroupId());
791 attachment
.ifPresent(group
::withAvatar
);
792 } catch (IOException e
) {
793 throw new AttachmentInvalidException(g
.getGroupId().toBase64(), e
);
796 return SignalServiceDataMessage
.newBuilder()
797 .asGroupMessage(group
.build())
798 .withExpiration(g
.getMessageExpirationTimer());
801 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV2 g
, byte[] signedGroupChange
) {
802 var group
= SignalServiceGroupV2
.newBuilder(g
.getMasterKey())
803 .withRevision(g
.getGroup().revision
)
804 .withSignedGroupChange(signedGroupChange
);
805 return SignalServiceDataMessage
.newBuilder()
806 .asGroupMessage(group
.build())
807 .withExpiration(g
.getMessageExpirationTimer());
810 private SendGroupMessageResults
sendUpdateGroupV2Message(
811 GroupInfoV2 group
, DecryptedGroup newDecryptedGroup
, GroupChange groupChange
812 ) throws IOException
{
813 final var selfRecipientId
= account
.getSelfRecipientId();
814 final var members
= group
.getMembersIncludingPendingWithout(selfRecipientId
);
815 group
.setGroup(newDecryptedGroup
);
816 members
.addAll(group
.getMembersIncludingPendingWithout(selfRecipientId
));
817 account
.getGroupStore().updateGroup(group
);
819 final var messageBuilder
= getGroupUpdateMessageBuilder(group
, groupChange
.encode());
820 return sendGroupMessage(messageBuilder
, members
, group
.getDistributionId());
823 private SendGroupMessageResults
sendGroupMessage(
824 final SignalServiceDataMessage
.Builder messageBuilder
,
825 final Set
<RecipientId
> members
,
826 final DistributionId distributionId
827 ) throws IOException
{
828 final var timestamp
= System
.currentTimeMillis();
829 messageBuilder
.withTimestamp(timestamp
);
830 final var results
= context
.getSendHelper().sendGroupMessage(messageBuilder
.build(), members
, distributionId
);
831 return new SendGroupMessageResults(timestamp
,
833 .map(sendMessageResult
-> SendMessageResult
.from(sendMessageResult
,
834 account
.getRecipientResolver(),
835 account
.getRecipientAddressResolver()))
839 private byte[] readAvatarBytes(final String avatarFile
) throws IOException
{
840 if (avatarFile
== null) {
843 try (final var avatar
= Utils
.createStreamDetails(avatarFile
).first()) {
844 return IOUtils
.readFully(avatar
.getStream());