]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
90d5d7261fd53113ffd67cc1e4564c3f01fefe5d
[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.SignalDependencies;
4 import org.asamk.signal.manager.api.AttachmentInvalidException;
5 import org.asamk.signal.manager.api.InactiveGroupLinkException;
6 import org.asamk.signal.manager.api.Pair;
7 import org.asamk.signal.manager.api.PendingAdminApprovalException;
8 import org.asamk.signal.manager.api.SendGroupMessageResults;
9 import org.asamk.signal.manager.api.SendMessageResult;
10 import org.asamk.signal.manager.config.ServiceConfig;
11 import org.asamk.signal.manager.groups.GroupId;
12 import org.asamk.signal.manager.groups.GroupIdV1;
13 import org.asamk.signal.manager.groups.GroupIdV2;
14 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
15 import org.asamk.signal.manager.groups.GroupLinkState;
16 import org.asamk.signal.manager.groups.GroupNotFoundException;
17 import org.asamk.signal.manager.groups.GroupPermission;
18 import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
19 import org.asamk.signal.manager.groups.GroupUtils;
20 import org.asamk.signal.manager.groups.LastGroupAdminException;
21 import org.asamk.signal.manager.groups.NotAGroupMemberException;
22 import org.asamk.signal.manager.storage.SignalAccount;
23 import org.asamk.signal.manager.storage.groups.GroupInfo;
24 import org.asamk.signal.manager.storage.groups.GroupInfoV1;
25 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
26 import org.asamk.signal.manager.storage.recipients.RecipientId;
27 import org.asamk.signal.manager.util.AttachmentUtils;
28 import org.asamk.signal.manager.util.IOUtils;
29 import org.signal.libsignal.zkgroup.InvalidInputException;
30 import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
31 import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
32 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
33 import org.signal.storageservice.protos.groups.GroupChange;
34 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
35 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
36 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry;
40 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
41 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
42 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
43 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
44 import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
45 import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
46 import org.whispersystems.signalservice.api.push.DistributionId;
47 import org.whispersystems.signalservice.api.push.ServiceId;
48 import org.whispersystems.signalservice.api.push.exceptions.ConflictException;
49
50 import java.io.File;
51 import java.io.IOException;
52 import java.io.InputStream;
53 import java.io.OutputStream;
54 import java.nio.file.Files;
55 import java.util.Collection;
56 import java.util.HashMap;
57 import java.util.HashSet;
58 import java.util.List;
59 import java.util.Objects;
60 import java.util.Optional;
61 import java.util.Set;
62
63 public class GroupHelper {
64
65 private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
66
67 private final SignalAccount account;
68 private final SignalDependencies dependencies;
69 private final Context context;
70
71 public GroupHelper(final Context context) {
72 this.account = context.getAccount();
73 this.dependencies = context.getDependencies();
74 this.context = context;
75 }
76
77 public GroupInfo getGroup(GroupId groupId) {
78 return getGroup(groupId, false);
79 }
80
81 public boolean isGroupBlocked(final GroupId groupId) {
82 var group = getGroup(groupId);
83 return group != null && group.isBlocked();
84 }
85
86 public void downloadGroupAvatar(GroupIdV1 groupId, SignalServiceAttachment avatar) {
87 try {
88 context.getAvatarStore()
89 .storeGroupAvatar(groupId,
90 outputStream -> context.getAttachmentHelper().retrieveAttachment(avatar, outputStream));
91 } catch (IOException e) {
92 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
93 }
94 }
95
96 public Optional<SignalServiceAttachmentStream> createGroupAvatarAttachment(GroupIdV1 groupId) throws IOException {
97 final var streamDetails = context.getAvatarStore().retrieveGroupAvatar(groupId);
98 if (streamDetails == null) {
99 return Optional.empty();
100 }
101
102 return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty()));
103 }
104
105 public GroupInfoV2 getOrMigrateGroup(
106 final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
107 ) {
108 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
109
110 var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
111 var groupInfo = getGroup(groupId);
112 final GroupInfoV2 groupInfoV2;
113 if (groupInfo instanceof GroupInfoV1) {
114 // Received a v2 group message for a v1 group, we need to locally migrate the group
115 account.getGroupStore().deleteGroup(((GroupInfoV1) groupInfo).getGroupId());
116 groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
117 groupInfoV2.setBlocked(groupInfo.isBlocked());
118 account.getGroupStore().updateGroup(groupInfoV2);
119 logger.info("Locally migrated group {} to group v2, id: {}",
120 groupInfo.getGroupId().toBase64(),
121 groupInfoV2.getGroupId().toBase64());
122 } else if (groupInfo instanceof GroupInfoV2) {
123 groupInfoV2 = (GroupInfoV2) groupInfo;
124 } else {
125 groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
126 }
127
128 if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) {
129 DecryptedGroup group = null;
130 if (signedGroupChange != null
131 && groupInfoV2.getGroup() != null
132 && groupInfoV2.getGroup().getRevision() + 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 group = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
145
146 if (group != null) {
147 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group);
148 }
149 } catch (NotAGroupMemberException ignored) {
150 }
151 }
152 if (group != null) {
153 storeProfileKeysFromMembers(group);
154 final var avatar = group.getAvatar();
155 if (avatar != null && !avatar.isEmpty()) {
156 downloadGroupAvatar(groupId, groupSecretParams, avatar);
157 }
158 }
159 groupInfoV2.setGroup(group);
160 account.getGroupStore().updateGroup(groupInfoV2);
161 }
162
163 return groupInfoV2;
164 }
165
166 public Pair<GroupId, SendGroupMessageResults> createGroup(
167 String name, Set<RecipientId> members, File avatarFile
168 ) throws IOException, AttachmentInvalidException {
169 final var selfRecipientId = account.getSelfRecipientId();
170 if (members != null && members.contains(selfRecipientId)) {
171 members = new HashSet<>(members);
172 members.remove(selfRecipientId);
173 }
174
175 var gv2Pair = context.getGroupV2Helper()
176 .createGroup(name == null ? "" : name, members == null ? Set.of() : members, avatarFile);
177
178 if (gv2Pair == null) {
179 // Failed to create v2 group, creating v1 group instead
180 var gv1 = new GroupInfoV1(GroupIdV1.createRandom());
181 gv1.addMembers(List.of(selfRecipientId));
182 final var result = updateGroupV1(gv1, name, members, avatarFile);
183 return new Pair<>(gv1.getGroupId(), result);
184 }
185
186 final var gv2 = gv2Pair.first();
187 final var decryptedGroup = gv2Pair.second();
188
189 gv2.setGroup(decryptedGroup);
190 if (avatarFile != null) {
191 context.getAvatarStore()
192 .storeGroupAvatar(gv2.getGroupId(),
193 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
194 }
195
196 account.getGroupStore().updateGroup(gv2);
197
198 final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null);
199
200 final var result = sendGroupMessage(messageBuilder,
201 gv2.getMembersIncludingPendingWithout(selfRecipientId),
202 gv2.getDistributionId());
203 return new Pair<>(gv2.getGroupId(), result);
204 }
205
206 public SendGroupMessageResults updateGroup(
207 final GroupId groupId,
208 final String name,
209 final String description,
210 final Set<RecipientId> members,
211 final Set<RecipientId> removeMembers,
212 final Set<RecipientId> admins,
213 final Set<RecipientId> removeAdmins,
214 final Set<RecipientId> banMembers,
215 final Set<RecipientId> unbanMembers,
216 final boolean resetGroupLink,
217 final GroupLinkState groupLinkState,
218 final GroupPermission addMemberPermission,
219 final GroupPermission editDetailsPermission,
220 final File avatarFile,
221 final Integer expirationTimer,
222 final Boolean isAnnouncementGroup
223 ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
224 var group = getGroupForUpdating(groupId);
225
226 if (group instanceof GroupInfoV2) {
227 try {
228 return updateGroupV2((GroupInfoV2) group,
229 name,
230 description,
231 members,
232 removeMembers,
233 admins,
234 removeAdmins,
235 banMembers,
236 unbanMembers,
237 resetGroupLink,
238 groupLinkState,
239 addMemberPermission,
240 editDetailsPermission,
241 avatarFile,
242 expirationTimer,
243 isAnnouncementGroup);
244 } catch (ConflictException e) {
245 // Detected conflicting update, refreshing group and trying again
246 group = getGroup(groupId, true);
247 return updateGroupV2((GroupInfoV2) group,
248 name,
249 description,
250 members,
251 removeMembers,
252 admins,
253 removeAdmins,
254 banMembers,
255 unbanMembers,
256 resetGroupLink,
257 groupLinkState,
258 addMemberPermission,
259 editDetailsPermission,
260 avatarFile,
261 expirationTimer,
262 isAnnouncementGroup);
263 }
264 }
265
266 final var gv1 = (GroupInfoV1) group;
267 final var result = updateGroupV1(gv1, name, members, avatarFile);
268 if (expirationTimer != null) {
269 setExpirationTimer(gv1, expirationTimer);
270 }
271 return result;
272 }
273
274 public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
275 var group = getGroupForUpdating(groupId);
276
277 if (group instanceof GroupInfoV2 groupInfoV2) {
278 Pair<DecryptedGroup, GroupChange> groupChangePair;
279 try {
280 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
281 } catch (ConflictException e) {
282 // Detected conflicting update, refreshing group and trying again
283 groupInfoV2 = (GroupInfoV2) getGroup(groupId, true);
284 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
285 }
286 if (groupChangePair != null) {
287 sendUpdateGroupV2Message(groupInfoV2, groupChangePair.first(), groupChangePair.second());
288 }
289 }
290 }
291
292 public Pair<GroupId, SendGroupMessageResults> joinGroup(
293 GroupInviteLinkUrl inviteLinkUrl
294 ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
295 final DecryptedGroupJoinInfo groupJoinInfo;
296 try {
297 groupJoinInfo = context.getGroupV2Helper()
298 .getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword());
299 } catch (GroupLinkNotActiveException e) {
300 throw new InactiveGroupLinkException("Group link inactive (reason: " + e.getReason() + ")", e);
301 }
302 if (groupJoinInfo.getPendingAdminApproval()) {
303 throw new PendingAdminApprovalException("You have already requested to join the group.");
304 }
305 final var groupChange = context.getGroupV2Helper()
306 .joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo);
307 final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
308 groupJoinInfo.getRevision() + 1,
309 groupChange.toByteArray());
310
311 if (group.getGroup() == null) {
312 // Only requested member, can't send update to group members
313 return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of()));
314 }
315
316 final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
317
318 return new Pair<>(group.getGroupId(), result);
319 }
320
321 public SendGroupMessageResults quitGroup(
322 final GroupId groupId, final Set<RecipientId> newAdmins
323 ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
324 var group = getGroupForUpdating(groupId);
325 if (group instanceof GroupInfoV1) {
326 return quitGroupV1((GroupInfoV1) group);
327 }
328
329 try {
330 return quitGroupV2((GroupInfoV2) group, newAdmins);
331 } catch (ConflictException e) {
332 // Detected conflicting update, refreshing group and trying again
333 group = getGroup(groupId, true);
334 return quitGroupV2((GroupInfoV2) group, newAdmins);
335 }
336 }
337
338 public void deleteGroup(GroupId groupId) throws IOException {
339 account.getGroupStore().deleteGroup(groupId);
340 context.getAvatarStore().deleteGroupAvatar(groupId);
341 }
342
343 public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
344 var group = getGroup(groupId);
345 if (group == null) {
346 throw new GroupNotFoundException(groupId);
347 }
348
349 group.setBlocked(blocked);
350 account.getGroupStore().updateGroup(group);
351 }
352
353 public SendGroupMessageResults sendGroupInfoRequest(
354 GroupIdV1 groupId, RecipientId recipientId
355 ) throws IOException {
356 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
357
358 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
359
360 // Send group info request message to the recipient who sent us a message with this groupId
361 return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
362 }
363
364 public SendGroupMessageResults sendGroupInfoMessage(
365 GroupIdV1 groupId, RecipientId recipientId
366 ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
367 GroupInfoV1 g;
368 var group = getGroupForUpdating(groupId);
369 if (!(group instanceof GroupInfoV1)) {
370 throw new IOException("Received an invalid group request for a v2 group!");
371 }
372 g = (GroupInfoV1) group;
373
374 if (!g.isMember(recipientId)) {
375 throw new NotAGroupMemberException(groupId, g.name);
376 }
377
378 var messageBuilder = getGroupUpdateMessageBuilder(g);
379
380 // Send group message only to the recipient who requested it
381 return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
382 }
383
384 private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
385 final var group = account.getGroupStore().getGroup(groupId);
386 if (group instanceof GroupInfoV2 groupInfoV2) {
387 if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) {
388 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
389 DecryptedGroup decryptedGroup;
390 try {
391 decryptedGroup = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
392 } catch (NotAGroupMemberException e) {
393 groupInfoV2.setPermissionDenied(true);
394 decryptedGroup = null;
395 }
396 if (decryptedGroup != null) {
397 try {
398 storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, decryptedGroup);
399 } catch (NotAGroupMemberException ignored) {
400 }
401 storeProfileKeysFromMembers(decryptedGroup);
402 final var avatar = decryptedGroup.getAvatar();
403 if (avatar != null && !avatar.isEmpty()) {
404 downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
405 }
406 }
407 groupInfoV2.setGroup(decryptedGroup);
408 account.getGroupStore().updateGroup(group);
409 }
410 }
411 return group;
412 }
413
414 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
415 try {
416 context.getAvatarStore()
417 .storeGroupAvatar(groupId,
418 outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
419 } catch (IOException e) {
420 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
421 }
422 }
423
424 private void retrieveGroupV2Avatar(
425 GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
426 ) throws IOException {
427 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
428
429 var tmpFile = IOUtils.createTempFile();
430 try (InputStream input = dependencies.getMessageReceiver()
431 .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
432 var encryptedData = IOUtils.readFully(input);
433
434 var decryptedData = groupOperations.decryptAvatar(encryptedData);
435 outputStream.write(decryptedData);
436 } finally {
437 try {
438 Files.delete(tmpFile.toPath());
439 } catch (IOException e) {
440 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
441 tmpFile,
442 e.getMessage());
443 }
444 }
445 }
446
447 private void storeProfileKeysFromMembers(final DecryptedGroup group) {
448 for (var member : group.getMembersList()) {
449 final var serviceId = ServiceId.fromByteString(member.getUuid());
450 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
451 final var profileStore = account.getProfileStore();
452 if (profileStore.getProfileKey(recipientId) != null) {
453 // We already have a profile key, not updating it from a non-authoritative source
454 continue;
455 }
456 try {
457 profileStore.storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray()));
458 } catch (InvalidInputException ignored) {
459 }
460 }
461 }
462
463 private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange) {
464 final var profileKeyFromChange = context.getGroupV2Helper()
465 .getAuthoritativeProfileKeyFromChange(decryptedGroupChange);
466
467 if (profileKeyFromChange != null) {
468 final var serviceId = profileKeyFromChange.first();
469 final var profileKey = profileKeyFromChange.second();
470 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
471 account.getProfileStore().storeProfileKey(recipientId, profileKey);
472 }
473 }
474
475 private void storeProfileKeysFromHistory(
476 final GroupSecretParams groupSecretParams,
477 final GroupInfoV2 localGroup,
478 final DecryptedGroup newDecryptedGroup
479 ) throws NotAGroupMemberException {
480 final var revisionWeWereAdded = context.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup);
481 final var localRevision = localGroup.getGroup() == null ? 0 : localGroup.getGroup().getRevision();
482 var fromRevision = Math.max(revisionWeWereAdded, localRevision);
483 final var newProfileKeys = new HashMap<RecipientId, ProfileKey>();
484 while (true) {
485 final var page = context.getGroupV2Helper().getDecryptedGroupHistoryPage(groupSecretParams, fromRevision);
486 page.getResults()
487 .stream()
488 .map(DecryptedGroupHistoryEntry::getChange)
489 .filter(Optional::isPresent)
490 .map(Optional::get)
491 .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
492 .filter(Objects::nonNull)
493 .forEach(p -> {
494 final var serviceId = p.first();
495 final var profileKey = p.second();
496 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
497 newProfileKeys.put(recipientId, profileKey);
498 });
499 if (!page.getPagingData().hasMorePages()) {
500 break;
501 }
502 fromRevision = page.getPagingData().getNextPageRevision();
503 }
504
505 newProfileKeys.forEach(account.getProfileStore()::storeProfileKey);
506 }
507
508 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
509 var g = getGroup(groupId);
510 if (g == null) {
511 throw new GroupNotFoundException(groupId);
512 }
513 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
514 throw new NotAGroupMemberException(groupId, g.getTitle());
515 }
516 if (groupId instanceof GroupIdV2) {
517 // Refresh group before updating
518 return getGroup(groupId, true);
519 }
520 return g;
521 }
522
523 private SendGroupMessageResults updateGroupV1(
524 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
525 ) throws IOException, AttachmentInvalidException {
526 updateGroupV1Details(gv1, name, members, avatarFile);
527
528 account.getGroupStore().updateGroup(gv1);
529
530 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
531 return sendGroupMessage(messageBuilder,
532 gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
533 gv1.getDistributionId());
534 }
535
536 private void updateGroupV1Details(
537 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
538 ) throws IOException {
539 if (name != null) {
540 g.name = name;
541 }
542
543 if (members != null) {
544 g.addMembers(members);
545 }
546
547 if (avatarFile != null) {
548 context.getAvatarStore()
549 .storeGroupAvatar(g.getGroupId(),
550 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
551 }
552 }
553
554 /**
555 * Change the expiration timer for a group
556 */
557 private void setExpirationTimer(
558 GroupInfoV1 groupInfoV1, int messageExpirationTimer
559 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
560 groupInfoV1.messageExpirationTime = messageExpirationTimer;
561 account.getGroupStore().updateGroup(groupInfoV1);
562 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
563 }
564
565 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
566 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
567 context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId);
568 }
569
570 private SendGroupMessageResults updateGroupV2(
571 final GroupInfoV2 group,
572 final String name,
573 final String description,
574 final Set<RecipientId> members,
575 final Set<RecipientId> removeMembers,
576 final Set<RecipientId> admins,
577 final Set<RecipientId> removeAdmins,
578 final Set<RecipientId> banMembers,
579 final Set<RecipientId> unbanMembers,
580 final boolean resetGroupLink,
581 final GroupLinkState groupLinkState,
582 final GroupPermission addMemberPermission,
583 final GroupPermission editDetailsPermission,
584 final File avatarFile,
585 final Integer expirationTimer,
586 final Boolean isAnnouncementGroup
587 ) throws IOException {
588 SendGroupMessageResults result = null;
589 final var groupV2Helper = context.getGroupV2Helper();
590 if (group.isPendingMember(account.getSelfRecipientId())) {
591 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
592 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
593 }
594
595 if (members != null) {
596 final var newMembers = new HashSet<>(members);
597 newMembers.removeAll(group.getMembers());
598 if (newMembers.size() > 0) {
599 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
600 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
601 }
602 }
603
604 if (removeMembers != null) {
605 var existingRemoveMembers = new HashSet<>(removeMembers);
606 if (banMembers != null) {
607 existingRemoveMembers.addAll(banMembers);
608 }
609 existingRemoveMembers.retainAll(group.getMembers());
610 if (members != null) {
611 existingRemoveMembers.removeAll(members);
612 }
613 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
614 if (existingRemoveMembers.size() > 0) {
615 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
616 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
617 }
618
619 var pendingRemoveMembers = new HashSet<>(removeMembers);
620 pendingRemoveMembers.retainAll(group.getPendingMembers());
621 if (pendingRemoveMembers.size() > 0) {
622 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
623 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
624 }
625 var requestingRemoveMembers = new HashSet<>(removeMembers);
626 requestingRemoveMembers.retainAll(group.getRequestingMembers());
627 if (requestingRemoveMembers.size() > 0) {
628 var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers);
629 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
630 }
631 }
632
633 if (admins != null) {
634 final var newAdmins = new HashSet<>(admins);
635 newAdmins.retainAll(group.getMembers());
636 newAdmins.removeAll(group.getAdminMembers());
637 if (newAdmins.size() > 0) {
638 for (var admin : newAdmins) {
639 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
640 result = sendUpdateGroupV2Message(group,
641 groupGroupChangePair.first(),
642 groupGroupChangePair.second());
643 }
644 }
645 }
646
647 if (removeAdmins != null) {
648 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
649 existingRemoveAdmins.retainAll(group.getAdminMembers());
650 if (existingRemoveAdmins.size() > 0) {
651 for (var admin : existingRemoveAdmins) {
652 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
653 result = sendUpdateGroupV2Message(group,
654 groupGroupChangePair.first(),
655 groupGroupChangePair.second());
656 }
657 }
658 }
659
660 if (banMembers != null) {
661 final var newlyBannedMembers = new HashSet<>(banMembers);
662 newlyBannedMembers.removeAll(group.getBannedMembers());
663 if (newlyBannedMembers.size() > 0) {
664 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
665 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
666 }
667 }
668
669 if (unbanMembers != null) {
670 var existingUnbanMembers = new HashSet<>(unbanMembers);
671 existingUnbanMembers.retainAll(group.getBannedMembers());
672 if (existingUnbanMembers.size() > 0) {
673 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
674 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
675 }
676 }
677
678 if (resetGroupLink) {
679 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
680 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
681 }
682
683 if (groupLinkState != null) {
684 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
685 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
686 }
687
688 if (addMemberPermission != null) {
689 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
690 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
691 }
692
693 if (editDetailsPermission != null) {
694 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
695 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
696 }
697
698 if (expirationTimer != null) {
699 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
700 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
701 }
702
703 if (isAnnouncementGroup != null) {
704 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
705 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
706 }
707
708 if (name != null || description != null || avatarFile != null) {
709 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
710 if (avatarFile != null) {
711 context.getAvatarStore()
712 .storeGroupAvatar(group.getGroupId(),
713 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
714 }
715 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
716 }
717
718 return result;
719 }
720
721 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
722 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
723 .withId(groupInfoV1.getGroupId().serialize())
724 .build();
725
726 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
727 groupInfoV1.removeMember(account.getSelfRecipientId());
728 account.getGroupStore().updateGroup(groupInfoV1);
729 return sendGroupMessage(messageBuilder,
730 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
731 groupInfoV1.getDistributionId());
732 }
733
734 private SendGroupMessageResults quitGroupV2(
735 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
736 ) throws LastGroupAdminException, IOException {
737 final var currentAdmins = groupInfoV2.getAdminMembers();
738 newAdmins.removeAll(currentAdmins);
739 newAdmins.retainAll(groupInfoV2.getMembers());
740 if (currentAdmins.contains(account.getSelfRecipientId())
741 && currentAdmins.size() == 1
742 && groupInfoV2.getMembers().size() > 1
743 && newAdmins.size() == 0) {
744 // Last admin can't leave the group, unless she's also the last member
745 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
746 }
747 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
748 groupInfoV2.setGroup(groupGroupChangePair.first());
749 account.getGroupStore().updateGroup(groupInfoV2);
750
751 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
752 return sendGroupMessage(messageBuilder,
753 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
754 groupInfoV2.getDistributionId());
755 }
756
757 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
758 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
759 .withId(g.getGroupId().serialize())
760 .withName(g.name)
761 .withMembers(g.getMembers()
762 .stream()
763 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
764 .toList());
765
766 try {
767 final var attachment = createGroupAvatarAttachment(g.getGroupId());
768 attachment.ifPresent(group::withAvatar);
769 } catch (IOException e) {
770 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
771 }
772
773 return SignalServiceDataMessage.newBuilder()
774 .asGroupMessage(group.build())
775 .withExpiration(g.getMessageExpirationTimer());
776 }
777
778 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
779 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
780 .withRevision(g.getGroup().getRevision())
781 .withSignedGroupChange(signedGroupChange);
782 return SignalServiceDataMessage.newBuilder()
783 .asGroupMessage(group.build())
784 .withExpiration(g.getMessageExpirationTimer());
785 }
786
787 private SendGroupMessageResults sendUpdateGroupV2Message(
788 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
789 ) throws IOException {
790 final var selfRecipientId = account.getSelfRecipientId();
791 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
792 group.setGroup(newDecryptedGroup);
793 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
794 account.getGroupStore().updateGroup(group);
795
796 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
797 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
798 }
799
800 private SendGroupMessageResults sendGroupMessage(
801 final SignalServiceDataMessage.Builder messageBuilder,
802 final Set<RecipientId> members,
803 final DistributionId distributionId
804 ) throws IOException {
805 final var timestamp = System.currentTimeMillis();
806 messageBuilder.withTimestamp(timestamp);
807 final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
808 return new SendGroupMessageResults(timestamp,
809 results.stream()
810 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
811 account.getRecipientResolver(),
812 account.getRecipientAddressResolver()))
813 .toList());
814 }
815 }