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