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
.GroupChangeResponse
;
37 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
38 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupChange
;
39 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroupJoinInfo
;
40 import org
.slf4j
.Logger
;
41 import org
.slf4j
.LoggerFactory
;
42 import org
.whispersystems
.signalservice
.api
.groupsv2
.DecryptedGroupChangeLog
;
43 import org
.whispersystems
.signalservice
.api
.groupsv2
.DecryptedGroupResponse
;
44 import org
.whispersystems
.signalservice
.api
.groupsv2
.GroupLinkNotActiveException
;
45 import org
.whispersystems
.signalservice
.api
.groupsv2
.ReceivedGroupSendEndorsements
;
46 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachment
;
47 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceAttachmentStream
;
48 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceDataMessage
;
49 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroup
;
50 import org
.whispersystems
.signalservice
.api
.messages
.SignalServiceGroupV2
;
51 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
52 import org
.whispersystems
.signalservice
.api
.push
.ServiceId
;
53 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.ConflictException
;
55 import java
.io
.IOException
;
56 import java
.io
.InputStream
;
57 import java
.io
.OutputStream
;
58 import java
.nio
.file
.Files
;
59 import java
.util
.Collection
;
60 import java
.util
.HashMap
;
61 import java
.util
.HashSet
;
62 import java
.util
.List
;
63 import java
.util
.Objects
;
64 import java
.util
.Optional
;
67 public class GroupHelper
{
69 private static final Logger logger
= LoggerFactory
.getLogger(GroupHelper
.class);
71 private final SignalAccount account
;
72 private final SignalDependencies dependencies
;
73 private final Context context
;
75 public GroupHelper(final Context context
) {
76 this.account
= context
.getAccount();
77 this.dependencies
= context
.getDependencies();
78 this.context
= context
;
81 public GroupInfo
getGroup(GroupId groupId
) {
82 return getGroup(groupId
, false);
85 public List
<GroupInfo
> getGroups() {
86 final var groups
= account
.getGroupStore().getGroups();
87 groups
.forEach(group
-> fillOrUpdateGroup(group
, false));
91 public boolean isGroupBlocked(final GroupId groupId
) {
92 var group
= getGroup(groupId
);
93 return group
!= null && group
.isBlocked();
96 public void downloadGroupAvatar(GroupIdV1 groupId
, SignalServiceAttachment avatar
) {
98 context
.getAvatarStore()
99 .storeGroupAvatar(groupId
,
100 outputStream
-> context
.getAttachmentHelper().retrieveAttachment(avatar
, outputStream
));
101 } catch (IOException e
) {
102 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
106 public Optional
<SignalServiceAttachmentStream
> createGroupAvatarAttachment(GroupIdV1 groupId
) throws IOException
{
107 final var streamDetails
= context
.getAvatarStore().retrieveGroupAvatar(groupId
);
108 if (streamDetails
== null) {
109 return Optional
.empty();
112 final var uploadSpec
= dependencies
.getMessageSender().getResumableUploadSpec().toProto();
113 return Optional
.of(AttachmentUtils
.createAttachmentStream(streamDetails
, Optional
.empty(), uploadSpec
));
116 public GroupInfoV2
getOrMigrateGroup(
117 final GroupMasterKey groupMasterKey
, final int revision
, final byte[] signedGroupChange
119 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupMasterKey
);
121 final var groupId
= GroupUtils
.getGroupIdV2(groupSecretParams
);
122 final var groupInfoV2
= account
.getGroupStore().getGroupOrPartialMigrate(groupMasterKey
, groupId
);
124 if (groupInfoV2
.getGroup() == null || groupInfoV2
.getGroup().revision
< revision
) {
125 DecryptedGroup group
= null;
126 if (signedGroupChange
!= null
127 && groupInfoV2
.getGroup() != null
128 && groupInfoV2
.getGroup().revision
+ 1 == revision
) {
129 final var decryptedGroupChange
= context
.getGroupV2Helper()
130 .getDecryptedGroupChange(signedGroupChange
, groupMasterKey
);
132 if (decryptedGroupChange
!= null) {
133 storeProfileKeyFromChange(decryptedGroupChange
);
134 group
= context
.getGroupV2Helper()
135 .getUpdatedDecryptedGroup(groupInfoV2
.getGroup(), decryptedGroupChange
);
140 final var response
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
142 if (response
!= null) {
143 group
= handleDecryptedGroupResponse(groupInfoV2
, response
);
144 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, group
);
146 } catch (NotAGroupMemberException ignored
) {
150 storeProfileKeysFromMembers(group
);
151 final var avatar
= group
.avatar
;
152 if (!avatar
.isEmpty()) {
153 downloadGroupAvatar(groupId
, groupSecretParams
, avatar
);
156 groupInfoV2
.setGroup(group
);
157 account
.getGroupStore().updateGroup(groupInfoV2
);
158 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
164 private DecryptedGroup
handleDecryptedGroupResponse(
165 GroupInfoV2 groupInfoV2
, final DecryptedGroupResponse decryptedGroupResponse
167 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
168 ReceivedGroupSendEndorsements groupSendEndorsements
= dependencies
.getGroupsV2Operations()
169 .forGroup(groupSecretParams
)
170 .receiveGroupSendEndorsements(account
.getAci(),
171 decryptedGroupResponse
.getGroup(),
172 decryptedGroupResponse
.getGroupSendEndorsementsResponse());
174 // TODO save group endorsements
176 return decryptedGroupResponse
.getGroup();
179 private GroupChange
handleGroupChangeResponse(
180 final GroupInfoV2 groupInfoV2
, final GroupChangeResponse groupChangeResponse
182 ReceivedGroupSendEndorsements groupSendEndorsements
= dependencies
.getGroupsV2Operations()
183 .forGroup(GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey()))
184 .receiveGroupSendEndorsements(account
.getAci(),
185 groupInfoV2
.getGroup(),
186 groupChangeResponse
.groupSendEndorsementsResponse
);
188 // TODO save group endorsements
190 return groupChangeResponse
.groupChange
;
193 public Pair
<GroupId
, SendGroupMessageResults
> createGroup(
194 String name
, Set
<RecipientId
> members
, String avatarFile
195 ) throws IOException
, AttachmentInvalidException
{
196 final var selfRecipientId
= account
.getSelfRecipientId();
197 if (members
!= null && members
.contains(selfRecipientId
)) {
198 members
= new HashSet
<>(members
);
199 members
.remove(selfRecipientId
);
202 final var avatarBytes
= readAvatarBytes(avatarFile
);
203 var gv2Pair
= context
.getGroupV2Helper()
204 .createGroup(name
== null ?
"" : name
, members
== null ? Set
.of() : members
, avatarBytes
);
206 if (gv2Pair
== null) {
207 // Failed to create v2 group, creating v1 group instead
208 var gv1
= new GroupInfoV1(GroupIdV1
.createRandom());
209 gv1
.setProfileSharingEnabled(true);
210 gv1
.addMembers(List
.of(selfRecipientId
));
211 final var result
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
212 return new Pair
<>(gv1
.getGroupId(), result
);
215 final var gv2
= gv2Pair
.first();
216 final var decryptedGroup
= gv2Pair
.second();
218 gv2
.setGroup(handleDecryptedGroupResponse(gv2
, decryptedGroup
));
219 gv2
.setProfileSharingEnabled(true);
220 if (avatarBytes
!= null) {
221 context
.getAvatarStore()
222 .storeGroupAvatar(gv2
.getGroupId(), outputStream
-> outputStream
.write(avatarBytes
));
225 account
.getGroupStore().updateGroup(gv2
);
227 final var messageBuilder
= getGroupUpdateMessageBuilder(gv2
, null);
229 final var result
= sendGroupMessage(messageBuilder
,
230 gv2
.getMembersIncludingPendingWithout(selfRecipientId
),
231 gv2
.getDistributionId());
232 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
233 return new Pair
<>(gv2
.getGroupId(), result
);
236 public SendGroupMessageResults
updateGroup(
237 final GroupId groupId
,
239 final String description
,
240 final Set
<RecipientId
> members
,
241 final Set
<RecipientId
> removeMembers
,
242 final Set
<RecipientId
> admins
,
243 final Set
<RecipientId
> removeAdmins
,
244 final Set
<RecipientId
> banMembers
,
245 final Set
<RecipientId
> unbanMembers
,
246 final boolean resetGroupLink
,
247 final GroupLinkState groupLinkState
,
248 final GroupPermission addMemberPermission
,
249 final GroupPermission editDetailsPermission
,
250 final String avatarFile
,
251 final Integer expirationTimer
,
252 final Boolean isAnnouncementGroup
253 ) throws IOException
, GroupNotFoundException
, AttachmentInvalidException
, NotAGroupMemberException
, GroupSendingNotAllowedException
{
254 var group
= getGroupForUpdating(groupId
);
255 final var avatarBytes
= readAvatarBytes(avatarFile
);
257 SendGroupMessageResults results
;
259 case GroupInfoV2 gv2
-> {
261 results
= updateGroupV2(gv2
,
273 editDetailsPermission
,
276 isAnnouncementGroup
);
277 } catch (ConflictException e
) {
278 // Detected conflicting update, refreshing group and trying again
279 group
= getGroup(groupId
, true);
280 results
= updateGroupV2((GroupInfoV2
) group
,
292 editDetailsPermission
,
295 isAnnouncementGroup
);
299 case GroupInfoV1 gv1
-> {
300 results
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
301 if (expirationTimer
!= null) {
302 setExpirationTimer(gv1
, expirationTimer
);
306 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
310 public void updateGroupProfileKey(GroupIdV2 groupId
) throws GroupNotFoundException
, NotAGroupMemberException
, IOException
{
311 var group
= getGroupForUpdating(groupId
);
313 if (group
instanceof GroupInfoV2 groupInfoV2
) {
314 Pair
<DecryptedGroup
, GroupChangeResponse
> groupChangePair
;
316 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
317 } catch (ConflictException e
) {
318 // Detected conflicting update, refreshing group and trying again
319 groupInfoV2
= (GroupInfoV2
) getGroup(groupId
, true);
320 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
322 if (groupChangePair
!= null) {
323 sendUpdateGroupV2Message(groupInfoV2
,
324 groupChangePair
.first(),
325 handleGroupChangeResponse(groupInfoV2
, groupChangePair
.second()));
330 public Pair
<GroupId
, SendGroupMessageResults
> joinGroup(
331 GroupInviteLinkUrl inviteLinkUrl
332 ) throws IOException
, InactiveGroupLinkException
, PendingAdminApprovalException
{
333 final DecryptedGroupJoinInfo groupJoinInfo
;
335 groupJoinInfo
= context
.getGroupV2Helper()
336 .getDecryptedGroupJoinInfo(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword());
337 } catch (GroupLinkNotActiveException e
) {
338 throw new InactiveGroupLinkException("Group link inactive (reason: " + e
.getReason() + ")", e
);
340 if (groupJoinInfo
.pendingAdminApproval
) {
341 throw new PendingAdminApprovalException("You have already requested to join the group.");
343 final var changeResponse
= context
.getGroupV2Helper()
344 .joinGroup(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword(), groupJoinInfo
);
345 final var group
= getOrMigrateGroup(inviteLinkUrl
.getGroupMasterKey(),
346 groupJoinInfo
.revision
+ 1,
347 changeResponse
.groupChange
== null ?
null : changeResponse
.groupChange
.encode());
348 final var groupChange
= handleGroupChangeResponse(group
, changeResponse
);
350 if (group
.getGroup() == null) {
351 // Only requested member, can't send update to group members
352 return new Pair
<>(group
.getGroupId(), new SendGroupMessageResults(0, List
.of()));
355 final var result
= sendUpdateGroupV2Message(group
, group
.getGroup(), groupChange
);
357 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
358 return new Pair
<>(group
.getGroupId(), result
);
361 public SendGroupMessageResults
quitGroup(
362 final GroupId groupId
, final Set
<RecipientId
> newAdmins
363 ) throws IOException
, LastGroupAdminException
, NotAGroupMemberException
, GroupNotFoundException
{
364 var group
= getGroupForUpdating(groupId
);
365 if (group
instanceof GroupInfoV1
) {
366 return quitGroupV1((GroupInfoV1
) group
);
370 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
371 } catch (ConflictException e
) {
372 // Detected conflicting update, refreshing group and trying again
373 group
= getGroup(groupId
, true);
374 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
378 public void deleteGroup(GroupId groupId
) throws IOException
{
379 account
.getGroupStore().deleteGroup(groupId
);
380 context
.getAvatarStore().deleteGroupAvatar(groupId
);
381 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
384 public void setGroupBlocked(final GroupId groupId
, final boolean blocked
) throws GroupNotFoundException
{
385 var group
= getGroup(groupId
);
387 throw new GroupNotFoundException(groupId
);
390 group
.setBlocked(blocked
);
391 account
.getGroupStore().updateGroup(group
);
392 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
395 public SendGroupMessageResults
sendGroupInfoRequest(
396 GroupIdV1 groupId
, RecipientId recipientId
397 ) throws IOException
{
398 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.REQUEST_INFO
).withId(groupId
.serialize());
400 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
.build());
402 // Send group info request message to the recipient who sent us a message with this groupId
403 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
406 public SendGroupMessageResults
sendGroupInfoMessage(
407 GroupIdV1 groupId
, RecipientId recipientId
408 ) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, AttachmentInvalidException
{
410 var group
= getGroupForUpdating(groupId
);
411 if (!(group
instanceof GroupInfoV1
)) {
412 throw new IOException("Received an invalid group request for a v2 group!");
414 g
= (GroupInfoV1
) group
;
416 if (!g
.isMember(recipientId
)) {
417 throw new NotAGroupMemberException(groupId
, g
.name
);
420 var messageBuilder
= getGroupUpdateMessageBuilder(g
);
422 // Send group message only to the recipient who requested it
423 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
426 private GroupInfo
getGroup(GroupId groupId
, boolean forceUpdate
) {
427 final var group
= account
.getGroupStore().getGroup(groupId
);
428 fillOrUpdateGroup(group
, forceUpdate
);
432 private void fillOrUpdateGroup(final GroupInfo group
, final boolean forceUpdate
) {
433 if (!(group
instanceof GroupInfoV2 groupInfoV2
)) {
437 if (!forceUpdate
&& (groupInfoV2
.isPermissionDenied() || groupInfoV2
.getGroup() != null)) {
441 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
442 DecryptedGroup decryptedGroup
;
444 final var response
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
445 if (response
== null) {
448 decryptedGroup
= handleDecryptedGroupResponse(groupInfoV2
, response
);
449 } catch (NotAGroupMemberException e
) {
450 groupInfoV2
.setPermissionDenied(true);
451 account
.getGroupStore().updateGroup(group
);
456 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, decryptedGroup
);
457 } catch (NotAGroupMemberException ignored
) {
459 storeProfileKeysFromMembers(decryptedGroup
);
460 final var avatar
= decryptedGroup
.avatar
;
461 if (!avatar
.isEmpty()) {
462 downloadGroupAvatar(groupInfoV2
.getGroupId(), groupSecretParams
, avatar
);
464 groupInfoV2
.setGroup(decryptedGroup
);
465 account
.getGroupStore().updateGroup(group
);
468 private void downloadGroupAvatar(GroupIdV2 groupId
, GroupSecretParams groupSecretParams
, String cdnKey
) {
470 context
.getAvatarStore()
471 .storeGroupAvatar(groupId
,
472 outputStream
-> retrieveGroupV2Avatar(groupSecretParams
, cdnKey
, outputStream
));
473 } catch (IOException e
) {
474 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
478 private void retrieveGroupV2Avatar(
479 GroupSecretParams groupSecretParams
, String cdnKey
, OutputStream outputStream
480 ) throws IOException
{
481 var groupOperations
= dependencies
.getGroupsV2Operations().forGroup(groupSecretParams
);
483 var tmpFile
= IOUtils
.createTempFile();
484 try (InputStream input
= dependencies
.getMessageReceiver()
485 .retrieveGroupsV2ProfileAvatar(cdnKey
, tmpFile
, ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
486 var encryptedData
= IOUtils
.readFully(input
);
488 var decryptedData
= groupOperations
.decryptAvatar(encryptedData
);
489 outputStream
.write(decryptedData
);
492 Files
.delete(tmpFile
.toPath());
493 } catch (IOException e
) {
494 logger
.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
501 private void storeProfileKeysFromMembers(final DecryptedGroup group
) {
502 for (var member
: group
.members
) {
503 final var serviceId
= ServiceId
.parseOrThrow(member
.aciBytes
);
504 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
505 final var profileStore
= account
.getProfileStore();
506 if (profileStore
.getProfileKey(recipientId
) != null) {
507 // We already have a profile key, not updating it from a non-authoritative source
511 profileStore
.storeProfileKey(recipientId
, new ProfileKey(member
.profileKey
.toByteArray()));
512 } catch (InvalidInputException ignored
) {
517 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange
) {
518 final var profileKeyFromChange
= context
.getGroupV2Helper()
519 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange
);
521 if (profileKeyFromChange
!= null) {
522 final var serviceId
= profileKeyFromChange
.first();
523 final var profileKey
= profileKeyFromChange
.second();
524 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
525 account
.getProfileStore().storeProfileKey(recipientId
, profileKey
);
529 private void storeProfileKeysFromHistory(
530 final GroupSecretParams groupSecretParams
,
531 final GroupInfoV2 localGroup
,
532 final DecryptedGroup newDecryptedGroup
533 ) throws NotAGroupMemberException
{
534 final var revisionWeWereAdded
= context
.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup
);
535 final var localRevision
= localGroup
.getGroup() == null ?
0 : localGroup
.getGroup().revision
;
536 final var sendEndorsementsExpirationMs
= 0L;// TODO store expiration localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
537 var fromRevision
= Math
.max(revisionWeWereAdded
, localRevision
);
538 final var newProfileKeys
= new HashMap
<RecipientId
, ProfileKey
>();
540 final var page
= context
.getGroupV2Helper()
541 .getDecryptedGroupHistoryPage(groupSecretParams
, fromRevision
, sendEndorsementsExpirationMs
);
544 .map(DecryptedGroupChangeLog
::getChange
)
545 .filter(Objects
::nonNull
)
546 .map(context
.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange
)
547 .filter(Objects
::nonNull
)
549 final var serviceId
= p
.first();
550 final var profileKey
= p
.second();
551 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
552 newProfileKeys
.put(recipientId
, profileKey
);
554 if (!page
.getPagingData().getHasMorePages()) {
557 fromRevision
= page
.getPagingData().getNextPageRevision();
560 newProfileKeys
.entrySet()
562 .filter(entry
-> account
.getProfileStore().getProfileKey(entry
.getKey()) == null)
563 .forEach(entry
-> account
.getProfileStore().storeProfileKey(entry
.getKey(), entry
.getValue()));
566 private GroupInfo
getGroupForUpdating(GroupId groupId
) throws GroupNotFoundException
, NotAGroupMemberException
{
567 var g
= getGroup(groupId
);
569 throw new GroupNotFoundException(groupId
);
571 if (!g
.isMember(account
.getSelfRecipientId()) && !g
.isPendingMember(account
.getSelfRecipientId())) {
572 throw new NotAGroupMemberException(groupId
, g
.getTitle());
574 if (groupId
instanceof GroupIdV2
) {
575 // Refresh group before updating
576 return getGroup(groupId
, true);
581 private SendGroupMessageResults
updateGroupV1(
582 final GroupInfoV1 gv1
, final String name
, final Set
<RecipientId
> members
, final byte[] avatarFile
583 ) throws IOException
, AttachmentInvalidException
{
584 updateGroupV1Details(gv1
, name
, members
, avatarFile
);
586 account
.getGroupStore().updateGroup(gv1
);
588 var messageBuilder
= getGroupUpdateMessageBuilder(gv1
);
589 return sendGroupMessage(messageBuilder
,
590 gv1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
591 gv1
.getDistributionId());
594 private void updateGroupV1Details(
595 final GroupInfoV1 g
, final String name
, final Collection
<RecipientId
> members
, final byte[] avatarFile
596 ) throws IOException
{
601 if (members
!= null) {
602 g
.addMembers(members
);
605 if (avatarFile
!= null) {
606 context
.getAvatarStore().storeGroupAvatar(g
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
611 * Change the expiration timer for a group
613 private void setExpirationTimer(
614 GroupInfoV1 groupInfoV1
, int messageExpirationTimer
615 ) throws NotAGroupMemberException
, GroupNotFoundException
, IOException
, GroupSendingNotAllowedException
{
616 groupInfoV1
.messageExpirationTime
= messageExpirationTimer
;
617 account
.getGroupStore().updateGroup(groupInfoV1
);
618 sendExpirationTimerUpdate(groupInfoV1
.getGroupId());
621 private void sendExpirationTimerUpdate(GroupIdV1 groupId
) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, GroupSendingNotAllowedException
{
622 final var messageBuilder
= SignalServiceDataMessage
.newBuilder().asExpirationUpdate();
623 context
.getSendHelper().sendAsGroupMessage(messageBuilder
, groupId
, false, Optional
.empty());
626 private SendGroupMessageResults
updateGroupV2(
627 final GroupInfoV2 group
,
629 final String description
,
630 final Set
<RecipientId
> members
,
631 final Set
<RecipientId
> removeMembers
,
632 final Set
<RecipientId
> admins
,
633 final Set
<RecipientId
> removeAdmins
,
634 final Set
<RecipientId
> banMembers
,
635 final Set
<RecipientId
> unbanMembers
,
636 final boolean resetGroupLink
,
637 final GroupLinkState groupLinkState
,
638 final GroupPermission addMemberPermission
,
639 final GroupPermission editDetailsPermission
,
640 final byte[] avatarFile
,
641 final Integer expirationTimer
,
642 final Boolean isAnnouncementGroup
643 ) throws IOException
{
644 SendGroupMessageResults result
= null;
645 final var groupV2Helper
= context
.getGroupV2Helper();
646 if (group
.isPendingMember(account
.getSelfRecipientId())) {
647 var groupGroupChangePair
= groupV2Helper
.acceptInvite(group
);
648 result
= sendUpdateGroupV2Message(group
,
649 groupGroupChangePair
.first(),
650 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
653 if (members
!= null) {
654 final var requestingMembers
= new HashSet
<>(members
);
655 requestingMembers
.retainAll(group
.getRequestingMembers());
656 if (!requestingMembers
.isEmpty()) {
657 var groupGroupChangePair
= groupV2Helper
.approveJoinRequestMembers(group
, requestingMembers
);
658 result
= sendUpdateGroupV2Message(group
,
659 groupGroupChangePair
.first(),
660 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
662 final var newMembers
= new HashSet
<>(members
);
663 newMembers
.removeAll(group
.getMembers());
664 newMembers
.removeAll(group
.getRequestingMembers());
665 if (!newMembers
.isEmpty()) {
666 var groupGroupChangePair
= groupV2Helper
.addMembers(group
, newMembers
);
667 result
= sendUpdateGroupV2Message(group
,
668 groupGroupChangePair
.first(),
669 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
673 if (removeMembers
!= null) {
674 var existingRemoveMembers
= new HashSet
<>(removeMembers
);
675 if (banMembers
!= null) {
676 existingRemoveMembers
.addAll(banMembers
);
678 existingRemoveMembers
.retainAll(group
.getMembers());
679 if (members
!= null) {
680 existingRemoveMembers
.removeAll(members
);
682 existingRemoveMembers
.remove(account
.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
683 if (!existingRemoveMembers
.isEmpty()) {
684 var groupGroupChangePair
= groupV2Helper
.removeMembers(group
, existingRemoveMembers
);
685 result
= sendUpdateGroupV2Message(group
,
686 groupGroupChangePair
.first(),
687 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
690 var pendingRemoveMembers
= new HashSet
<>(removeMembers
);
691 pendingRemoveMembers
.retainAll(group
.getPendingMembers());
692 if (!pendingRemoveMembers
.isEmpty()) {
693 var groupGroupChangePair
= groupV2Helper
.revokeInvitedMembers(group
, pendingRemoveMembers
);
694 result
= sendUpdateGroupV2Message(group
,
695 groupGroupChangePair
.first(),
696 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
698 var requestingRemoveMembers
= new HashSet
<>(removeMembers
);
699 requestingRemoveMembers
.retainAll(group
.getRequestingMembers());
700 if (!requestingRemoveMembers
.isEmpty()) {
701 var groupGroupChangePair
= groupV2Helper
.refuseJoinRequestMembers(group
, requestingRemoveMembers
);
702 result
= sendUpdateGroupV2Message(group
,
703 groupGroupChangePair
.first(),
704 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
708 if (admins
!= null) {
709 final var newAdmins
= new HashSet
<>(admins
);
710 newAdmins
.retainAll(group
.getMembers());
711 newAdmins
.removeAll(group
.getAdminMembers());
712 if (!newAdmins
.isEmpty()) {
713 for (var admin
: newAdmins
) {
714 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, true);
715 result
= sendUpdateGroupV2Message(group
,
716 groupGroupChangePair
.first(),
717 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
722 if (removeAdmins
!= null) {
723 final var existingRemoveAdmins
= new HashSet
<>(removeAdmins
);
724 existingRemoveAdmins
.retainAll(group
.getAdminMembers());
725 if (!existingRemoveAdmins
.isEmpty()) {
726 for (var admin
: existingRemoveAdmins
) {
727 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, false);
728 result
= sendUpdateGroupV2Message(group
,
729 groupGroupChangePair
.first(),
730 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
735 if (banMembers
!= null) {
736 final var newlyBannedMembers
= new HashSet
<>(banMembers
);
737 newlyBannedMembers
.removeAll(group
.getBannedMembers());
738 if (!newlyBannedMembers
.isEmpty()) {
739 var groupGroupChangePair
= groupV2Helper
.banMembers(group
, newlyBannedMembers
);
740 result
= sendUpdateGroupV2Message(group
,
741 groupGroupChangePair
.first(),
742 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
746 if (unbanMembers
!= null) {
747 var existingUnbanMembers
= new HashSet
<>(unbanMembers
);
748 existingUnbanMembers
.retainAll(group
.getBannedMembers());
749 if (!existingUnbanMembers
.isEmpty()) {
750 var groupGroupChangePair
= groupV2Helper
.unbanMembers(group
, existingUnbanMembers
);
751 result
= sendUpdateGroupV2Message(group
,
752 groupGroupChangePair
.first(),
753 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
757 if (resetGroupLink
) {
758 var groupGroupChangePair
= groupV2Helper
.resetGroupLinkPassword(group
);
759 result
= sendUpdateGroupV2Message(group
,
760 groupGroupChangePair
.first(),
761 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
764 if (groupLinkState
!= null) {
765 var groupGroupChangePair
= groupV2Helper
.setGroupLinkState(group
, groupLinkState
);
766 result
= sendUpdateGroupV2Message(group
,
767 groupGroupChangePair
.first(),
768 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
771 if (addMemberPermission
!= null) {
772 var groupGroupChangePair
= groupV2Helper
.setAddMemberPermission(group
, addMemberPermission
);
773 result
= sendUpdateGroupV2Message(group
,
774 groupGroupChangePair
.first(),
775 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
778 if (editDetailsPermission
!= null) {
779 var groupGroupChangePair
= groupV2Helper
.setEditDetailsPermission(group
, editDetailsPermission
);
780 result
= sendUpdateGroupV2Message(group
,
781 groupGroupChangePair
.first(),
782 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
785 if (expirationTimer
!= null) {
786 var groupGroupChangePair
= groupV2Helper
.setMessageExpirationTimer(group
, expirationTimer
);
787 result
= sendUpdateGroupV2Message(group
,
788 groupGroupChangePair
.first(),
789 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
792 if (isAnnouncementGroup
!= null) {
793 var groupGroupChangePair
= groupV2Helper
.setIsAnnouncementGroup(group
, isAnnouncementGroup
);
794 result
= sendUpdateGroupV2Message(group
,
795 groupGroupChangePair
.first(),
796 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
799 if (name
!= null || description
!= null || avatarFile
!= null) {
800 var groupGroupChangePair
= groupV2Helper
.updateGroup(group
, name
, description
, avatarFile
);
801 if (avatarFile
!= null) {
802 context
.getAvatarStore()
803 .storeGroupAvatar(group
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
805 result
= sendUpdateGroupV2Message(group
,
806 groupGroupChangePair
.first(),
807 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
813 private SendGroupMessageResults
quitGroupV1(final GroupInfoV1 groupInfoV1
) throws IOException
{
814 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.QUIT
)
815 .withId(groupInfoV1
.getGroupId().serialize())
818 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
);
819 groupInfoV1
.removeMember(account
.getSelfRecipientId());
820 account
.getGroupStore().updateGroup(groupInfoV1
);
821 return sendGroupMessage(messageBuilder
,
822 groupInfoV1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
823 groupInfoV1
.getDistributionId());
826 private SendGroupMessageResults
quitGroupV2(
827 final GroupInfoV2 groupInfoV2
, final Set
<RecipientId
> newAdmins
828 ) throws LastGroupAdminException
, IOException
{
829 final var currentAdmins
= groupInfoV2
.getAdminMembers();
830 newAdmins
.removeAll(currentAdmins
);
831 newAdmins
.retainAll(groupInfoV2
.getMembers());
832 if (currentAdmins
.contains(account
.getSelfRecipientId())
833 && currentAdmins
.size() == 1
834 && groupInfoV2
.getMembers().size() > 1
835 && newAdmins
.isEmpty()) {
836 // Last admin can't leave the group, unless she's also the last member
837 throw new LastGroupAdminException(groupInfoV2
.getGroupId(), groupInfoV2
.getTitle());
839 final var groupGroupChangePair
= context
.getGroupV2Helper().leaveGroup(groupInfoV2
, newAdmins
);
840 groupInfoV2
.setGroup(groupGroupChangePair
.first());
841 account
.getGroupStore().updateGroup(groupInfoV2
);
843 var messageBuilder
= getGroupUpdateMessageBuilder(groupInfoV2
,
844 handleGroupChangeResponse(groupInfoV2
, groupGroupChangePair
.second()).encode());
845 return sendGroupMessage(messageBuilder
,
846 groupInfoV2
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
847 groupInfoV2
.getDistributionId());
850 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV1 g
) throws AttachmentInvalidException
{
851 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.UPDATE
)
852 .withId(g
.getGroupId().serialize())
854 .withMembers(g
.getMembers()
856 .map(context
.getRecipientHelper()::resolveSignalServiceAddress
)
860 final var attachment
= createGroupAvatarAttachment(g
.getGroupId());
861 attachment
.ifPresent(group
::withAvatar
);
862 } catch (IOException e
) {
863 throw new AttachmentInvalidException(g
.getGroupId().toBase64(), e
);
866 return SignalServiceDataMessage
.newBuilder()
867 .asGroupMessage(group
.build())
868 .withExpiration(g
.getMessageExpirationTimer());
871 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV2 g
, byte[] signedGroupChange
) {
872 var group
= SignalServiceGroupV2
.newBuilder(g
.getMasterKey())
873 .withRevision(g
.getGroup().revision
)
874 .withSignedGroupChange(signedGroupChange
);
875 return SignalServiceDataMessage
.newBuilder()
876 .asGroupMessage(group
.build())
877 .withExpiration(g
.getMessageExpirationTimer());
880 private SendGroupMessageResults
sendUpdateGroupV2Message(
881 GroupInfoV2 group
, DecryptedGroup newDecryptedGroup
, GroupChange groupChange
882 ) throws IOException
{
883 final var selfRecipientId
= account
.getSelfRecipientId();
884 final var members
= group
.getMembersIncludingPendingWithout(selfRecipientId
);
885 group
.setGroup(newDecryptedGroup
);
886 members
.addAll(group
.getMembersIncludingPendingWithout(selfRecipientId
));
887 account
.getGroupStore().updateGroup(group
);
889 final var messageBuilder
= getGroupUpdateMessageBuilder(group
, groupChange
.encode());
890 return sendGroupMessage(messageBuilder
, members
, group
.getDistributionId());
893 private SendGroupMessageResults
sendGroupMessage(
894 final SignalServiceDataMessage
.Builder messageBuilder
,
895 final Set
<RecipientId
> members
,
896 final DistributionId distributionId
897 ) throws IOException
{
898 final var timestamp
= System
.currentTimeMillis();
899 messageBuilder
.withTimestamp(timestamp
);
900 final var results
= context
.getSendHelper().sendGroupMessage(messageBuilder
.build(), members
, distributionId
);
901 return new SendGroupMessageResults(timestamp
,
903 .map(sendMessageResult
-> SendMessageResult
.from(sendMessageResult
,
904 account
.getRecipientResolver(),
905 account
.getRecipientAddressResolver()))
909 private byte[] readAvatarBytes(final String avatarFile
) throws IOException
{
910 if (avatarFile
== null) {
913 try (final var avatar
= Utils
.createStreamDetails(avatarFile
).first()) {
914 return IOUtils
.readFully(avatar
.getStream());