]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Implement refuse group join requests
[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 var requestingRemoveMembers = new HashSet<>(removeMembers);
622 requestingRemoveMembers.retainAll(group.getRequestingMembers());
623 if (requestingRemoveMembers.size() > 0) {
624 var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers);
625 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
626 }
627 }
628
629 if (admins != null) {
630 final var newAdmins = new HashSet<>(admins);
631 newAdmins.retainAll(group.getMembers());
632 newAdmins.removeAll(group.getAdminMembers());
633 if (newAdmins.size() > 0) {
634 for (var admin : newAdmins) {
635 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
636 result = sendUpdateGroupV2Message(group,
637 groupGroupChangePair.first(),
638 groupGroupChangePair.second());
639 }
640 }
641 }
642
643 if (removeAdmins != null) {
644 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
645 existingRemoveAdmins.retainAll(group.getAdminMembers());
646 if (existingRemoveAdmins.size() > 0) {
647 for (var admin : existingRemoveAdmins) {
648 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
649 result = sendUpdateGroupV2Message(group,
650 groupGroupChangePair.first(),
651 groupGroupChangePair.second());
652 }
653 }
654 }
655
656 if (banMembers != null) {
657 final var newlyBannedMembers = new HashSet<>(banMembers);
658 newlyBannedMembers.removeAll(group.getBannedMembers());
659 if (newlyBannedMembers.size() > 0) {
660 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
661 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
662 }
663 }
664
665 if (unbanMembers != null) {
666 var existingUnbanMembers = new HashSet<>(unbanMembers);
667 existingUnbanMembers.retainAll(group.getBannedMembers());
668 if (existingUnbanMembers.size() > 0) {
669 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
670 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
671 }
672 }
673
674 if (resetGroupLink) {
675 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
676 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
677 }
678
679 if (groupLinkState != null) {
680 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
681 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
682 }
683
684 if (addMemberPermission != null) {
685 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
686 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
687 }
688
689 if (editDetailsPermission != null) {
690 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
691 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
692 }
693
694 if (expirationTimer != null) {
695 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
696 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
697 }
698
699 if (isAnnouncementGroup != null) {
700 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
701 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
702 }
703
704 if (name != null || description != null || avatarFile != null) {
705 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
706 if (avatarFile != null) {
707 context.getAvatarStore()
708 .storeGroupAvatar(group.getGroupId(),
709 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
710 }
711 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
712 }
713
714 return result;
715 }
716
717 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
718 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
719 .withId(groupInfoV1.getGroupId().serialize())
720 .build();
721
722 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
723 groupInfoV1.removeMember(account.getSelfRecipientId());
724 account.getGroupStore().updateGroup(groupInfoV1);
725 return sendGroupMessage(messageBuilder,
726 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
727 groupInfoV1.getDistributionId());
728 }
729
730 private SendGroupMessageResults quitGroupV2(
731 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
732 ) throws LastGroupAdminException, IOException {
733 final var currentAdmins = groupInfoV2.getAdminMembers();
734 newAdmins.removeAll(currentAdmins);
735 newAdmins.retainAll(groupInfoV2.getMembers());
736 if (currentAdmins.contains(account.getSelfRecipientId())
737 && currentAdmins.size() == 1
738 && groupInfoV2.getMembers().size() > 1
739 && newAdmins.size() == 0) {
740 // Last admin can't leave the group, unless she's also the last member
741 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
742 }
743 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
744 groupInfoV2.setGroup(groupGroupChangePair.first());
745 account.getGroupStore().updateGroup(groupInfoV2);
746
747 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
748 return sendGroupMessage(messageBuilder,
749 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
750 groupInfoV2.getDistributionId());
751 }
752
753 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
754 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
755 .withId(g.getGroupId().serialize())
756 .withName(g.name)
757 .withMembers(g.getMembers()
758 .stream()
759 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
760 .toList());
761
762 try {
763 final var attachment = createGroupAvatarAttachment(g.getGroupId());
764 attachment.ifPresent(group::withAvatar);
765 } catch (IOException e) {
766 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
767 }
768
769 return SignalServiceDataMessage.newBuilder()
770 .asGroupMessage(group.build())
771 .withExpiration(g.getMessageExpirationTimer());
772 }
773
774 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
775 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
776 .withRevision(g.getGroup().getRevision())
777 .withSignedGroupChange(signedGroupChange);
778 return SignalServiceDataMessage.newBuilder()
779 .asGroupMessage(group.build())
780 .withExpiration(g.getMessageExpirationTimer());
781 }
782
783 private SendGroupMessageResults sendUpdateGroupV2Message(
784 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
785 ) throws IOException {
786 final var selfRecipientId = account.getSelfRecipientId();
787 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
788 group.setGroup(newDecryptedGroup);
789 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
790 account.getGroupStore().updateGroup(group);
791
792 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
793 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
794 }
795
796 private SendGroupMessageResults sendGroupMessage(
797 final SignalServiceDataMessage.Builder messageBuilder,
798 final Set<RecipientId> members,
799 final DistributionId distributionId
800 ) throws IOException {
801 final var timestamp = System.currentTimeMillis();
802 messageBuilder.withTimestamp(timestamp);
803 final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
804 return new SendGroupMessageResults(timestamp,
805 results.stream()
806 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
807 account.getRecipientResolver(),
808 account.getRecipientAddressResolver()))
809 .toList());
810 }
811 }