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 void updateGroupSendEndorsements(GroupId groupId
) {
86 getGroup(groupId
, true);
89 public List
<GroupInfo
> getGroups() {
90 final var groups
= account
.getGroupStore().getGroups();
91 groups
.forEach(group
-> fillOrUpdateGroup(group
, false));
95 public boolean isGroupBlocked(final GroupId groupId
) {
96 var group
= getGroup(groupId
);
97 return group
!= null && group
.isBlocked();
100 public void downloadGroupAvatar(GroupIdV1 groupId
, SignalServiceAttachment avatar
) {
102 context
.getAvatarStore()
103 .storeGroupAvatar(groupId
,
104 outputStream
-> context
.getAttachmentHelper().retrieveAttachment(avatar
, outputStream
));
105 } catch (IOException e
) {
106 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
110 public Optional
<SignalServiceAttachmentStream
> createGroupAvatarAttachment(GroupIdV1 groupId
) throws IOException
{
111 final var streamDetails
= context
.getAvatarStore().retrieveGroupAvatar(groupId
);
112 if (streamDetails
== null) {
113 return Optional
.empty();
116 final var uploadSpec
= dependencies
.getMessageSender().getResumableUploadSpec().toProto();
117 return Optional
.of(AttachmentUtils
.createAttachmentStream(streamDetails
, Optional
.empty(), uploadSpec
));
120 public GroupInfoV2
getOrMigrateGroup(
121 final GroupMasterKey groupMasterKey
, final int revision
, final byte[] signedGroupChange
123 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupMasterKey
);
125 final var groupId
= GroupUtils
.getGroupIdV2(groupSecretParams
);
126 final var groupInfoV2
= account
.getGroupStore().getGroupOrPartialMigrate(groupMasterKey
, groupId
);
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 final var response
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
146 if (response
!= null) {
147 group
= handleDecryptedGroupResponse(groupInfoV2
, response
);
148 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, group
);
150 } catch (NotAGroupMemberException ignored
) {
154 storeProfileKeysFromMembers(group
);
155 final var avatar
= group
.avatar
;
156 if (!avatar
.isEmpty()) {
157 downloadGroupAvatar(groupId
, groupSecretParams
, avatar
);
160 groupInfoV2
.setGroup(group
);
161 account
.getGroupStore().updateGroup(groupInfoV2
);
162 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
168 private DecryptedGroup
handleDecryptedGroupResponse(
169 GroupInfoV2 groupInfoV2
, final DecryptedGroupResponse decryptedGroupResponse
171 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
172 ReceivedGroupSendEndorsements groupSendEndorsements
= dependencies
.getGroupsV2Operations()
173 .forGroup(groupSecretParams
)
174 .receiveGroupSendEndorsements(account
.getAci(),
175 decryptedGroupResponse
.getGroup(),
176 decryptedGroupResponse
.getGroupSendEndorsementsResponse());
178 // TODO save group endorsements
180 return decryptedGroupResponse
.getGroup();
183 private GroupChange
handleGroupChangeResponse(
184 final GroupInfoV2 groupInfoV2
, final GroupChangeResponse groupChangeResponse
186 ReceivedGroupSendEndorsements groupSendEndorsements
= dependencies
.getGroupsV2Operations()
187 .forGroup(GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey()))
188 .receiveGroupSendEndorsements(account
.getAci(),
189 groupInfoV2
.getGroup(),
190 groupChangeResponse
.groupSendEndorsementsResponse
);
192 // TODO save group endorsements
194 return groupChangeResponse
.groupChange
;
197 public Pair
<GroupId
, SendGroupMessageResults
> createGroup(
198 String name
, Set
<RecipientId
> members
, String avatarFile
199 ) throws IOException
, AttachmentInvalidException
{
200 final var selfRecipientId
= account
.getSelfRecipientId();
201 if (members
!= null && members
.contains(selfRecipientId
)) {
202 members
= new HashSet
<>(members
);
203 members
.remove(selfRecipientId
);
206 final var avatarBytes
= readAvatarBytes(avatarFile
);
207 var gv2Pair
= context
.getGroupV2Helper()
208 .createGroup(name
== null ?
"" : name
, members
== null ? Set
.of() : members
, avatarBytes
);
210 if (gv2Pair
== null) {
211 // Failed to create v2 group, creating v1 group instead
212 var gv1
= new GroupInfoV1(GroupIdV1
.createRandom());
213 gv1
.setProfileSharingEnabled(true);
214 gv1
.addMembers(List
.of(selfRecipientId
));
215 final var result
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
216 return new Pair
<>(gv1
.getGroupId(), result
);
219 final var gv2
= gv2Pair
.first();
220 final var decryptedGroup
= gv2Pair
.second();
222 gv2
.setGroup(handleDecryptedGroupResponse(gv2
, decryptedGroup
));
223 gv2
.setProfileSharingEnabled(true);
224 if (avatarBytes
!= null) {
225 context
.getAvatarStore()
226 .storeGroupAvatar(gv2
.getGroupId(), outputStream
-> outputStream
.write(avatarBytes
));
229 account
.getGroupStore().updateGroup(gv2
);
231 final var messageBuilder
= getGroupUpdateMessageBuilder(gv2
, null);
233 final var result
= sendGroupMessage(messageBuilder
,
234 gv2
.getMembersIncludingPendingWithout(selfRecipientId
),
235 gv2
.getDistributionId());
236 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
237 return new Pair
<>(gv2
.getGroupId(), result
);
240 public SendGroupMessageResults
updateGroup(
241 final GroupId groupId
,
243 final String description
,
244 final Set
<RecipientId
> members
,
245 final Set
<RecipientId
> removeMembers
,
246 final Set
<RecipientId
> admins
,
247 final Set
<RecipientId
> removeAdmins
,
248 final Set
<RecipientId
> banMembers
,
249 final Set
<RecipientId
> unbanMembers
,
250 final boolean resetGroupLink
,
251 final GroupLinkState groupLinkState
,
252 final GroupPermission addMemberPermission
,
253 final GroupPermission editDetailsPermission
,
254 final String avatarFile
,
255 final Integer expirationTimer
,
256 final Boolean isAnnouncementGroup
257 ) throws IOException
, GroupNotFoundException
, AttachmentInvalidException
, NotAGroupMemberException
, GroupSendingNotAllowedException
{
258 var group
= getGroupForUpdating(groupId
);
259 final var avatarBytes
= readAvatarBytes(avatarFile
);
261 SendGroupMessageResults results
;
263 case GroupInfoV2 gv2
-> {
265 results
= updateGroupV2(gv2
,
277 editDetailsPermission
,
280 isAnnouncementGroup
);
281 } catch (ConflictException e
) {
282 // Detected conflicting update, refreshing group and trying again
283 group
= getGroup(groupId
, true);
284 results
= updateGroupV2((GroupInfoV2
) group
,
296 editDetailsPermission
,
299 isAnnouncementGroup
);
303 case GroupInfoV1 gv1
-> {
304 results
= updateGroupV1(gv1
, name
, members
, avatarBytes
);
305 if (expirationTimer
!= null) {
306 setExpirationTimer(gv1
, expirationTimer
);
310 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
314 public void updateGroupProfileKey(GroupIdV2 groupId
) throws GroupNotFoundException
, NotAGroupMemberException
, IOException
{
315 var group
= getGroupForUpdating(groupId
);
317 if (group
instanceof GroupInfoV2 groupInfoV2
) {
318 Pair
<DecryptedGroup
, GroupChangeResponse
> groupChangePair
;
320 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
321 } catch (ConflictException e
) {
322 // Detected conflicting update, refreshing group and trying again
323 groupInfoV2
= (GroupInfoV2
) getGroup(groupId
, true);
324 groupChangePair
= context
.getGroupV2Helper().updateSelfProfileKey(groupInfoV2
);
326 if (groupChangePair
!= null) {
327 sendUpdateGroupV2Message(groupInfoV2
,
328 groupChangePair
.first(),
329 handleGroupChangeResponse(groupInfoV2
, groupChangePair
.second()));
334 public Pair
<GroupId
, SendGroupMessageResults
> joinGroup(
335 GroupInviteLinkUrl inviteLinkUrl
336 ) throws IOException
, InactiveGroupLinkException
, PendingAdminApprovalException
{
337 final DecryptedGroupJoinInfo groupJoinInfo
;
339 groupJoinInfo
= context
.getGroupV2Helper()
340 .getDecryptedGroupJoinInfo(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword());
341 } catch (GroupLinkNotActiveException e
) {
342 throw new InactiveGroupLinkException("Group link inactive (reason: " + e
.getReason() + ")", e
);
344 if (groupJoinInfo
.pendingAdminApproval
) {
345 throw new PendingAdminApprovalException("You have already requested to join the group.");
347 final var changeResponse
= context
.getGroupV2Helper()
348 .joinGroup(inviteLinkUrl
.getGroupMasterKey(), inviteLinkUrl
.getPassword(), groupJoinInfo
);
349 final var group
= getOrMigrateGroup(inviteLinkUrl
.getGroupMasterKey(),
350 groupJoinInfo
.revision
+ 1,
351 changeResponse
.groupChange
== null ?
null : changeResponse
.groupChange
.encode());
352 final var groupChange
= handleGroupChangeResponse(group
, changeResponse
);
354 if (group
.getGroup() == null) {
355 // Only requested member, can't send update to group members
356 return new Pair
<>(group
.getGroupId(), new SendGroupMessageResults(0, List
.of()));
359 final var result
= sendUpdateGroupV2Message(group
, group
.getGroup(), groupChange
);
361 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
362 return new Pair
<>(group
.getGroupId(), result
);
365 public SendGroupMessageResults
quitGroup(
366 final GroupId groupId
, final Set
<RecipientId
> newAdmins
367 ) throws IOException
, LastGroupAdminException
, NotAGroupMemberException
, GroupNotFoundException
{
368 var group
= getGroupForUpdating(groupId
);
369 if (group
instanceof GroupInfoV1
) {
370 return quitGroupV1((GroupInfoV1
) group
);
374 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
375 } catch (ConflictException e
) {
376 // Detected conflicting update, refreshing group and trying again
377 group
= getGroup(groupId
, true);
378 return quitGroupV2((GroupInfoV2
) group
, newAdmins
);
382 public void deleteGroup(GroupId groupId
) throws IOException
{
383 account
.getGroupStore().deleteGroup(groupId
);
384 context
.getAvatarStore().deleteGroupAvatar(groupId
);
385 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
388 public void setGroupBlocked(final GroupId groupId
, final boolean blocked
) throws GroupNotFoundException
{
389 var group
= getGroup(groupId
);
391 throw new GroupNotFoundException(groupId
);
394 group
.setBlocked(blocked
);
395 account
.getGroupStore().updateGroup(group
);
396 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
399 public SendGroupMessageResults
sendGroupInfoRequest(
400 GroupIdV1 groupId
, RecipientId recipientId
401 ) throws IOException
{
402 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.REQUEST_INFO
).withId(groupId
.serialize());
404 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
.build());
406 // Send group info request message to the recipient who sent us a message with this groupId
407 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
410 public SendGroupMessageResults
sendGroupInfoMessage(
411 GroupIdV1 groupId
, RecipientId recipientId
412 ) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, AttachmentInvalidException
{
414 var group
= getGroupForUpdating(groupId
);
415 if (!(group
instanceof GroupInfoV1
)) {
416 throw new IOException("Received an invalid group request for a v2 group!");
418 g
= (GroupInfoV1
) group
;
420 if (!g
.isMember(recipientId
)) {
421 throw new NotAGroupMemberException(groupId
, g
.name
);
424 var messageBuilder
= getGroupUpdateMessageBuilder(g
);
426 // Send group message only to the recipient who requested it
427 return sendGroupMessage(messageBuilder
, Set
.of(recipientId
), null);
430 private GroupInfo
getGroup(GroupId groupId
, boolean forceUpdate
) {
431 final var group
= account
.getGroupStore().getGroup(groupId
);
432 fillOrUpdateGroup(group
, forceUpdate
);
436 private void fillOrUpdateGroup(final GroupInfo group
, final boolean forceUpdate
) {
437 if (!(group
instanceof GroupInfoV2 groupInfoV2
)) {
441 if (!forceUpdate
&& (groupInfoV2
.isPermissionDenied() || groupInfoV2
.getGroup() != null)) {
445 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupInfoV2
.getMasterKey());
446 DecryptedGroup decryptedGroup
;
448 final var response
= context
.getGroupV2Helper().getDecryptedGroup(groupSecretParams
);
449 if (response
== null) {
452 decryptedGroup
= handleDecryptedGroupResponse(groupInfoV2
, response
);
453 } catch (NotAGroupMemberException e
) {
454 groupInfoV2
.setPermissionDenied(true);
455 account
.getGroupStore().updateGroup(group
);
460 storeProfileKeysFromHistory(groupSecretParams
, groupInfoV2
, decryptedGroup
);
461 } catch (NotAGroupMemberException ignored
) {
463 storeProfileKeysFromMembers(decryptedGroup
);
464 final var avatar
= decryptedGroup
.avatar
;
465 if (!avatar
.isEmpty()) {
466 downloadGroupAvatar(groupInfoV2
.getGroupId(), groupSecretParams
, avatar
);
468 groupInfoV2
.setGroup(decryptedGroup
);
469 account
.getGroupStore().updateGroup(group
);
472 private void downloadGroupAvatar(GroupIdV2 groupId
, GroupSecretParams groupSecretParams
, String cdnKey
) {
474 context
.getAvatarStore()
475 .storeGroupAvatar(groupId
,
476 outputStream
-> retrieveGroupV2Avatar(groupSecretParams
, cdnKey
, outputStream
));
477 } catch (IOException e
) {
478 logger
.warn("Failed to download avatar for group {}, ignoring: {}", groupId
.toBase64(), e
.getMessage());
482 private void retrieveGroupV2Avatar(
483 GroupSecretParams groupSecretParams
, String cdnKey
, OutputStream outputStream
484 ) throws IOException
{
485 var groupOperations
= dependencies
.getGroupsV2Operations().forGroup(groupSecretParams
);
487 var tmpFile
= IOUtils
.createTempFile();
488 try (InputStream input
= dependencies
.getMessageReceiver()
489 .retrieveGroupsV2ProfileAvatar(cdnKey
, tmpFile
, ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
490 var encryptedData
= IOUtils
.readFully(input
);
492 var decryptedData
= groupOperations
.decryptAvatar(encryptedData
);
493 outputStream
.write(decryptedData
);
496 Files
.delete(tmpFile
.toPath());
497 } catch (IOException e
) {
498 logger
.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
505 private void storeProfileKeysFromMembers(final DecryptedGroup group
) {
506 for (var member
: group
.members
) {
507 final var serviceId
= ServiceId
.parseOrThrow(member
.aciBytes
);
508 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
509 final var profileStore
= account
.getProfileStore();
510 if (profileStore
.getProfileKey(recipientId
) != null) {
511 // We already have a profile key, not updating it from a non-authoritative source
515 profileStore
.storeProfileKey(recipientId
, new ProfileKey(member
.profileKey
.toByteArray()));
516 } catch (InvalidInputException ignored
) {
521 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange
) {
522 final var profileKeyFromChange
= context
.getGroupV2Helper()
523 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange
);
525 if (profileKeyFromChange
!= null) {
526 final var serviceId
= profileKeyFromChange
.first();
527 final var profileKey
= profileKeyFromChange
.second();
528 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
529 account
.getProfileStore().storeProfileKey(recipientId
, profileKey
);
533 private void storeProfileKeysFromHistory(
534 final GroupSecretParams groupSecretParams
,
535 final GroupInfoV2 localGroup
,
536 final DecryptedGroup newDecryptedGroup
537 ) throws NotAGroupMemberException
{
538 final var revisionWeWereAdded
= context
.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup
);
539 final var localRevision
= localGroup
.getGroup() == null ?
0 : localGroup
.getGroup().revision
;
540 final var sendEndorsementsExpirationMs
= 0L;// TODO store expiration localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
541 var fromRevision
= Math
.max(revisionWeWereAdded
, localRevision
);
542 final var newProfileKeys
= new HashMap
<RecipientId
, ProfileKey
>();
544 final var page
= context
.getGroupV2Helper()
545 .getDecryptedGroupHistoryPage(groupSecretParams
, fromRevision
, sendEndorsementsExpirationMs
);
548 .map(DecryptedGroupChangeLog
::getChange
)
549 .filter(Objects
::nonNull
)
550 .map(context
.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange
)
551 .filter(Objects
::nonNull
)
553 final var serviceId
= p
.first();
554 final var profileKey
= p
.second();
555 final var recipientId
= account
.getRecipientResolver().resolveRecipient(serviceId
);
556 newProfileKeys
.put(recipientId
, profileKey
);
558 if (!page
.getPagingData().getHasMorePages()) {
561 fromRevision
= page
.getPagingData().getNextPageRevision();
564 newProfileKeys
.entrySet()
566 .filter(entry
-> account
.getProfileStore().getProfileKey(entry
.getKey()) == null)
567 .forEach(entry
-> account
.getProfileStore().storeProfileKey(entry
.getKey(), entry
.getValue()));
570 private GroupInfo
getGroupForUpdating(GroupId groupId
) throws GroupNotFoundException
, NotAGroupMemberException
{
571 var g
= getGroup(groupId
);
573 throw new GroupNotFoundException(groupId
);
575 if (!g
.isMember(account
.getSelfRecipientId()) && !g
.isPendingMember(account
.getSelfRecipientId())) {
576 throw new NotAGroupMemberException(groupId
, g
.getTitle());
578 if (groupId
instanceof GroupIdV2
) {
579 // Refresh group before updating
580 return getGroup(groupId
, true);
585 private SendGroupMessageResults
updateGroupV1(
586 final GroupInfoV1 gv1
, final String name
, final Set
<RecipientId
> members
, final byte[] avatarFile
587 ) throws IOException
, AttachmentInvalidException
{
588 updateGroupV1Details(gv1
, name
, members
, avatarFile
);
590 account
.getGroupStore().updateGroup(gv1
);
592 var messageBuilder
= getGroupUpdateMessageBuilder(gv1
);
593 return sendGroupMessage(messageBuilder
,
594 gv1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
595 gv1
.getDistributionId());
598 private void updateGroupV1Details(
599 final GroupInfoV1 g
, final String name
, final Collection
<RecipientId
> members
, final byte[] avatarFile
600 ) throws IOException
{
605 if (members
!= null) {
606 g
.addMembers(members
);
609 if (avatarFile
!= null) {
610 context
.getAvatarStore().storeGroupAvatar(g
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
615 * Change the expiration timer for a group
617 private void setExpirationTimer(
618 GroupInfoV1 groupInfoV1
, int messageExpirationTimer
619 ) throws NotAGroupMemberException
, GroupNotFoundException
, IOException
, GroupSendingNotAllowedException
{
620 groupInfoV1
.messageExpirationTime
= messageExpirationTimer
;
621 account
.getGroupStore().updateGroup(groupInfoV1
);
622 sendExpirationTimerUpdate(groupInfoV1
.getGroupId());
625 private void sendExpirationTimerUpdate(GroupIdV1 groupId
) throws IOException
, NotAGroupMemberException
, GroupNotFoundException
, GroupSendingNotAllowedException
{
626 final var messageBuilder
= SignalServiceDataMessage
.newBuilder().asExpirationUpdate();
627 context
.getSendHelper().sendAsGroupMessage(messageBuilder
, groupId
, false, Optional
.empty());
630 private SendGroupMessageResults
updateGroupV2(
631 final GroupInfoV2 group
,
633 final String description
,
634 final Set
<RecipientId
> members
,
635 final Set
<RecipientId
> removeMembers
,
636 final Set
<RecipientId
> admins
,
637 final Set
<RecipientId
> removeAdmins
,
638 final Set
<RecipientId
> banMembers
,
639 final Set
<RecipientId
> unbanMembers
,
640 final boolean resetGroupLink
,
641 final GroupLinkState groupLinkState
,
642 final GroupPermission addMemberPermission
,
643 final GroupPermission editDetailsPermission
,
644 final byte[] avatarFile
,
645 final Integer expirationTimer
,
646 final Boolean isAnnouncementGroup
647 ) throws IOException
{
648 SendGroupMessageResults result
= null;
649 final var groupV2Helper
= context
.getGroupV2Helper();
650 if (group
.isPendingMember(account
.getSelfRecipientId())) {
651 var groupGroupChangePair
= groupV2Helper
.acceptInvite(group
);
652 result
= sendUpdateGroupV2Message(group
,
653 groupGroupChangePair
.first(),
654 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
657 if (members
!= null) {
658 final var requestingMembers
= new HashSet
<>(members
);
659 requestingMembers
.retainAll(group
.getRequestingMembers());
660 if (!requestingMembers
.isEmpty()) {
661 var groupGroupChangePair
= groupV2Helper
.approveJoinRequestMembers(group
, requestingMembers
);
662 result
= sendUpdateGroupV2Message(group
,
663 groupGroupChangePair
.first(),
664 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
666 final var newMembers
= new HashSet
<>(members
);
667 newMembers
.removeAll(group
.getMembers());
668 newMembers
.removeAll(group
.getRequestingMembers());
669 if (!newMembers
.isEmpty()) {
670 var groupGroupChangePair
= groupV2Helper
.addMembers(group
, newMembers
);
671 result
= sendUpdateGroupV2Message(group
,
672 groupGroupChangePair
.first(),
673 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
677 if (removeMembers
!= null) {
678 var existingRemoveMembers
= new HashSet
<>(removeMembers
);
679 if (banMembers
!= null) {
680 existingRemoveMembers
.addAll(banMembers
);
682 existingRemoveMembers
.retainAll(group
.getMembers());
683 if (members
!= null) {
684 existingRemoveMembers
.removeAll(members
);
686 existingRemoveMembers
.remove(account
.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
687 if (!existingRemoveMembers
.isEmpty()) {
688 var groupGroupChangePair
= groupV2Helper
.removeMembers(group
, existingRemoveMembers
);
689 result
= sendUpdateGroupV2Message(group
,
690 groupGroupChangePair
.first(),
691 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
694 var pendingRemoveMembers
= new HashSet
<>(removeMembers
);
695 pendingRemoveMembers
.retainAll(group
.getPendingMembers());
696 if (!pendingRemoveMembers
.isEmpty()) {
697 var groupGroupChangePair
= groupV2Helper
.revokeInvitedMembers(group
, pendingRemoveMembers
);
698 result
= sendUpdateGroupV2Message(group
,
699 groupGroupChangePair
.first(),
700 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
702 var requestingRemoveMembers
= new HashSet
<>(removeMembers
);
703 requestingRemoveMembers
.retainAll(group
.getRequestingMembers());
704 if (!requestingRemoveMembers
.isEmpty()) {
705 var groupGroupChangePair
= groupV2Helper
.refuseJoinRequestMembers(group
, requestingRemoveMembers
);
706 result
= sendUpdateGroupV2Message(group
,
707 groupGroupChangePair
.first(),
708 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
712 if (admins
!= null) {
713 final var newAdmins
= new HashSet
<>(admins
);
714 newAdmins
.retainAll(group
.getMembers());
715 newAdmins
.removeAll(group
.getAdminMembers());
716 if (!newAdmins
.isEmpty()) {
717 for (var admin
: newAdmins
) {
718 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, true);
719 result
= sendUpdateGroupV2Message(group
,
720 groupGroupChangePair
.first(),
721 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
726 if (removeAdmins
!= null) {
727 final var existingRemoveAdmins
= new HashSet
<>(removeAdmins
);
728 existingRemoveAdmins
.retainAll(group
.getAdminMembers());
729 if (!existingRemoveAdmins
.isEmpty()) {
730 for (var admin
: existingRemoveAdmins
) {
731 var groupGroupChangePair
= groupV2Helper
.setMemberAdmin(group
, admin
, false);
732 result
= sendUpdateGroupV2Message(group
,
733 groupGroupChangePair
.first(),
734 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
739 if (banMembers
!= null) {
740 final var newlyBannedMembers
= new HashSet
<>(banMembers
);
741 newlyBannedMembers
.removeAll(group
.getBannedMembers());
742 if (!newlyBannedMembers
.isEmpty()) {
743 var groupGroupChangePair
= groupV2Helper
.banMembers(group
, newlyBannedMembers
);
744 result
= sendUpdateGroupV2Message(group
,
745 groupGroupChangePair
.first(),
746 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
750 if (unbanMembers
!= null) {
751 var existingUnbanMembers
= new HashSet
<>(unbanMembers
);
752 existingUnbanMembers
.retainAll(group
.getBannedMembers());
753 if (!existingUnbanMembers
.isEmpty()) {
754 var groupGroupChangePair
= groupV2Helper
.unbanMembers(group
, existingUnbanMembers
);
755 result
= sendUpdateGroupV2Message(group
,
756 groupGroupChangePair
.first(),
757 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
761 if (resetGroupLink
) {
762 var groupGroupChangePair
= groupV2Helper
.resetGroupLinkPassword(group
);
763 result
= sendUpdateGroupV2Message(group
,
764 groupGroupChangePair
.first(),
765 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
768 if (groupLinkState
!= null) {
769 var groupGroupChangePair
= groupV2Helper
.setGroupLinkState(group
, groupLinkState
);
770 result
= sendUpdateGroupV2Message(group
,
771 groupGroupChangePair
.first(),
772 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
775 if (addMemberPermission
!= null) {
776 var groupGroupChangePair
= groupV2Helper
.setAddMemberPermission(group
, addMemberPermission
);
777 result
= sendUpdateGroupV2Message(group
,
778 groupGroupChangePair
.first(),
779 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
782 if (editDetailsPermission
!= null) {
783 var groupGroupChangePair
= groupV2Helper
.setEditDetailsPermission(group
, editDetailsPermission
);
784 result
= sendUpdateGroupV2Message(group
,
785 groupGroupChangePair
.first(),
786 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
789 if (expirationTimer
!= null) {
790 var groupGroupChangePair
= groupV2Helper
.setMessageExpirationTimer(group
, expirationTimer
);
791 result
= sendUpdateGroupV2Message(group
,
792 groupGroupChangePair
.first(),
793 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
796 if (isAnnouncementGroup
!= null) {
797 var groupGroupChangePair
= groupV2Helper
.setIsAnnouncementGroup(group
, isAnnouncementGroup
);
798 result
= sendUpdateGroupV2Message(group
,
799 groupGroupChangePair
.first(),
800 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
803 if (name
!= null || description
!= null || avatarFile
!= null) {
804 var groupGroupChangePair
= groupV2Helper
.updateGroup(group
, name
, description
, avatarFile
);
805 if (avatarFile
!= null) {
806 context
.getAvatarStore()
807 .storeGroupAvatar(group
.getGroupId(), outputStream
-> outputStream
.write(avatarFile
));
809 result
= sendUpdateGroupV2Message(group
,
810 groupGroupChangePair
.first(),
811 handleGroupChangeResponse(group
, groupGroupChangePair
.second()));
817 private SendGroupMessageResults
quitGroupV1(final GroupInfoV1 groupInfoV1
) throws IOException
{
818 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.QUIT
)
819 .withId(groupInfoV1
.getGroupId().serialize())
822 var messageBuilder
= SignalServiceDataMessage
.newBuilder().asGroupMessage(group
);
823 groupInfoV1
.removeMember(account
.getSelfRecipientId());
824 account
.getGroupStore().updateGroup(groupInfoV1
);
825 return sendGroupMessage(messageBuilder
,
826 groupInfoV1
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
827 groupInfoV1
.getDistributionId());
830 private SendGroupMessageResults
quitGroupV2(
831 final GroupInfoV2 groupInfoV2
, final Set
<RecipientId
> newAdmins
832 ) throws LastGroupAdminException
, IOException
{
833 final var currentAdmins
= groupInfoV2
.getAdminMembers();
834 newAdmins
.removeAll(currentAdmins
);
835 newAdmins
.retainAll(groupInfoV2
.getMembers());
836 if (currentAdmins
.contains(account
.getSelfRecipientId())
837 && currentAdmins
.size() == 1
838 && groupInfoV2
.getMembers().size() > 1
839 && newAdmins
.isEmpty()) {
840 // Last admin can't leave the group, unless she's also the last member
841 throw new LastGroupAdminException(groupInfoV2
.getGroupId(), groupInfoV2
.getTitle());
843 final var groupGroupChangePair
= context
.getGroupV2Helper().leaveGroup(groupInfoV2
, newAdmins
);
844 groupInfoV2
.setGroup(groupGroupChangePair
.first());
845 account
.getGroupStore().updateGroup(groupInfoV2
);
847 var messageBuilder
= getGroupUpdateMessageBuilder(groupInfoV2
,
848 handleGroupChangeResponse(groupInfoV2
, groupGroupChangePair
.second()).encode());
849 return sendGroupMessage(messageBuilder
,
850 groupInfoV2
.getMembersIncludingPendingWithout(account
.getSelfRecipientId()),
851 groupInfoV2
.getDistributionId());
854 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV1 g
) throws AttachmentInvalidException
{
855 var group
= SignalServiceGroup
.newBuilder(SignalServiceGroup
.Type
.UPDATE
)
856 .withId(g
.getGroupId().serialize())
858 .withMembers(g
.getMembers()
860 .map(context
.getRecipientHelper()::resolveSignalServiceAddress
)
864 final var attachment
= createGroupAvatarAttachment(g
.getGroupId());
865 attachment
.ifPresent(group
::withAvatar
);
866 } catch (IOException e
) {
867 throw new AttachmentInvalidException(g
.getGroupId().toBase64(), e
);
870 return SignalServiceDataMessage
.newBuilder()
871 .asGroupMessage(group
.build())
872 .withExpiration(g
.getMessageExpirationTimer());
875 private SignalServiceDataMessage
.Builder
getGroupUpdateMessageBuilder(GroupInfoV2 g
, byte[] signedGroupChange
) {
876 var group
= SignalServiceGroupV2
.newBuilder(g
.getMasterKey())
877 .withRevision(g
.getGroup().revision
)
878 .withSignedGroupChange(signedGroupChange
);
879 return SignalServiceDataMessage
.newBuilder()
880 .asGroupMessage(group
.build())
881 .withExpiration(g
.getMessageExpirationTimer());
884 private SendGroupMessageResults
sendUpdateGroupV2Message(
885 GroupInfoV2 group
, DecryptedGroup newDecryptedGroup
, GroupChange groupChange
886 ) throws IOException
{
887 final var selfRecipientId
= account
.getSelfRecipientId();
888 final var members
= group
.getMembersIncludingPendingWithout(selfRecipientId
);
889 group
.setGroup(newDecryptedGroup
);
890 members
.addAll(group
.getMembersIncludingPendingWithout(selfRecipientId
));
891 account
.getGroupStore().updateGroup(group
);
893 final var messageBuilder
= getGroupUpdateMessageBuilder(group
, groupChange
.encode());
894 return sendGroupMessage(messageBuilder
, members
, group
.getDistributionId());
897 private SendGroupMessageResults
sendGroupMessage(
898 final SignalServiceDataMessage
.Builder messageBuilder
,
899 final Set
<RecipientId
> members
,
900 final DistributionId distributionId
901 ) throws IOException
{
902 final var timestamp
= System
.currentTimeMillis();
903 messageBuilder
.withTimestamp(timestamp
);
904 final var results
= context
.getSendHelper().sendGroupMessage(messageBuilder
.build(), members
, distributionId
);
905 return new SendGroupMessageResults(timestamp
,
907 .map(sendMessageResult
-> SendMessageResult
.from(sendMessageResult
,
908 account
.getRecipientResolver(),
909 account
.getRecipientAddressResolver()))
913 private byte[] readAvatarBytes(final String avatarFile
) throws IOException
{
914 if (avatarFile
== null) {
917 try (final var avatar
= Utils
.createStreamDetails(avatarFile
).first()) {
918 return IOUtils
.readFully(avatar
.getStream());