]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
ee2e94166ccbb55b23c7cb33402038d7f22924cd
[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) {
355 final var groupInfoV2 = (GroupInfoV2) group;
356 if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) {
357 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
358 DecryptedGroup decryptedGroup;
359 try {
360 decryptedGroup = groupV2Helper.getDecryptedGroup(groupSecretParams);
361 } catch (NotAGroupMemberException e) {
362 groupInfoV2.setPermissionDenied(true);
363 decryptedGroup = null;
364 }
365 groupInfoV2.setGroup(decryptedGroup, recipientResolver);
366 account.getGroupStore().updateGroup(group);
367 }
368 }
369 return group;
370 }
371
372 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
373 try {
374 avatarStore.storeGroupAvatar(groupId,
375 outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
376 } catch (IOException e) {
377 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
378 }
379 }
380
381 private void retrieveGroupV2Avatar(
382 GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
383 ) throws IOException {
384 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
385
386 var tmpFile = IOUtils.createTempFile();
387 try (InputStream input = dependencies.getMessageReceiver()
388 .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
389 var encryptedData = IOUtils.readFully(input);
390
391 var decryptedData = groupOperations.decryptAvatar(encryptedData);
392 outputStream.write(decryptedData);
393 } finally {
394 try {
395 Files.delete(tmpFile.toPath());
396 } catch (IOException e) {
397 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
398 tmpFile,
399 e.getMessage());
400 }
401 }
402 }
403
404 private void storeProfileKeysFromMembers(final DecryptedGroup group) {
405 for (var member : group.getMembersList()) {
406 final var uuid = UuidUtil.parseOrThrow(member.getUuid().toByteArray());
407 final var recipientId = account.getRecipientStore().resolveRecipient(uuid);
408 try {
409 account.getProfileStore()
410 .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray()));
411 } catch (InvalidInputException ignored) {
412 }
413 }
414 }
415
416 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
417 var g = getGroup(groupId);
418 if (g == null) {
419 throw new GroupNotFoundException(groupId);
420 }
421 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
422 throw new NotAGroupMemberException(groupId, g.getTitle());
423 }
424 return g;
425 }
426
427 private SendGroupMessageResults updateGroupV1(
428 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
429 ) throws IOException, AttachmentInvalidException {
430 updateGroupV1Details(gv1, name, members, avatarFile);
431
432 account.getGroupStore().updateGroup(gv1);
433
434 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
435 return sendGroupMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
436 }
437
438 private void updateGroupV1Details(
439 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
440 ) throws IOException {
441 if (name != null) {
442 g.name = name;
443 }
444
445 if (members != null) {
446 g.addMembers(members);
447 }
448
449 if (avatarFile != null) {
450 avatarStore.storeGroupAvatar(g.getGroupId(),
451 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
452 }
453 }
454
455 /**
456 * Change the expiration timer for a group
457 */
458 private void setExpirationTimer(
459 GroupInfoV1 groupInfoV1, int messageExpirationTimer
460 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
461 groupInfoV1.messageExpirationTime = messageExpirationTimer;
462 account.getGroupStore().updateGroup(groupInfoV1);
463 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
464 }
465
466 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
467 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
468 sendHelper.sendAsGroupMessage(messageBuilder, groupId);
469 }
470
471 private SendGroupMessageResults updateGroupV2(
472 final GroupInfoV2 group,
473 final String name,
474 final String description,
475 final Set<RecipientId> members,
476 final Set<RecipientId> removeMembers,
477 final Set<RecipientId> admins,
478 final Set<RecipientId> removeAdmins,
479 final boolean resetGroupLink,
480 final GroupLinkState groupLinkState,
481 final GroupPermission addMemberPermission,
482 final GroupPermission editDetailsPermission,
483 final File avatarFile,
484 final Integer expirationTimer,
485 final Boolean isAnnouncementGroup
486 ) throws IOException {
487 SendGroupMessageResults result = null;
488 if (group.isPendingMember(account.getSelfRecipientId())) {
489 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
490 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
491 }
492
493 if (members != null) {
494 final var newMembers = new HashSet<>(members);
495 newMembers.removeAll(group.getMembers());
496 if (newMembers.size() > 0) {
497 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
498 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
499 }
500 }
501
502 if (removeMembers != null) {
503 var existingRemoveMembers = new HashSet<>(removeMembers);
504 existingRemoveMembers.retainAll(group.getMembers());
505 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
506 if (existingRemoveMembers.size() > 0) {
507 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
508 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
509 }
510
511 var pendingRemoveMembers = new HashSet<>(removeMembers);
512 pendingRemoveMembers.retainAll(group.getPendingMembers());
513 if (pendingRemoveMembers.size() > 0) {
514 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
515 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
516 }
517 }
518
519 if (admins != null) {
520 final var newAdmins = new HashSet<>(admins);
521 newAdmins.retainAll(group.getMembers());
522 newAdmins.removeAll(group.getAdminMembers());
523 if (newAdmins.size() > 0) {
524 for (var admin : newAdmins) {
525 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
526 result = sendUpdateGroupV2Message(group,
527 groupGroupChangePair.first(),
528 groupGroupChangePair.second());
529 }
530 }
531 }
532
533 if (removeAdmins != null) {
534 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
535 existingRemoveAdmins.retainAll(group.getAdminMembers());
536 if (existingRemoveAdmins.size() > 0) {
537 for (var admin : existingRemoveAdmins) {
538 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
539 result = sendUpdateGroupV2Message(group,
540 groupGroupChangePair.first(),
541 groupGroupChangePair.second());
542 }
543 }
544 }
545
546 if (resetGroupLink) {
547 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
548 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
549 }
550
551 if (groupLinkState != null) {
552 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
553 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
554 }
555
556 if (addMemberPermission != null) {
557 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
558 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
559 }
560
561 if (editDetailsPermission != null) {
562 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
563 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
564 }
565
566 if (expirationTimer != null) {
567 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
568 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
569 }
570
571 if (isAnnouncementGroup != null) {
572 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
573 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
574 }
575
576 if (name != null || description != null || avatarFile != null) {
577 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
578 if (avatarFile != null) {
579 avatarStore.storeGroupAvatar(group.getGroupId(),
580 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
581 }
582 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
583 }
584
585 return result;
586 }
587
588 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
589 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
590 .withId(groupInfoV1.getGroupId().serialize())
591 .build();
592
593 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
594 groupInfoV1.removeMember(account.getSelfRecipientId());
595 account.getGroupStore().updateGroup(groupInfoV1);
596 return sendGroupMessage(messageBuilder,
597 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
598 }
599
600 private SendGroupMessageResults quitGroupV2(
601 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
602 ) throws LastGroupAdminException, IOException {
603 final var currentAdmins = groupInfoV2.getAdminMembers();
604 newAdmins.removeAll(currentAdmins);
605 newAdmins.retainAll(groupInfoV2.getMembers());
606 if (currentAdmins.contains(account.getSelfRecipientId())
607 && currentAdmins.size() == 1
608 && groupInfoV2.getMembers().size() > 1
609 && newAdmins.size() == 0) {
610 // Last admin can't leave the group, unless she's also the last member
611 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
612 }
613 final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins);
614 groupInfoV2.setGroup(groupGroupChangePair.first(), recipientResolver);
615 account.getGroupStore().updateGroup(groupInfoV2);
616
617 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
618 return sendGroupMessage(messageBuilder,
619 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
620 }
621
622 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
623 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
624 .withId(g.getGroupId().serialize())
625 .withName(g.name)
626 .withMembers(g.getMembers()
627 .stream()
628 .map(addressResolver::resolveSignalServiceAddress)
629 .collect(Collectors.toList()));
630
631 try {
632 final var attachment = createGroupAvatarAttachment(g.getGroupId());
633 if (attachment.isPresent()) {
634 group.withAvatar(attachment.get());
635 }
636 } catch (IOException e) {
637 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
638 }
639
640 return SignalServiceDataMessage.newBuilder()
641 .asGroupMessage(group.build())
642 .withExpiration(g.getMessageExpirationTimer());
643 }
644
645 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
646 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
647 .withRevision(g.getGroup().getRevision())
648 .withSignedGroupChange(signedGroupChange);
649 return SignalServiceDataMessage.newBuilder()
650 .asGroupMessage(group.build())
651 .withExpiration(g.getMessageExpirationTimer());
652 }
653
654 private SendGroupMessageResults sendUpdateGroupV2Message(
655 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
656 ) throws IOException {
657 final var selfRecipientId = account.getSelfRecipientId();
658 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
659 group.setGroup(newDecryptedGroup, recipientResolver);
660 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
661 account.getGroupStore().updateGroup(group);
662
663 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
664 return sendGroupMessage(messageBuilder, members);
665 }
666
667 private SendGroupMessageResults sendGroupMessage(
668 final SignalServiceDataMessage.Builder messageBuilder, final Set<RecipientId> members
669 ) throws IOException {
670 final var timestamp = System.currentTimeMillis();
671 messageBuilder.withTimestamp(timestamp);
672 final var results = sendHelper.sendGroupMessage(messageBuilder.build(), members);
673 return new SendGroupMessageResults(timestamp, results);
674 }
675 }