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