]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Improve error message when joining a group with already pending admin approval
[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 return g;
517 }
518
519 private SendGroupMessageResults updateGroupV1(
520 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
521 ) throws IOException, AttachmentInvalidException {
522 updateGroupV1Details(gv1, name, members, avatarFile);
523
524 account.getGroupStore().updateGroup(gv1);
525
526 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
527 return sendGroupMessage(messageBuilder,
528 gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
529 gv1.getDistributionId());
530 }
531
532 private void updateGroupV1Details(
533 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
534 ) throws IOException {
535 if (name != null) {
536 g.name = name;
537 }
538
539 if (members != null) {
540 g.addMembers(members);
541 }
542
543 if (avatarFile != null) {
544 context.getAvatarStore()
545 .storeGroupAvatar(g.getGroupId(),
546 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
547 }
548 }
549
550 /**
551 * Change the expiration timer for a group
552 */
553 private void setExpirationTimer(
554 GroupInfoV1 groupInfoV1, int messageExpirationTimer
555 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
556 groupInfoV1.messageExpirationTime = messageExpirationTimer;
557 account.getGroupStore().updateGroup(groupInfoV1);
558 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
559 }
560
561 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
562 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
563 context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId);
564 }
565
566 private SendGroupMessageResults updateGroupV2(
567 final GroupInfoV2 group,
568 final String name,
569 final String description,
570 final Set<RecipientId> members,
571 final Set<RecipientId> removeMembers,
572 final Set<RecipientId> admins,
573 final Set<RecipientId> removeAdmins,
574 final Set<RecipientId> banMembers,
575 final Set<RecipientId> unbanMembers,
576 final boolean resetGroupLink,
577 final GroupLinkState groupLinkState,
578 final GroupPermission addMemberPermission,
579 final GroupPermission editDetailsPermission,
580 final File avatarFile,
581 final Integer expirationTimer,
582 final Boolean isAnnouncementGroup
583 ) throws IOException {
584 SendGroupMessageResults result = null;
585 final var groupV2Helper = context.getGroupV2Helper();
586 if (group.isPendingMember(account.getSelfRecipientId())) {
587 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
588 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
589 }
590
591 if (members != null) {
592 final var newMembers = new HashSet<>(members);
593 newMembers.removeAll(group.getMembers());
594 if (newMembers.size() > 0) {
595 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
596 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
597 }
598 }
599
600 if (removeMembers != null) {
601 var existingRemoveMembers = new HashSet<>(removeMembers);
602 if (banMembers != null) {
603 existingRemoveMembers.addAll(banMembers);
604 }
605 existingRemoveMembers.retainAll(group.getMembers());
606 if (members != null) {
607 existingRemoveMembers.removeAll(members);
608 }
609 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
610 if (existingRemoveMembers.size() > 0) {
611 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
612 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
613 }
614
615 var pendingRemoveMembers = new HashSet<>(removeMembers);
616 pendingRemoveMembers.retainAll(group.getPendingMembers());
617 if (pendingRemoveMembers.size() > 0) {
618 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
619 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
620 }
621 }
622
623 if (admins != null) {
624 final var newAdmins = new HashSet<>(admins);
625 newAdmins.retainAll(group.getMembers());
626 newAdmins.removeAll(group.getAdminMembers());
627 if (newAdmins.size() > 0) {
628 for (var admin : newAdmins) {
629 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
630 result = sendUpdateGroupV2Message(group,
631 groupGroupChangePair.first(),
632 groupGroupChangePair.second());
633 }
634 }
635 }
636
637 if (removeAdmins != null) {
638 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
639 existingRemoveAdmins.retainAll(group.getAdminMembers());
640 if (existingRemoveAdmins.size() > 0) {
641 for (var admin : existingRemoveAdmins) {
642 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
643 result = sendUpdateGroupV2Message(group,
644 groupGroupChangePair.first(),
645 groupGroupChangePair.second());
646 }
647 }
648 }
649
650 if (banMembers != null) {
651 final var newlyBannedMembers = new HashSet<>(banMembers);
652 newlyBannedMembers.removeAll(group.getBannedMembers());
653 if (newlyBannedMembers.size() > 0) {
654 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
655 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
656 }
657 }
658
659 if (unbanMembers != null) {
660 var existingUnbanMembers = new HashSet<>(unbanMembers);
661 existingUnbanMembers.retainAll(group.getBannedMembers());
662 if (existingUnbanMembers.size() > 0) {
663 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
664 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
665 }
666 }
667
668 if (resetGroupLink) {
669 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
670 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
671 }
672
673 if (groupLinkState != null) {
674 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
675 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
676 }
677
678 if (addMemberPermission != null) {
679 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
680 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
681 }
682
683 if (editDetailsPermission != null) {
684 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
685 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
686 }
687
688 if (expirationTimer != null) {
689 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
690 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
691 }
692
693 if (isAnnouncementGroup != null) {
694 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
695 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
696 }
697
698 if (name != null || description != null || avatarFile != null) {
699 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
700 if (avatarFile != null) {
701 context.getAvatarStore()
702 .storeGroupAvatar(group.getGroupId(),
703 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
704 }
705 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
706 }
707
708 return result;
709 }
710
711 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
712 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
713 .withId(groupInfoV1.getGroupId().serialize())
714 .build();
715
716 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
717 groupInfoV1.removeMember(account.getSelfRecipientId());
718 account.getGroupStore().updateGroup(groupInfoV1);
719 return sendGroupMessage(messageBuilder,
720 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
721 groupInfoV1.getDistributionId());
722 }
723
724 private SendGroupMessageResults quitGroupV2(
725 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
726 ) throws LastGroupAdminException, IOException {
727 final var currentAdmins = groupInfoV2.getAdminMembers();
728 newAdmins.removeAll(currentAdmins);
729 newAdmins.retainAll(groupInfoV2.getMembers());
730 if (currentAdmins.contains(account.getSelfRecipientId())
731 && currentAdmins.size() == 1
732 && groupInfoV2.getMembers().size() > 1
733 && newAdmins.size() == 0) {
734 // Last admin can't leave the group, unless she's also the last member
735 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
736 }
737 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
738 groupInfoV2.setGroup(groupGroupChangePair.first());
739 account.getGroupStore().updateGroup(groupInfoV2);
740
741 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
742 return sendGroupMessage(messageBuilder,
743 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
744 groupInfoV2.getDistributionId());
745 }
746
747 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
748 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
749 .withId(g.getGroupId().serialize())
750 .withName(g.name)
751 .withMembers(g.getMembers()
752 .stream()
753 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
754 .toList());
755
756 try {
757 final var attachment = createGroupAvatarAttachment(g.getGroupId());
758 attachment.ifPresent(group::withAvatar);
759 } catch (IOException e) {
760 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
761 }
762
763 return SignalServiceDataMessage.newBuilder()
764 .asGroupMessage(group.build())
765 .withExpiration(g.getMessageExpirationTimer());
766 }
767
768 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
769 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
770 .withRevision(g.getGroup().getRevision())
771 .withSignedGroupChange(signedGroupChange);
772 return SignalServiceDataMessage.newBuilder()
773 .asGroupMessage(group.build())
774 .withExpiration(g.getMessageExpirationTimer());
775 }
776
777 private SendGroupMessageResults sendUpdateGroupV2Message(
778 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
779 ) throws IOException {
780 final var selfRecipientId = account.getSelfRecipientId();
781 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
782 group.setGroup(newDecryptedGroup);
783 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
784 account.getGroupStore().updateGroup(group);
785
786 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
787 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
788 }
789
790 private SendGroupMessageResults sendGroupMessage(
791 final SignalServiceDataMessage.Builder messageBuilder,
792 final Set<RecipientId> members,
793 final DistributionId distributionId
794 ) throws IOException {
795 final var timestamp = System.currentTimeMillis();
796 messageBuilder.withTimestamp(timestamp);
797 final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
798 return new SendGroupMessageResults(timestamp,
799 results.stream()
800 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
801 account.getRecipientResolver(),
802 account.getRecipientAddressResolver()))
803 .toList());
804 }
805 }