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