]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Replace collect(Collectors.toList()) with toList()
[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.AttachmentInvalidException;
4 import org.asamk.signal.manager.AvatarStore;
5 import org.asamk.signal.manager.SignalDependencies;
6 import org.asamk.signal.manager.api.InactiveGroupLinkException;
7 import org.asamk.signal.manager.api.Pair;
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.storage.recipients.RecipientResolver;
28 import org.asamk.signal.manager.util.AttachmentUtils;
29 import org.asamk.signal.manager.util.IOUtils;
30 import org.signal.storageservice.protos.groups.GroupChange;
31 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
32 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
33 import org.signal.zkgroup.InvalidInputException;
34 import org.signal.zkgroup.groups.GroupMasterKey;
35 import org.signal.zkgroup.groups.GroupSecretParams;
36 import org.signal.zkgroup.profiles.ProfileKey;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39 import org.whispersystems.libsignal.util.guava.Optional;
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.ACI;
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.HashSet;
56 import java.util.List;
57 import java.util.Set;
58
59 public class GroupHelper {
60
61 private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
62
63 private final SignalAccount account;
64 private final SignalDependencies dependencies;
65 private final AttachmentHelper attachmentHelper;
66 private final SendHelper sendHelper;
67 private final GroupV2Helper groupV2Helper;
68 private final AvatarStore avatarStore;
69 private final SignalServiceAddressResolver addressResolver;
70 private final RecipientResolver recipientResolver;
71
72 public GroupHelper(
73 final SignalAccount account,
74 final SignalDependencies dependencies,
75 final AttachmentHelper attachmentHelper,
76 final SendHelper sendHelper,
77 final GroupV2Helper groupV2Helper,
78 final AvatarStore avatarStore,
79 final SignalServiceAddressResolver addressResolver,
80 final RecipientResolver recipientResolver
81 ) {
82 this.account = account;
83 this.dependencies = dependencies;
84 this.attachmentHelper = attachmentHelper;
85 this.sendHelper = sendHelper;
86 this.groupV2Helper = groupV2Helper;
87 this.avatarStore = avatarStore;
88 this.addressResolver = addressResolver;
89 this.recipientResolver = recipientResolver;
90 }
91
92 public GroupInfo getGroup(GroupId groupId) {
93 return getGroup(groupId, false);
94 }
95
96 public boolean isGroupBlocked(final GroupId groupId) {
97 var group = getGroup(groupId);
98 return group != null && group.isBlocked();
99 }
100
101 public void downloadGroupAvatar(GroupIdV1 groupId, SignalServiceAttachment avatar) {
102 try {
103 avatarStore.storeGroupAvatar(groupId,
104 outputStream -> attachmentHelper.retrieveAttachment(avatar, outputStream));
105 } catch (IOException e) {
106 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
107 }
108 }
109
110 public Optional<SignalServiceAttachmentStream> createGroupAvatarAttachment(GroupIdV1 groupId) throws IOException {
111 final var streamDetails = avatarStore.retrieveGroupAvatar(groupId);
112 if (streamDetails == null) {
113 return Optional.absent();
114 }
115
116 return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent()));
117 }
118
119 public GroupInfoV2 getOrMigrateGroup(
120 final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
121 ) {
122 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
123
124 var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
125 var groupInfo = getGroup(groupId);
126 final GroupInfoV2 groupInfoV2;
127 if (groupInfo instanceof GroupInfoV1) {
128 // Received a v2 group message for a v1 group, we need to locally migrate the group
129 account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId());
130 groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
131 logger.info("Locally migrated group {} to group v2, id: {}",
132 groupInfo.getGroupId().toBase64(),
133 groupInfoV2.getGroupId().toBase64());
134 } else if (groupInfo instanceof GroupInfoV2) {
135 groupInfoV2 = (GroupInfoV2) groupInfo;
136 } else {
137 groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
138 }
139
140 if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) {
141 DecryptedGroup group = null;
142 if (signedGroupChange != null
143 && groupInfoV2.getGroup() != null
144 && groupInfoV2.getGroup().getRevision() + 1 == revision) {
145 group = groupV2Helper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(),
146 signedGroupChange,
147 groupMasterKey);
148 }
149 if (group == null) {
150 try {
151 group = groupV2Helper.getDecryptedGroup(groupSecretParams);
152 } catch (NotAGroupMemberException ignored) {
153 }
154 }
155 if (group != null) {
156 storeProfileKeysFromMembers(group);
157 final var avatar = group.getAvatar();
158 if (avatar != null && !avatar.isEmpty()) {
159 downloadGroupAvatar(groupId, groupSecretParams, avatar);
160 }
161 }
162 groupInfoV2.setGroup(group, recipientResolver);
163 account.getGroupStore().updateGroup(groupInfoV2);
164 }
165
166 return groupInfoV2;
167 }
168
169 public Pair<GroupId, SendGroupMessageResults> createGroup(
170 String name, Set<RecipientId> members, File avatarFile
171 ) throws IOException, AttachmentInvalidException {
172 final var selfRecipientId = account.getSelfRecipientId();
173 if (members != null && members.contains(selfRecipientId)) {
174 members = new HashSet<>(members);
175 members.remove(selfRecipientId);
176 }
177
178 var gv2Pair = groupV2Helper.createGroup(name == null ? "" : name,
179 members == null ? Set.of() : members,
180 avatarFile);
181
182 if (gv2Pair == null) {
183 // Failed to create v2 group, creating v1 group instead
184 var gv1 = new GroupInfoV1(GroupIdV1.createRandom());
185 gv1.addMembers(List.of(selfRecipientId));
186 final var result = updateGroupV1(gv1, name, members, avatarFile);
187 return new Pair<>(gv1.getGroupId(), result);
188 }
189
190 final var gv2 = gv2Pair.first();
191 final var decryptedGroup = gv2Pair.second();
192
193 gv2.setGroup(decryptedGroup, recipientResolver);
194 if (avatarFile != null) {
195 avatarStore.storeGroupAvatar(gv2.getGroupId(),
196 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
197 }
198
199 account.getGroupStore().updateGroup(gv2);
200
201 final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null);
202
203 final var result = sendGroupMessage(messageBuilder, gv2.getMembersIncludingPendingWithout(selfRecipientId));
204 return new Pair<>(gv2.getGroupId(), result);
205 }
206
207 public SendGroupMessageResults updateGroup(
208 final GroupId groupId,
209 final String name,
210 final String description,
211 final Set<RecipientId> members,
212 final Set<RecipientId> removeMembers,
213 final Set<RecipientId> admins,
214 final Set<RecipientId> removeAdmins,
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 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 resetGroupLink,
252 groupLinkState,
253 addMemberPermission,
254 editDetailsPermission,
255 avatarFile,
256 expirationTimer,
257 isAnnouncementGroup);
258 }
259 }
260
261 final var gv1 = (GroupInfoV1) group;
262 final var result = updateGroupV1(gv1, name, members, avatarFile);
263 if (expirationTimer != null) {
264 setExpirationTimer(gv1, expirationTimer);
265 }
266 return result;
267 }
268
269 public Pair<GroupId, SendGroupMessageResults> joinGroup(
270 GroupInviteLinkUrl inviteLinkUrl
271 ) throws IOException, InactiveGroupLinkException {
272 final DecryptedGroupJoinInfo groupJoinInfo;
273 try {
274 groupJoinInfo = groupV2Helper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(),
275 inviteLinkUrl.getPassword());
276 } catch (GroupLinkNotActiveException e) {
277 throw new InactiveGroupLinkException("Group link inactive", e);
278 }
279 final var groupChange = groupV2Helper.joinGroup(inviteLinkUrl.getGroupMasterKey(),
280 inviteLinkUrl.getPassword(),
281 groupJoinInfo);
282 final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
283 groupJoinInfo.getRevision() + 1,
284 groupChange.toByteArray());
285
286 if (group.getGroup() == null) {
287 // Only requested member, can't send update to group members
288 return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of()));
289 }
290
291 final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
292
293 return new Pair<>(group.getGroupId(), result);
294 }
295
296 public SendGroupMessageResults quitGroup(
297 final GroupId groupId, final Set<RecipientId> newAdmins
298 ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
299 var group = getGroupForUpdating(groupId);
300 if (group instanceof GroupInfoV1) {
301 return quitGroupV1((GroupInfoV1) group);
302 }
303
304 try {
305 return quitGroupV2((GroupInfoV2) group, newAdmins);
306 } catch (ConflictException e) {
307 // Detected conflicting update, refreshing group and trying again
308 group = getGroup(groupId, true);
309 return quitGroupV2((GroupInfoV2) group, newAdmins);
310 }
311 }
312
313 public void deleteGroup(GroupId groupId) throws IOException {
314 account.getGroupStore().deleteGroup(groupId);
315 avatarStore.deleteGroupAvatar(groupId);
316 }
317
318 public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
319 var group = getGroup(groupId);
320 if (group == null) {
321 throw new GroupNotFoundException(groupId);
322 }
323
324 group.setBlocked(blocked);
325 account.getGroupStore().updateGroup(group);
326 }
327
328 public SendGroupMessageResults sendGroupInfoRequest(
329 GroupIdV1 groupId, RecipientId recipientId
330 ) throws IOException {
331 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
332
333 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
334
335 // Send group info request message to the recipient who sent us a message with this groupId
336 return sendGroupMessage(messageBuilder, Set.of(recipientId));
337 }
338
339 public SendGroupMessageResults sendGroupInfoMessage(
340 GroupIdV1 groupId, RecipientId recipientId
341 ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
342 GroupInfoV1 g;
343 var group = getGroupForUpdating(groupId);
344 if (!(group instanceof GroupInfoV1)) {
345 throw new IOException("Received an invalid group request for a v2 group!");
346 }
347 g = (GroupInfoV1) group;
348
349 if (!g.isMember(recipientId)) {
350 throw new NotAGroupMemberException(groupId, g.name);
351 }
352
353 var messageBuilder = getGroupUpdateMessageBuilder(g);
354
355 // Send group message only to the recipient who requested it
356 return sendGroupMessage(messageBuilder, Set.of(recipientId));
357 }
358
359 private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
360 final var group = account.getGroupStore().getGroup(groupId);
361 if (group instanceof GroupInfoV2 groupInfoV2) {
362 if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) {
363 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
364 DecryptedGroup decryptedGroup;
365 try {
366 decryptedGroup = groupV2Helper.getDecryptedGroup(groupSecretParams);
367 } catch (NotAGroupMemberException e) {
368 groupInfoV2.setPermissionDenied(true);
369 decryptedGroup = null;
370 }
371 groupInfoV2.setGroup(decryptedGroup, recipientResolver);
372 account.getGroupStore().updateGroup(group);
373 }
374 }
375 return group;
376 }
377
378 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
379 try {
380 avatarStore.storeGroupAvatar(groupId,
381 outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
382 } catch (IOException e) {
383 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
384 }
385 }
386
387 private void retrieveGroupV2Avatar(
388 GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
389 ) throws IOException {
390 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
391
392 var tmpFile = IOUtils.createTempFile();
393 try (InputStream input = dependencies.getMessageReceiver()
394 .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
395 var encryptedData = IOUtils.readFully(input);
396
397 var decryptedData = groupOperations.decryptAvatar(encryptedData);
398 outputStream.write(decryptedData);
399 } finally {
400 try {
401 Files.delete(tmpFile.toPath());
402 } catch (IOException e) {
403 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
404 tmpFile,
405 e.getMessage());
406 }
407 }
408 }
409
410 private void storeProfileKeysFromMembers(final DecryptedGroup group) {
411 for (var member : group.getMembersList()) {
412 final var aci = ACI.fromByteString(member.getUuid());
413 final var recipientId = account.getRecipientStore().resolveRecipient(aci);
414 try {
415 account.getProfileStore()
416 .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray()));
417 } catch (InvalidInputException ignored) {
418 }
419 }
420 }
421
422 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
423 var g = getGroup(groupId);
424 if (g == null) {
425 throw new GroupNotFoundException(groupId);
426 }
427 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
428 throw new NotAGroupMemberException(groupId, g.getTitle());
429 }
430 return g;
431 }
432
433 private SendGroupMessageResults updateGroupV1(
434 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
435 ) throws IOException, AttachmentInvalidException {
436 updateGroupV1Details(gv1, name, members, avatarFile);
437
438 account.getGroupStore().updateGroup(gv1);
439
440 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
441 return sendGroupMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
442 }
443
444 private void updateGroupV1Details(
445 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
446 ) throws IOException {
447 if (name != null) {
448 g.name = name;
449 }
450
451 if (members != null) {
452 g.addMembers(members);
453 }
454
455 if (avatarFile != null) {
456 avatarStore.storeGroupAvatar(g.getGroupId(),
457 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
458 }
459 }
460
461 /**
462 * Change the expiration timer for a group
463 */
464 private void setExpirationTimer(
465 GroupInfoV1 groupInfoV1, int messageExpirationTimer
466 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
467 groupInfoV1.messageExpirationTime = messageExpirationTimer;
468 account.getGroupStore().updateGroup(groupInfoV1);
469 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
470 }
471
472 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
473 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
474 sendHelper.sendAsGroupMessage(messageBuilder, groupId);
475 }
476
477 private SendGroupMessageResults updateGroupV2(
478 final GroupInfoV2 group,
479 final String name,
480 final String description,
481 final Set<RecipientId> members,
482 final Set<RecipientId> removeMembers,
483 final Set<RecipientId> admins,
484 final Set<RecipientId> removeAdmins,
485 final boolean resetGroupLink,
486 final GroupLinkState groupLinkState,
487 final GroupPermission addMemberPermission,
488 final GroupPermission editDetailsPermission,
489 final File avatarFile,
490 final Integer expirationTimer,
491 final Boolean isAnnouncementGroup
492 ) throws IOException {
493 SendGroupMessageResults result = null;
494 if (group.isPendingMember(account.getSelfRecipientId())) {
495 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
496 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
497 }
498
499 if (members != null) {
500 final var newMembers = new HashSet<>(members);
501 newMembers.removeAll(group.getMembers());
502 if (newMembers.size() > 0) {
503 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
504 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
505 }
506 }
507
508 if (removeMembers != null) {
509 var existingRemoveMembers = new HashSet<>(removeMembers);
510 existingRemoveMembers.retainAll(group.getMembers());
511 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
512 if (existingRemoveMembers.size() > 0) {
513 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
514 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
515 }
516
517 var pendingRemoveMembers = new HashSet<>(removeMembers);
518 pendingRemoveMembers.retainAll(group.getPendingMembers());
519 if (pendingRemoveMembers.size() > 0) {
520 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
521 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
522 }
523 }
524
525 if (admins != null) {
526 final var newAdmins = new HashSet<>(admins);
527 newAdmins.retainAll(group.getMembers());
528 newAdmins.removeAll(group.getAdminMembers());
529 if (newAdmins.size() > 0) {
530 for (var admin : newAdmins) {
531 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
532 result = sendUpdateGroupV2Message(group,
533 groupGroupChangePair.first(),
534 groupGroupChangePair.second());
535 }
536 }
537 }
538
539 if (removeAdmins != null) {
540 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
541 existingRemoveAdmins.retainAll(group.getAdminMembers());
542 if (existingRemoveAdmins.size() > 0) {
543 for (var admin : existingRemoveAdmins) {
544 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
545 result = sendUpdateGroupV2Message(group,
546 groupGroupChangePair.first(),
547 groupGroupChangePair.second());
548 }
549 }
550 }
551
552 if (resetGroupLink) {
553 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
554 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
555 }
556
557 if (groupLinkState != null) {
558 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
559 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
560 }
561
562 if (addMemberPermission != null) {
563 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
564 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
565 }
566
567 if (editDetailsPermission != null) {
568 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
569 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
570 }
571
572 if (expirationTimer != null) {
573 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
574 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
575 }
576
577 if (isAnnouncementGroup != null) {
578 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
579 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
580 }
581
582 if (name != null || description != null || avatarFile != null) {
583 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
584 if (avatarFile != null) {
585 avatarStore.storeGroupAvatar(group.getGroupId(),
586 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
587 }
588 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
589 }
590
591 return result;
592 }
593
594 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
595 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
596 .withId(groupInfoV1.getGroupId().serialize())
597 .build();
598
599 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
600 groupInfoV1.removeMember(account.getSelfRecipientId());
601 account.getGroupStore().updateGroup(groupInfoV1);
602 return sendGroupMessage(messageBuilder,
603 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
604 }
605
606 private SendGroupMessageResults quitGroupV2(
607 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
608 ) throws LastGroupAdminException, IOException {
609 final var currentAdmins = groupInfoV2.getAdminMembers();
610 newAdmins.removeAll(currentAdmins);
611 newAdmins.retainAll(groupInfoV2.getMembers());
612 if (currentAdmins.contains(account.getSelfRecipientId())
613 && currentAdmins.size() == 1
614 && groupInfoV2.getMembers().size() > 1
615 && newAdmins.size() == 0) {
616 // Last admin can't leave the group, unless she's also the last member
617 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
618 }
619 final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins);
620 groupInfoV2.setGroup(groupGroupChangePair.first(), recipientResolver);
621 account.getGroupStore().updateGroup(groupInfoV2);
622
623 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
624 return sendGroupMessage(messageBuilder,
625 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
626 }
627
628 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
629 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
630 .withId(g.getGroupId().serialize())
631 .withName(g.name)
632 .withMembers(g.getMembers().stream().map(addressResolver::resolveSignalServiceAddress).toList());
633
634 try {
635 final var attachment = createGroupAvatarAttachment(g.getGroupId());
636 if (attachment.isPresent()) {
637 group.withAvatar(attachment.get());
638 }
639 } catch (IOException e) {
640 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
641 }
642
643 return SignalServiceDataMessage.newBuilder()
644 .asGroupMessage(group.build())
645 .withExpiration(g.getMessageExpirationTimer());
646 }
647
648 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
649 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
650 .withRevision(g.getGroup().getRevision())
651 .withSignedGroupChange(signedGroupChange);
652 return SignalServiceDataMessage.newBuilder()
653 .asGroupMessage(group.build())
654 .withExpiration(g.getMessageExpirationTimer());
655 }
656
657 private SendGroupMessageResults sendUpdateGroupV2Message(
658 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
659 ) throws IOException {
660 final var selfRecipientId = account.getSelfRecipientId();
661 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
662 group.setGroup(newDecryptedGroup, recipientResolver);
663 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
664 account.getGroupStore().updateGroup(group);
665
666 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
667 return sendGroupMessage(messageBuilder, members);
668 }
669
670 private SendGroupMessageResults sendGroupMessage(
671 final SignalServiceDataMessage.Builder messageBuilder, final Set<RecipientId> members
672 ) throws IOException {
673 final var timestamp = System.currentTimeMillis();
674 messageBuilder.withTimestamp(timestamp);
675 final var results = sendHelper.sendGroupMessage(messageBuilder.build(), members);
676 return new SendGroupMessageResults(timestamp,
677 results.stream()
678 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
679 recipientResolver,
680 account.getRecipientStore()::resolveRecipientAddress))
681 .toList());
682 }
683 }