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