]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
0d838204c390e7f87bf9dc100c538c63851b6687
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / GroupHelper.java
1 package org.asamk.signal.manager.helper;
2
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;
54
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;
65 import java.util.Set;
66
67 public class GroupHelper {
68
69 private static final Logger logger = LoggerFactory.getLogger(GroupHelper.class);
70
71 private final SignalAccount account;
72 private final SignalDependencies dependencies;
73 private final Context context;
74
75 public GroupHelper(final Context context) {
76 this.account = context.getAccount();
77 this.dependencies = context.getDependencies();
78 this.context = context;
79 }
80
81 public GroupInfo getGroup(GroupId groupId) {
82 return getGroup(groupId, false);
83 }
84
85 public void updateGroupSendEndorsements(GroupId groupId) {
86 getGroup(groupId, true);
87 }
88
89 public List<GroupInfo> getGroups() {
90 final var groups = account.getGroupStore().getGroups();
91 groups.forEach(group -> fillOrUpdateGroup(group, false));
92 return groups;
93 }
94
95 public boolean isGroupBlocked(final GroupId groupId) {
96 var group = getGroup(groupId);
97 return group != null && group.isBlocked();
98 }
99
100 public void downloadGroupAvatar(GroupIdV1 groupId, SignalServiceAttachment avatar) {
101 try {
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());
107 }
108 }
109
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();
114 }
115
116 final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
117 return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
118 }
119
120 public GroupInfoV2 getOrMigrateGroup(
121 final GroupMasterKey groupMasterKey,
122 final int revision,
123 final byte[] signedGroupChange
124 ) {
125 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
126
127 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
128 final var groupInfoV2 = account.getGroupStore().getGroupOrPartialMigrate(groupMasterKey, groupId);
129
130 if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().revision < revision) {
131 DecryptedGroup group = null;
132 if (signedGroupChange != null
133 && groupInfoV2.getGroup() != null
134 && groupInfoV2.getGroup().revision + 1 == revision) {
135 final var decryptedGroupChange = context.getGroupV2Helper()
136 .getDecryptedGroupChange(signedGroupChange, groupMasterKey);
137
138 if (decryptedGroupChange != null) {
139 storeProfileKeyFromChange(decryptedGroupChange);
140 group = context.getGroupV2Helper()
141 .getUpdatedDecryptedGroup(groupInfoV2.getGroup(), decryptedGroupChange);
142 }
143 }
144 if (group == null) {
145 try {
146 final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
147
148 if (response != null) {
149 group = handleDecryptedGroupResponse(groupInfoV2, response);
150 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group);
151 }
152 } catch (NotAGroupMemberException ignored) {
153 }
154 }
155 if (group != null) {
156 storeProfileKeysFromMembers(group);
157 final var avatar = group.avatar;
158 if (!avatar.isEmpty()) {
159 downloadGroupAvatar(groupId, groupSecretParams, avatar);
160 }
161 }
162 groupInfoV2.setGroup(group);
163 account.getGroupStore().updateGroup(groupInfoV2);
164 context.getJobExecutor().enqueueJob(new SyncStorageJob());
165 }
166
167 return groupInfoV2;
168 }
169
170 private DecryptedGroup handleDecryptedGroupResponse(
171 GroupInfoV2 groupInfoV2,
172 final DecryptedGroupResponse decryptedGroupResponse
173 ) {
174 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
175 ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations()
176 .forGroup(groupSecretParams)
177 .receiveGroupSendEndorsements(account.getAci(),
178 decryptedGroupResponse.getGroup(),
179 decryptedGroupResponse.getGroupSendEndorsementsResponse());
180
181 // TODO save group endorsements
182
183 return decryptedGroupResponse.getGroup();
184 }
185
186 private GroupChange handleGroupChangeResponse(
187 final GroupInfoV2 groupInfoV2,
188 final GroupChangeResponse groupChangeResponse
189 ) {
190 ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations()
191 .forGroup(GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()))
192 .receiveGroupSendEndorsements(account.getAci(),
193 groupInfoV2.getGroup(),
194 groupChangeResponse.groupSendEndorsementsResponse);
195
196 // TODO save group endorsements
197
198 return groupChangeResponse.groupChange;
199 }
200
201 public Pair<GroupId, SendGroupMessageResults> createGroup(
202 String name,
203 Set<RecipientId> members,
204 String avatarFile
205 ) throws IOException, AttachmentInvalidException {
206 final var selfRecipientId = account.getSelfRecipientId();
207 if (members != null && members.contains(selfRecipientId)) {
208 members = new HashSet<>(members);
209 members.remove(selfRecipientId);
210 }
211
212 final var avatarBytes = readAvatarBytes(avatarFile);
213 var gv2Pair = context.getGroupV2Helper()
214 .createGroup(name == null ? "" : name, members == null ? Set.of() : members, avatarBytes);
215
216 if (gv2Pair == null) {
217 // Failed to create v2 group, creating v1 group instead
218 var gv1 = new GroupInfoV1(GroupIdV1.createRandom());
219 gv1.setProfileSharingEnabled(true);
220 gv1.addMembers(List.of(selfRecipientId));
221 final var result = updateGroupV1(gv1, name, members, avatarBytes);
222 return new Pair<>(gv1.getGroupId(), result);
223 }
224
225 final var gv2 = gv2Pair.first();
226 final var decryptedGroup = gv2Pair.second();
227
228 gv2.setGroup(handleDecryptedGroupResponse(gv2, decryptedGroup));
229 gv2.setProfileSharingEnabled(true);
230 if (avatarBytes != null) {
231 context.getAvatarStore()
232 .storeGroupAvatar(gv2.getGroupId(), outputStream -> outputStream.write(avatarBytes));
233 }
234
235 account.getGroupStore().updateGroup(gv2);
236
237 final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null);
238
239 final var result = sendGroupMessage(messageBuilder,
240 gv2.getMembersIncludingPendingWithout(selfRecipientId),
241 gv2.getDistributionId());
242 context.getJobExecutor().enqueueJob(new SyncStorageJob());
243 return new Pair<>(gv2.getGroupId(), result);
244 }
245
246 public SendGroupMessageResults updateGroup(
247 final GroupId groupId,
248 final String name,
249 final String description,
250 final Set<RecipientId> members,
251 final Set<RecipientId> removeMembers,
252 final Set<RecipientId> admins,
253 final Set<RecipientId> removeAdmins,
254 final Set<RecipientId> banMembers,
255 final Set<RecipientId> unbanMembers,
256 final boolean resetGroupLink,
257 final GroupLinkState groupLinkState,
258 final GroupPermission addMemberPermission,
259 final GroupPermission editDetailsPermission,
260 final String avatarFile,
261 final Integer expirationTimer,
262 final Boolean isAnnouncementGroup
263 ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
264 var group = getGroupForUpdating(groupId);
265 final var avatarBytes = readAvatarBytes(avatarFile);
266
267 SendGroupMessageResults results;
268 switch (group) {
269 case GroupInfoV2 gv2 -> {
270 try {
271 results = updateGroupV2(gv2,
272 name,
273 description,
274 members,
275 removeMembers,
276 admins,
277 removeAdmins,
278 banMembers,
279 unbanMembers,
280 resetGroupLink,
281 groupLinkState,
282 addMemberPermission,
283 editDetailsPermission,
284 avatarBytes,
285 expirationTimer,
286 isAnnouncementGroup);
287 } catch (ConflictException e) {
288 // Detected conflicting update, refreshing group and trying again
289 group = getGroup(groupId, true);
290 results = updateGroupV2((GroupInfoV2) group,
291 name,
292 description,
293 members,
294 removeMembers,
295 admins,
296 removeAdmins,
297 banMembers,
298 unbanMembers,
299 resetGroupLink,
300 groupLinkState,
301 addMemberPermission,
302 editDetailsPermission,
303 avatarBytes,
304 expirationTimer,
305 isAnnouncementGroup);
306 }
307 }
308
309 case GroupInfoV1 gv1 -> {
310 results = updateGroupV1(gv1, name, members, avatarBytes);
311 if (expirationTimer != null) {
312 setExpirationTimer(gv1, expirationTimer);
313 }
314 }
315 }
316 context.getJobExecutor().enqueueJob(new SyncStorageJob());
317 return results;
318 }
319
320 public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
321 var group = getGroupForUpdating(groupId);
322
323 if (group instanceof GroupInfoV2 groupInfoV2) {
324 Pair<DecryptedGroup, GroupChangeResponse> groupChangePair;
325 try {
326 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
327 } catch (ConflictException e) {
328 // Detected conflicting update, refreshing group and trying again
329 groupInfoV2 = (GroupInfoV2) getGroup(groupId, true);
330 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
331 }
332 if (groupChangePair != null) {
333 sendUpdateGroupV2Message(groupInfoV2,
334 groupChangePair.first(),
335 handleGroupChangeResponse(groupInfoV2, groupChangePair.second()));
336 }
337 }
338 }
339
340 public Pair<GroupId, SendGroupMessageResults> joinGroup(
341 GroupInviteLinkUrl inviteLinkUrl
342 ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
343 final DecryptedGroupJoinInfo groupJoinInfo;
344 try {
345 groupJoinInfo = context.getGroupV2Helper()
346 .getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword());
347 } catch (GroupLinkNotActiveException e) {
348 throw new InactiveGroupLinkException("Group link inactive (reason: " + e.getReason() + ")", e);
349 }
350 if (groupJoinInfo.pendingAdminApproval) {
351 throw new PendingAdminApprovalException("You have already requested to join the group.");
352 }
353 final var changeResponse = context.getGroupV2Helper()
354 .joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo);
355 final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
356 groupJoinInfo.revision + 1,
357 changeResponse.groupChange == null ? null : changeResponse.groupChange.encode());
358 final var groupChange = handleGroupChangeResponse(group, changeResponse);
359
360 if (group.getGroup() == null) {
361 // Only requested member, can't send update to group members
362 return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of()));
363 }
364
365 final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
366
367 context.getJobExecutor().enqueueJob(new SyncStorageJob());
368 return new Pair<>(group.getGroupId(), result);
369 }
370
371 public SendGroupMessageResults quitGroup(
372 final GroupId groupId,
373 final Set<RecipientId> newAdmins
374 ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
375 var group = getGroupForUpdating(groupId);
376 if (group instanceof GroupInfoV1) {
377 return quitGroupV1((GroupInfoV1) group);
378 }
379
380 try {
381 return quitGroupV2((GroupInfoV2) group, newAdmins);
382 } catch (ConflictException e) {
383 // Detected conflicting update, refreshing group and trying again
384 group = getGroup(groupId, true);
385 return quitGroupV2((GroupInfoV2) group, newAdmins);
386 }
387 }
388
389 public void deleteGroup(GroupId groupId) throws IOException {
390 account.getGroupStore().deleteGroup(groupId);
391 context.getAvatarStore().deleteGroupAvatar(groupId);
392 context.getJobExecutor().enqueueJob(new SyncStorageJob());
393 }
394
395 public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
396 var group = getGroup(groupId);
397 if (group == null) {
398 throw new GroupNotFoundException(groupId);
399 }
400
401 group.setBlocked(blocked);
402 account.getGroupStore().updateGroup(group);
403 context.getJobExecutor().enqueueJob(new SyncStorageJob());
404 }
405
406 public SendGroupMessageResults sendGroupInfoRequest(GroupIdV1 groupId, RecipientId recipientId) throws IOException {
407 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
408
409 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
410
411 // Send group info request message to the recipient who sent us a message with this groupId
412 return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
413 }
414
415 public SendGroupMessageResults sendGroupInfoMessage(
416 GroupIdV1 groupId,
417 RecipientId recipientId
418 ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
419 GroupInfoV1 g;
420 var group = getGroupForUpdating(groupId);
421 if (!(group instanceof GroupInfoV1)) {
422 throw new IOException("Received an invalid group request for a v2 group!");
423 }
424 g = (GroupInfoV1) group;
425
426 if (!g.isMember(recipientId)) {
427 throw new NotAGroupMemberException(groupId, g.name);
428 }
429
430 var messageBuilder = getGroupUpdateMessageBuilder(g);
431
432 // Send group message only to the recipient who requested it
433 return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
434 }
435
436 private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
437 final var group = account.getGroupStore().getGroup(groupId);
438 fillOrUpdateGroup(group, forceUpdate);
439 return group;
440 }
441
442 private void fillOrUpdateGroup(final GroupInfo group, final boolean forceUpdate) {
443 if (!(group instanceof GroupInfoV2 groupInfoV2)) {
444 return;
445 }
446
447 if (!forceUpdate && (groupInfoV2.isPermissionDenied() || groupInfoV2.getGroup() != null)) {
448 return;
449 }
450
451 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
452 DecryptedGroup decryptedGroup;
453 try {
454 final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
455 if (response == null) {
456 return;
457 }
458 decryptedGroup = handleDecryptedGroupResponse(groupInfoV2, response);
459 } catch (NotAGroupMemberException e) {
460 groupInfoV2.setPermissionDenied(true);
461 account.getGroupStore().updateGroup(group);
462 return;
463 }
464
465 try {
466 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, decryptedGroup);
467 } catch (NotAGroupMemberException ignored) {
468 }
469 storeProfileKeysFromMembers(decryptedGroup);
470 final var avatar = decryptedGroup.avatar;
471 if (!avatar.isEmpty()) {
472 downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
473 }
474 groupInfoV2.setGroup(decryptedGroup);
475 account.getGroupStore().updateGroup(group);
476 }
477
478 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
479 try {
480 context.getAvatarStore()
481 .storeGroupAvatar(groupId,
482 outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
483 } catch (IOException e) {
484 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
485 }
486 }
487
488 private void retrieveGroupV2Avatar(
489 GroupSecretParams groupSecretParams,
490 String cdnKey,
491 OutputStream outputStream
492 ) throws IOException {
493 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
494
495 var tmpFile = IOUtils.createTempFile();
496 try (InputStream input = dependencies.getMessageReceiver()
497 .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
498 var encryptedData = IOUtils.readFully(input);
499
500 var decryptedData = groupOperations.decryptAvatar(encryptedData);
501 outputStream.write(decryptedData);
502 } finally {
503 try {
504 Files.delete(tmpFile.toPath());
505 } catch (IOException e) {
506 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
507 tmpFile,
508 e.getMessage());
509 }
510 }
511 }
512
513 private void storeProfileKeysFromMembers(final DecryptedGroup group) {
514 for (var member : group.members) {
515 final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
516 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
517 final var profileStore = account.getProfileStore();
518 if (profileStore.getProfileKey(recipientId) != null) {
519 // We already have a profile key, not updating it from a non-authoritative source
520 continue;
521 }
522 try {
523 profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
524 } catch (InvalidInputException ignored) {
525 }
526 }
527 }
528
529 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange) {
530 final var profileKeyFromChange = context.getGroupV2Helper()
531 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange);
532
533 if (profileKeyFromChange != null) {
534 final var serviceId = profileKeyFromChange.first();
535 final var profileKey = profileKeyFromChange.second();
536 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
537 account.getProfileStore().storeProfileKey(recipientId, profileKey);
538 }
539 }
540
541 private void storeProfileKeysFromHistory(
542 final GroupSecretParams groupSecretParams,
543 final GroupInfoV2 localGroup,
544 final DecryptedGroup newDecryptedGroup
545 ) throws NotAGroupMemberException {
546 final var revisionWeWereAdded = context.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup);
547 final var localRevision = localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
548 final var sendEndorsementsExpirationMs = 0L;// TODO store expiration localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
549 var fromRevision = Math.max(revisionWeWereAdded, localRevision);
550 final var newProfileKeys = new HashMap<RecipientId, ProfileKey>();
551 while (true) {
552 final var page = context.getGroupV2Helper()
553 .getDecryptedGroupHistoryPage(groupSecretParams, fromRevision, sendEndorsementsExpirationMs);
554 page.getChangeLogs()
555 .stream()
556 .map(DecryptedGroupChangeLog::getChange)
557 .filter(Objects::nonNull)
558 .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
559 .filter(Objects::nonNull)
560 .forEach(p -> {
561 final var serviceId = p.first();
562 final var profileKey = p.second();
563 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
564 newProfileKeys.put(recipientId, profileKey);
565 });
566 if (!page.getPagingData().getHasMorePages()) {
567 break;
568 }
569 fromRevision = page.getPagingData().getNextPageRevision();
570 }
571
572 newProfileKeys.entrySet()
573 .stream()
574 .filter(entry -> account.getProfileStore().getProfileKey(entry.getKey()) == null)
575 .forEach(entry -> account.getProfileStore().storeProfileKey(entry.getKey(), entry.getValue()));
576 }
577
578 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
579 var g = getGroup(groupId);
580 if (g == null) {
581 throw new GroupNotFoundException(groupId);
582 }
583 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
584 throw new NotAGroupMemberException(groupId, g.getTitle());
585 }
586 if (groupId instanceof GroupIdV2) {
587 // Refresh group before updating
588 return getGroup(groupId, true);
589 }
590 return g;
591 }
592
593 private SendGroupMessageResults updateGroupV1(
594 final GroupInfoV1 gv1,
595 final String name,
596 final Set<RecipientId> members,
597 final byte[] avatarFile
598 ) throws IOException, AttachmentInvalidException {
599 updateGroupV1Details(gv1, name, members, avatarFile);
600
601 account.getGroupStore().updateGroup(gv1);
602
603 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
604 return sendGroupMessage(messageBuilder,
605 gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
606 gv1.getDistributionId());
607 }
608
609 private void updateGroupV1Details(
610 final GroupInfoV1 g,
611 final String name,
612 final Collection<RecipientId> members,
613 final byte[] avatarFile
614 ) throws IOException {
615 if (name != null) {
616 g.name = name;
617 }
618
619 if (members != null) {
620 g.addMembers(members);
621 }
622
623 if (avatarFile != null) {
624 context.getAvatarStore().storeGroupAvatar(g.getGroupId(), outputStream -> outputStream.write(avatarFile));
625 }
626 }
627
628 /**
629 * Change the expiration timer for a group
630 */
631 private void setExpirationTimer(
632 GroupInfoV1 groupInfoV1,
633 int messageExpirationTimer
634 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
635 groupInfoV1.messageExpirationTime = messageExpirationTimer;
636 account.getGroupStore().updateGroup(groupInfoV1);
637 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
638 }
639
640 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
641 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
642 context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty());
643 }
644
645 private SendGroupMessageResults updateGroupV2(
646 final GroupInfoV2 group,
647 final String name,
648 final String description,
649 final Set<RecipientId> members,
650 final Set<RecipientId> removeMembers,
651 final Set<RecipientId> admins,
652 final Set<RecipientId> removeAdmins,
653 final Set<RecipientId> banMembers,
654 final Set<RecipientId> unbanMembers,
655 final boolean resetGroupLink,
656 final GroupLinkState groupLinkState,
657 final GroupPermission addMemberPermission,
658 final GroupPermission editDetailsPermission,
659 final byte[] avatarFile,
660 final Integer expirationTimer,
661 final Boolean isAnnouncementGroup
662 ) throws IOException {
663 SendGroupMessageResults result = null;
664 final var groupV2Helper = context.getGroupV2Helper();
665 if (group.isPendingMember(account.getSelfRecipientId())) {
666 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
667 result = sendUpdateGroupV2Message(group,
668 groupGroupChangePair.first(),
669 handleGroupChangeResponse(group, groupGroupChangePair.second()));
670 }
671
672 if (members != null) {
673 final var requestingMembers = new HashSet<>(members);
674 requestingMembers.retainAll(group.getRequestingMembers());
675 if (!requestingMembers.isEmpty()) {
676 var groupGroupChangePair = groupV2Helper.approveJoinRequestMembers(group, requestingMembers);
677 result = sendUpdateGroupV2Message(group,
678 groupGroupChangePair.first(),
679 handleGroupChangeResponse(group, groupGroupChangePair.second()));
680 }
681 final var newMembers = new HashSet<>(members);
682 newMembers.removeAll(group.getMembers());
683 newMembers.removeAll(group.getRequestingMembers());
684 if (!newMembers.isEmpty()) {
685 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
686 result = sendUpdateGroupV2Message(group,
687 groupGroupChangePair.first(),
688 handleGroupChangeResponse(group, groupGroupChangePair.second()));
689 }
690 }
691
692 if (removeMembers != null) {
693 var existingRemoveMembers = new HashSet<>(removeMembers);
694 if (banMembers != null) {
695 existingRemoveMembers.addAll(banMembers);
696 }
697 existingRemoveMembers.retainAll(group.getMembers());
698 if (members != null) {
699 existingRemoveMembers.removeAll(members);
700 }
701 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
702 if (!existingRemoveMembers.isEmpty()) {
703 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
704 result = sendUpdateGroupV2Message(group,
705 groupGroupChangePair.first(),
706 handleGroupChangeResponse(group, groupGroupChangePair.second()));
707 }
708
709 var pendingRemoveMembers = new HashSet<>(removeMembers);
710 pendingRemoveMembers.retainAll(group.getPendingMembers());
711 if (!pendingRemoveMembers.isEmpty()) {
712 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
713 result = sendUpdateGroupV2Message(group,
714 groupGroupChangePair.first(),
715 handleGroupChangeResponse(group, groupGroupChangePair.second()));
716 }
717 var requestingRemoveMembers = new HashSet<>(removeMembers);
718 requestingRemoveMembers.retainAll(group.getRequestingMembers());
719 if (!requestingRemoveMembers.isEmpty()) {
720 var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers);
721 result = sendUpdateGroupV2Message(group,
722 groupGroupChangePair.first(),
723 handleGroupChangeResponse(group, groupGroupChangePair.second()));
724 }
725 }
726
727 if (admins != null) {
728 final var newAdmins = new HashSet<>(admins);
729 newAdmins.retainAll(group.getMembers());
730 newAdmins.removeAll(group.getAdminMembers());
731 if (!newAdmins.isEmpty()) {
732 for (var admin : newAdmins) {
733 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
734 result = sendUpdateGroupV2Message(group,
735 groupGroupChangePair.first(),
736 handleGroupChangeResponse(group, groupGroupChangePair.second()));
737 }
738 }
739 }
740
741 if (removeAdmins != null) {
742 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
743 existingRemoveAdmins.retainAll(group.getAdminMembers());
744 if (!existingRemoveAdmins.isEmpty()) {
745 for (var admin : existingRemoveAdmins) {
746 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
747 result = sendUpdateGroupV2Message(group,
748 groupGroupChangePair.first(),
749 handleGroupChangeResponse(group, groupGroupChangePair.second()));
750 }
751 }
752 }
753
754 if (banMembers != null) {
755 final var newlyBannedMembers = new HashSet<>(banMembers);
756 newlyBannedMembers.removeAll(group.getBannedMembers());
757 if (!newlyBannedMembers.isEmpty()) {
758 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
759 result = sendUpdateGroupV2Message(group,
760 groupGroupChangePair.first(),
761 handleGroupChangeResponse(group, groupGroupChangePair.second()));
762 }
763 }
764
765 if (unbanMembers != null) {
766 var existingUnbanMembers = new HashSet<>(unbanMembers);
767 existingUnbanMembers.retainAll(group.getBannedMembers());
768 if (!existingUnbanMembers.isEmpty()) {
769 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
770 result = sendUpdateGroupV2Message(group,
771 groupGroupChangePair.first(),
772 handleGroupChangeResponse(group, groupGroupChangePair.second()));
773 }
774 }
775
776 if (resetGroupLink) {
777 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
778 result = sendUpdateGroupV2Message(group,
779 groupGroupChangePair.first(),
780 handleGroupChangeResponse(group, groupGroupChangePair.second()));
781 }
782
783 if (groupLinkState != null) {
784 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
785 result = sendUpdateGroupV2Message(group,
786 groupGroupChangePair.first(),
787 handleGroupChangeResponse(group, groupGroupChangePair.second()));
788 }
789
790 if (addMemberPermission != null) {
791 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
792 result = sendUpdateGroupV2Message(group,
793 groupGroupChangePair.first(),
794 handleGroupChangeResponse(group, groupGroupChangePair.second()));
795 }
796
797 if (editDetailsPermission != null) {
798 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
799 result = sendUpdateGroupV2Message(group,
800 groupGroupChangePair.first(),
801 handleGroupChangeResponse(group, groupGroupChangePair.second()));
802 }
803
804 if (expirationTimer != null) {
805 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
806 result = sendUpdateGroupV2Message(group,
807 groupGroupChangePair.first(),
808 handleGroupChangeResponse(group, groupGroupChangePair.second()));
809 }
810
811 if (isAnnouncementGroup != null) {
812 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
813 result = sendUpdateGroupV2Message(group,
814 groupGroupChangePair.first(),
815 handleGroupChangeResponse(group, groupGroupChangePair.second()));
816 }
817
818 if (name != null || description != null || avatarFile != null) {
819 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
820 if (avatarFile != null) {
821 context.getAvatarStore()
822 .storeGroupAvatar(group.getGroupId(), outputStream -> outputStream.write(avatarFile));
823 }
824 result = sendUpdateGroupV2Message(group,
825 groupGroupChangePair.first(),
826 handleGroupChangeResponse(group, groupGroupChangePair.second()));
827 }
828
829 return result;
830 }
831
832 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
833 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
834 .withId(groupInfoV1.getGroupId().serialize())
835 .build();
836
837 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
838 groupInfoV1.removeMember(account.getSelfRecipientId());
839 account.getGroupStore().updateGroup(groupInfoV1);
840 return sendGroupMessage(messageBuilder,
841 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
842 groupInfoV1.getDistributionId());
843 }
844
845 private SendGroupMessageResults quitGroupV2(
846 final GroupInfoV2 groupInfoV2,
847 final Set<RecipientId> newAdmins
848 ) throws LastGroupAdminException, IOException {
849 final var currentAdmins = groupInfoV2.getAdminMembers();
850 newAdmins.removeAll(currentAdmins);
851 newAdmins.retainAll(groupInfoV2.getMembers());
852 if (currentAdmins.contains(account.getSelfRecipientId())
853 && currentAdmins.size() == 1
854 && groupInfoV2.getMembers().size() > 1
855 && newAdmins.isEmpty()) {
856 // Last admin can't leave the group, unless she's also the last member
857 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
858 }
859 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
860 groupInfoV2.setGroup(groupGroupChangePair.first());
861 account.getGroupStore().updateGroup(groupInfoV2);
862
863 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2,
864 handleGroupChangeResponse(groupInfoV2, groupGroupChangePair.second()).encode());
865 return sendGroupMessage(messageBuilder,
866 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
867 groupInfoV2.getDistributionId());
868 }
869
870 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
871 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
872 .withId(g.getGroupId().serialize())
873 .withName(g.name)
874 .withMembers(g.getMembers()
875 .stream()
876 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
877 .toList());
878
879 try {
880 final var attachment = createGroupAvatarAttachment(g.getGroupId());
881 attachment.ifPresent(group::withAvatar);
882 } catch (IOException e) {
883 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
884 }
885
886 return SignalServiceDataMessage.newBuilder()
887 .asGroupMessage(group.build())
888 .withExpiration(g.getMessageExpirationTimer());
889 }
890
891 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
892 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
893 .withRevision(g.getGroup().revision)
894 .withSignedGroupChange(signedGroupChange);
895 return SignalServiceDataMessage.newBuilder()
896 .asGroupMessage(group.build())
897 .withExpiration(g.getMessageExpirationTimer());
898 }
899
900 private SendGroupMessageResults sendUpdateGroupV2Message(
901 GroupInfoV2 group,
902 DecryptedGroup newDecryptedGroup,
903 GroupChange groupChange
904 ) throws IOException {
905 final var selfRecipientId = account.getSelfRecipientId();
906 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
907 group.setGroup(newDecryptedGroup);
908 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
909 account.getGroupStore().updateGroup(group);
910
911 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode());
912 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
913 }
914
915 private SendGroupMessageResults sendGroupMessage(
916 final SignalServiceDataMessage.Builder messageBuilder,
917 final Set<RecipientId> members,
918 final DistributionId distributionId
919 ) throws IOException {
920 final var timestamp = System.currentTimeMillis();
921 messageBuilder.withTimestamp(timestamp);
922 final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
923 return new SendGroupMessageResults(timestamp,
924 results.stream()
925 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
926 account.getRecipientResolver(),
927 account.getRecipientAddressResolver()))
928 .toList());
929 }
930
931 private byte[] readAvatarBytes(final String avatarFile) throws IOException {
932 if (avatarFile == null) {
933 return null;
934 }
935 try (final var avatar = Utils.createStreamDetails(avatarFile).first()) {
936 return IOUtils.readFully(avatar.getStream());
937 }
938 }
939 }