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