]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Add method to update group endorsements
[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().toProto();
117 return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
118 }
119
120 public GroupInfoV2 getOrMigrateGroup(
121 final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
122 ) {
123 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
124
125 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
126 final var groupInfoV2 = account.getGroupStore().getGroupOrPartialMigrate(groupMasterKey, groupId);
127
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);
135
136 if (decryptedGroupChange != null) {
137 storeProfileKeyFromChange(decryptedGroupChange);
138 group = context.getGroupV2Helper()
139 .getUpdatedDecryptedGroup(groupInfoV2.getGroup(), decryptedGroupChange);
140 }
141 }
142 if (group == null) {
143 try {
144 final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
145
146 if (response != null) {
147 group = handleDecryptedGroupResponse(groupInfoV2, response);
148 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group);
149 }
150 } catch (NotAGroupMemberException ignored) {
151 }
152 }
153 if (group != null) {
154 storeProfileKeysFromMembers(group);
155 final var avatar = group.avatar;
156 if (!avatar.isEmpty()) {
157 downloadGroupAvatar(groupId, groupSecretParams, avatar);
158 }
159 }
160 groupInfoV2.setGroup(group);
161 account.getGroupStore().updateGroup(groupInfoV2);
162 context.getJobExecutor().enqueueJob(new SyncStorageJob());
163 }
164
165 return groupInfoV2;
166 }
167
168 private DecryptedGroup handleDecryptedGroupResponse(
169 GroupInfoV2 groupInfoV2, final DecryptedGroupResponse decryptedGroupResponse
170 ) {
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());
177
178 // TODO save group endorsements
179
180 return decryptedGroupResponse.getGroup();
181 }
182
183 private GroupChange handleGroupChangeResponse(
184 final GroupInfoV2 groupInfoV2, final GroupChangeResponse groupChangeResponse
185 ) {
186 ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations()
187 .forGroup(GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()))
188 .receiveGroupSendEndorsements(account.getAci(),
189 groupInfoV2.getGroup(),
190 groupChangeResponse.groupSendEndorsementsResponse);
191
192 // TODO save group endorsements
193
194 return groupChangeResponse.groupChange;
195 }
196
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);
204 }
205
206 final var avatarBytes = readAvatarBytes(avatarFile);
207 var gv2Pair = context.getGroupV2Helper()
208 .createGroup(name == null ? "" : name, members == null ? Set.of() : members, avatarBytes);
209
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);
217 }
218
219 final var gv2 = gv2Pair.first();
220 final var decryptedGroup = gv2Pair.second();
221
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));
227 }
228
229 account.getGroupStore().updateGroup(gv2);
230
231 final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null);
232
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);
238 }
239
240 public SendGroupMessageResults updateGroup(
241 final GroupId groupId,
242 final String name,
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);
260
261 SendGroupMessageResults results;
262 switch (group) {
263 case GroupInfoV2 gv2 -> {
264 try {
265 results = updateGroupV2(gv2,
266 name,
267 description,
268 members,
269 removeMembers,
270 admins,
271 removeAdmins,
272 banMembers,
273 unbanMembers,
274 resetGroupLink,
275 groupLinkState,
276 addMemberPermission,
277 editDetailsPermission,
278 avatarBytes,
279 expirationTimer,
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,
285 name,
286 description,
287 members,
288 removeMembers,
289 admins,
290 removeAdmins,
291 banMembers,
292 unbanMembers,
293 resetGroupLink,
294 groupLinkState,
295 addMemberPermission,
296 editDetailsPermission,
297 avatarBytes,
298 expirationTimer,
299 isAnnouncementGroup);
300 }
301 }
302
303 case GroupInfoV1 gv1 -> {
304 results = updateGroupV1(gv1, name, members, avatarBytes);
305 if (expirationTimer != null) {
306 setExpirationTimer(gv1, expirationTimer);
307 }
308 }
309 }
310 context.getJobExecutor().enqueueJob(new SyncStorageJob());
311 return results;
312 }
313
314 public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
315 var group = getGroupForUpdating(groupId);
316
317 if (group instanceof GroupInfoV2 groupInfoV2) {
318 Pair<DecryptedGroup, GroupChangeResponse> groupChangePair;
319 try {
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);
325 }
326 if (groupChangePair != null) {
327 sendUpdateGroupV2Message(groupInfoV2,
328 groupChangePair.first(),
329 handleGroupChangeResponse(groupInfoV2, groupChangePair.second()));
330 }
331 }
332 }
333
334 public Pair<GroupId, SendGroupMessageResults> joinGroup(
335 GroupInviteLinkUrl inviteLinkUrl
336 ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
337 final DecryptedGroupJoinInfo groupJoinInfo;
338 try {
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);
343 }
344 if (groupJoinInfo.pendingAdminApproval) {
345 throw new PendingAdminApprovalException("You have already requested to join the group.");
346 }
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);
353
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()));
357 }
358
359 final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
360
361 context.getJobExecutor().enqueueJob(new SyncStorageJob());
362 return new Pair<>(group.getGroupId(), result);
363 }
364
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);
371 }
372
373 try {
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);
379 }
380 }
381
382 public void deleteGroup(GroupId groupId) throws IOException {
383 account.getGroupStore().deleteGroup(groupId);
384 context.getAvatarStore().deleteGroupAvatar(groupId);
385 context.getJobExecutor().enqueueJob(new SyncStorageJob());
386 }
387
388 public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
389 var group = getGroup(groupId);
390 if (group == null) {
391 throw new GroupNotFoundException(groupId);
392 }
393
394 group.setBlocked(blocked);
395 account.getGroupStore().updateGroup(group);
396 context.getJobExecutor().enqueueJob(new SyncStorageJob());
397 }
398
399 public SendGroupMessageResults sendGroupInfoRequest(
400 GroupIdV1 groupId, RecipientId recipientId
401 ) throws IOException {
402 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
403
404 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
405
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);
408 }
409
410 public SendGroupMessageResults sendGroupInfoMessage(
411 GroupIdV1 groupId, RecipientId recipientId
412 ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
413 GroupInfoV1 g;
414 var group = getGroupForUpdating(groupId);
415 if (!(group instanceof GroupInfoV1)) {
416 throw new IOException("Received an invalid group request for a v2 group!");
417 }
418 g = (GroupInfoV1) group;
419
420 if (!g.isMember(recipientId)) {
421 throw new NotAGroupMemberException(groupId, g.name);
422 }
423
424 var messageBuilder = getGroupUpdateMessageBuilder(g);
425
426 // Send group message only to the recipient who requested it
427 return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
428 }
429
430 private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
431 final var group = account.getGroupStore().getGroup(groupId);
432 fillOrUpdateGroup(group, forceUpdate);
433 return group;
434 }
435
436 private void fillOrUpdateGroup(final GroupInfo group, final boolean forceUpdate) {
437 if (!(group instanceof GroupInfoV2 groupInfoV2)) {
438 return;
439 }
440
441 if (!forceUpdate && (groupInfoV2.isPermissionDenied() || groupInfoV2.getGroup() != null)) {
442 return;
443 }
444
445 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
446 DecryptedGroup decryptedGroup;
447 try {
448 final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
449 if (response == null) {
450 return;
451 }
452 decryptedGroup = handleDecryptedGroupResponse(groupInfoV2, response);
453 } catch (NotAGroupMemberException e) {
454 groupInfoV2.setPermissionDenied(true);
455 account.getGroupStore().updateGroup(group);
456 return;
457 }
458
459 try {
460 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, decryptedGroup);
461 } catch (NotAGroupMemberException ignored) {
462 }
463 storeProfileKeysFromMembers(decryptedGroup);
464 final var avatar = decryptedGroup.avatar;
465 if (!avatar.isEmpty()) {
466 downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
467 }
468 groupInfoV2.setGroup(decryptedGroup);
469 account.getGroupStore().updateGroup(group);
470 }
471
472 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
473 try {
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());
479 }
480 }
481
482 private void retrieveGroupV2Avatar(
483 GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
484 ) throws IOException {
485 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
486
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);
491
492 var decryptedData = groupOperations.decryptAvatar(encryptedData);
493 outputStream.write(decryptedData);
494 } finally {
495 try {
496 Files.delete(tmpFile.toPath());
497 } catch (IOException e) {
498 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
499 tmpFile,
500 e.getMessage());
501 }
502 }
503 }
504
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
512 continue;
513 }
514 try {
515 profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
516 } catch (InvalidInputException ignored) {
517 }
518 }
519 }
520
521 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange) {
522 final var profileKeyFromChange = context.getGroupV2Helper()
523 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange);
524
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);
530 }
531 }
532
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>();
543 while (true) {
544 final var page = context.getGroupV2Helper()
545 .getDecryptedGroupHistoryPage(groupSecretParams, fromRevision, sendEndorsementsExpirationMs);
546 page.getChangeLogs()
547 .stream()
548 .map(DecryptedGroupChangeLog::getChange)
549 .filter(Objects::nonNull)
550 .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
551 .filter(Objects::nonNull)
552 .forEach(p -> {
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);
557 });
558 if (!page.getPagingData().getHasMorePages()) {
559 break;
560 }
561 fromRevision = page.getPagingData().getNextPageRevision();
562 }
563
564 newProfileKeys.entrySet()
565 .stream()
566 .filter(entry -> account.getProfileStore().getProfileKey(entry.getKey()) == null)
567 .forEach(entry -> account.getProfileStore().storeProfileKey(entry.getKey(), entry.getValue()));
568 }
569
570 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
571 var g = getGroup(groupId);
572 if (g == null) {
573 throw new GroupNotFoundException(groupId);
574 }
575 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
576 throw new NotAGroupMemberException(groupId, g.getTitle());
577 }
578 if (groupId instanceof GroupIdV2) {
579 // Refresh group before updating
580 return getGroup(groupId, true);
581 }
582 return g;
583 }
584
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);
589
590 account.getGroupStore().updateGroup(gv1);
591
592 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
593 return sendGroupMessage(messageBuilder,
594 gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
595 gv1.getDistributionId());
596 }
597
598 private void updateGroupV1Details(
599 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final byte[] avatarFile
600 ) throws IOException {
601 if (name != null) {
602 g.name = name;
603 }
604
605 if (members != null) {
606 g.addMembers(members);
607 }
608
609 if (avatarFile != null) {
610 context.getAvatarStore().storeGroupAvatar(g.getGroupId(), outputStream -> outputStream.write(avatarFile));
611 }
612 }
613
614 /**
615 * Change the expiration timer for a group
616 */
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());
623 }
624
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());
628 }
629
630 private SendGroupMessageResults updateGroupV2(
631 final GroupInfoV2 group,
632 final String name,
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()));
655 }
656
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()));
665 }
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()));
674 }
675 }
676
677 if (removeMembers != null) {
678 var existingRemoveMembers = new HashSet<>(removeMembers);
679 if (banMembers != null) {
680 existingRemoveMembers.addAll(banMembers);
681 }
682 existingRemoveMembers.retainAll(group.getMembers());
683 if (members != null) {
684 existingRemoveMembers.removeAll(members);
685 }
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()));
692 }
693
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()));
701 }
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()));
709 }
710 }
711
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()));
722 }
723 }
724 }
725
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()));
735 }
736 }
737 }
738
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()));
747 }
748 }
749
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()));
758 }
759 }
760
761 if (resetGroupLink) {
762 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
763 result = sendUpdateGroupV2Message(group,
764 groupGroupChangePair.first(),
765 handleGroupChangeResponse(group, groupGroupChangePair.second()));
766 }
767
768 if (groupLinkState != null) {
769 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
770 result = sendUpdateGroupV2Message(group,
771 groupGroupChangePair.first(),
772 handleGroupChangeResponse(group, groupGroupChangePair.second()));
773 }
774
775 if (addMemberPermission != null) {
776 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
777 result = sendUpdateGroupV2Message(group,
778 groupGroupChangePair.first(),
779 handleGroupChangeResponse(group, groupGroupChangePair.second()));
780 }
781
782 if (editDetailsPermission != null) {
783 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
784 result = sendUpdateGroupV2Message(group,
785 groupGroupChangePair.first(),
786 handleGroupChangeResponse(group, groupGroupChangePair.second()));
787 }
788
789 if (expirationTimer != null) {
790 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
791 result = sendUpdateGroupV2Message(group,
792 groupGroupChangePair.first(),
793 handleGroupChangeResponse(group, groupGroupChangePair.second()));
794 }
795
796 if (isAnnouncementGroup != null) {
797 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
798 result = sendUpdateGroupV2Message(group,
799 groupGroupChangePair.first(),
800 handleGroupChangeResponse(group, groupGroupChangePair.second()));
801 }
802
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));
808 }
809 result = sendUpdateGroupV2Message(group,
810 groupGroupChangePair.first(),
811 handleGroupChangeResponse(group, groupGroupChangePair.second()));
812 }
813
814 return result;
815 }
816
817 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
818 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
819 .withId(groupInfoV1.getGroupId().serialize())
820 .build();
821
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());
828 }
829
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());
842 }
843 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
844 groupInfoV2.setGroup(groupGroupChangePair.first());
845 account.getGroupStore().updateGroup(groupInfoV2);
846
847 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2,
848 handleGroupChangeResponse(groupInfoV2, groupGroupChangePair.second()).encode());
849 return sendGroupMessage(messageBuilder,
850 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
851 groupInfoV2.getDistributionId());
852 }
853
854 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
855 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
856 .withId(g.getGroupId().serialize())
857 .withName(g.name)
858 .withMembers(g.getMembers()
859 .stream()
860 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
861 .toList());
862
863 try {
864 final var attachment = createGroupAvatarAttachment(g.getGroupId());
865 attachment.ifPresent(group::withAvatar);
866 } catch (IOException e) {
867 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
868 }
869
870 return SignalServiceDataMessage.newBuilder()
871 .asGroupMessage(group.build())
872 .withExpiration(g.getMessageExpirationTimer());
873 }
874
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());
882 }
883
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);
892
893 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode());
894 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
895 }
896
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,
906 results.stream()
907 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
908 account.getRecipientResolver(),
909 account.getRecipientAddressResolver()))
910 .toList());
911 }
912
913 private byte[] readAvatarBytes(final String avatarFile) throws IOException {
914 if (avatarFile == null) {
915 return null;
916 }
917 try (final var avatar = Utils.createStreamDetails(avatarFile).first()) {
918 return IOUtils.readFully(avatar.getStream());
919 }
920 }
921 }