]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Remove libsignal-service from manager lib API
[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.exceptions.ConflictException;
47 import org.whispersystems.signalservice.api.util.UuidUtil;
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 import java.util.stream.Collectors;
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, gv2.getMembersIncludingPendingWithout(selfRecipientId));
205 return new Pair<>(gv2.getGroupId(), result);
206 }
207
208 public SendGroupMessageResults updateGroup(
209 final GroupId groupId,
210 final String name,
211 final String description,
212 final Set<RecipientId> members,
213 final Set<RecipientId> removeMembers,
214 final Set<RecipientId> admins,
215 final Set<RecipientId> removeAdmins,
216 final boolean resetGroupLink,
217 final GroupLinkState groupLinkState,
218 final GroupPermission addMemberPermission,
219 final GroupPermission editDetailsPermission,
220 final File avatarFile,
221 final Integer expirationTimer,
222 final Boolean isAnnouncementGroup
223 ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
224 var group = getGroupForUpdating(groupId);
225
226 if (group instanceof GroupInfoV2) {
227 try {
228 return updateGroupV2((GroupInfoV2) group,
229 name,
230 description,
231 members,
232 removeMembers,
233 admins,
234 removeAdmins,
235 resetGroupLink,
236 groupLinkState,
237 addMemberPermission,
238 editDetailsPermission,
239 avatarFile,
240 expirationTimer,
241 isAnnouncementGroup);
242 } catch (ConflictException e) {
243 // Detected conflicting update, refreshing group and trying again
244 group = getGroup(groupId, true);
245 return updateGroupV2((GroupInfoV2) group,
246 name,
247 description,
248 members,
249 removeMembers,
250 admins,
251 removeAdmins,
252 resetGroupLink,
253 groupLinkState,
254 addMemberPermission,
255 editDetailsPermission,
256 avatarFile,
257 expirationTimer,
258 isAnnouncementGroup);
259 }
260 }
261
262 final var gv1 = (GroupInfoV1) group;
263 final var result = updateGroupV1(gv1, name, members, avatarFile);
264 if (expirationTimer != null) {
265 setExpirationTimer(gv1, expirationTimer);
266 }
267 return result;
268 }
269
270 public Pair<GroupId, SendGroupMessageResults> joinGroup(
271 GroupInviteLinkUrl inviteLinkUrl
272 ) throws IOException, InactiveGroupLinkException {
273 final DecryptedGroupJoinInfo groupJoinInfo;
274 try {
275 groupJoinInfo = groupV2Helper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(),
276 inviteLinkUrl.getPassword());
277 } catch (GroupLinkNotActiveException e) {
278 throw new InactiveGroupLinkException("Group link inactive", e);
279 }
280 final var groupChange = groupV2Helper.joinGroup(inviteLinkUrl.getGroupMasterKey(),
281 inviteLinkUrl.getPassword(),
282 groupJoinInfo);
283 final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
284 groupJoinInfo.getRevision() + 1,
285 groupChange.toByteArray());
286
287 if (group.getGroup() == null) {
288 // Only requested member, can't send update to group members
289 return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of()));
290 }
291
292 final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
293
294 return new Pair<>(group.getGroupId(), result);
295 }
296
297 public SendGroupMessageResults quitGroup(
298 final GroupId groupId, final Set<RecipientId> newAdmins
299 ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
300 var group = getGroupForUpdating(groupId);
301 if (group instanceof GroupInfoV1) {
302 return quitGroupV1((GroupInfoV1) group);
303 }
304
305 try {
306 return quitGroupV2((GroupInfoV2) group, newAdmins);
307 } catch (ConflictException e) {
308 // Detected conflicting update, refreshing group and trying again
309 group = getGroup(groupId, true);
310 return quitGroupV2((GroupInfoV2) group, newAdmins);
311 }
312 }
313
314 public void deleteGroup(GroupId groupId) throws IOException {
315 account.getGroupStore().deleteGroup(groupId);
316 avatarStore.deleteGroupAvatar(groupId);
317 }
318
319 public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
320 var group = getGroup(groupId);
321 if (group == null) {
322 throw new GroupNotFoundException(groupId);
323 }
324
325 group.setBlocked(blocked);
326 account.getGroupStore().updateGroup(group);
327 }
328
329 public SendGroupMessageResults sendGroupInfoRequest(
330 GroupIdV1 groupId, RecipientId recipientId
331 ) throws IOException {
332 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
333
334 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
335
336 // Send group info request message to the recipient who sent us a message with this groupId
337 return sendGroupMessage(messageBuilder, Set.of(recipientId));
338 }
339
340 public SendGroupMessageResults sendGroupInfoMessage(
341 GroupIdV1 groupId, RecipientId recipientId
342 ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
343 GroupInfoV1 g;
344 var group = getGroupForUpdating(groupId);
345 if (!(group instanceof GroupInfoV1)) {
346 throw new IOException("Received an invalid group request for a v2 group!");
347 }
348 g = (GroupInfoV1) group;
349
350 if (!g.isMember(recipientId)) {
351 throw new NotAGroupMemberException(groupId, g.name);
352 }
353
354 var messageBuilder = getGroupUpdateMessageBuilder(g);
355
356 // Send group message only to the recipient who requested it
357 return sendGroupMessage(messageBuilder, Set.of(recipientId));
358 }
359
360 private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
361 final var group = account.getGroupStore().getGroup(groupId);
362 if (group instanceof GroupInfoV2 groupInfoV2) {
363 if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) {
364 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
365 DecryptedGroup decryptedGroup;
366 try {
367 decryptedGroup = groupV2Helper.getDecryptedGroup(groupSecretParams);
368 } catch (NotAGroupMemberException e) {
369 groupInfoV2.setPermissionDenied(true);
370 decryptedGroup = null;
371 }
372 groupInfoV2.setGroup(decryptedGroup, recipientResolver);
373 account.getGroupStore().updateGroup(group);
374 }
375 }
376 return group;
377 }
378
379 private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
380 try {
381 avatarStore.storeGroupAvatar(groupId,
382 outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
383 } catch (IOException e) {
384 logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
385 }
386 }
387
388 private void retrieveGroupV2Avatar(
389 GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
390 ) throws IOException {
391 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
392
393 var tmpFile = IOUtils.createTempFile();
394 try (InputStream input = dependencies.getMessageReceiver()
395 .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
396 var encryptedData = IOUtils.readFully(input);
397
398 var decryptedData = groupOperations.decryptAvatar(encryptedData);
399 outputStream.write(decryptedData);
400 } finally {
401 try {
402 Files.delete(tmpFile.toPath());
403 } catch (IOException e) {
404 logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
405 tmpFile,
406 e.getMessage());
407 }
408 }
409 }
410
411 private void storeProfileKeysFromMembers(final DecryptedGroup group) {
412 for (var member : group.getMembersList()) {
413 final var uuid = UuidUtil.parseOrThrow(member.getUuid().toByteArray());
414 final var recipientId = account.getRecipientStore().resolveRecipient(uuid);
415 try {
416 account.getProfileStore()
417 .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray()));
418 } catch (InvalidInputException ignored) {
419 }
420 }
421 }
422
423 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
424 var g = getGroup(groupId);
425 if (g == null) {
426 throw new GroupNotFoundException(groupId);
427 }
428 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
429 throw new NotAGroupMemberException(groupId, g.getTitle());
430 }
431 return g;
432 }
433
434 private SendGroupMessageResults updateGroupV1(
435 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
436 ) throws IOException, AttachmentInvalidException {
437 updateGroupV1Details(gv1, name, members, avatarFile);
438
439 account.getGroupStore().updateGroup(gv1);
440
441 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
442 return sendGroupMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
443 }
444
445 private void updateGroupV1Details(
446 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
447 ) throws IOException {
448 if (name != null) {
449 g.name = name;
450 }
451
452 if (members != null) {
453 g.addMembers(members);
454 }
455
456 if (avatarFile != null) {
457 avatarStore.storeGroupAvatar(g.getGroupId(),
458 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
459 }
460 }
461
462 /**
463 * Change the expiration timer for a group
464 */
465 private void setExpirationTimer(
466 GroupInfoV1 groupInfoV1, int messageExpirationTimer
467 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
468 groupInfoV1.messageExpirationTime = messageExpirationTimer;
469 account.getGroupStore().updateGroup(groupInfoV1);
470 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
471 }
472
473 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
474 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
475 sendHelper.sendAsGroupMessage(messageBuilder, groupId);
476 }
477
478 private SendGroupMessageResults updateGroupV2(
479 final GroupInfoV2 group,
480 final String name,
481 final String description,
482 final Set<RecipientId> members,
483 final Set<RecipientId> removeMembers,
484 final Set<RecipientId> admins,
485 final Set<RecipientId> removeAdmins,
486 final boolean resetGroupLink,
487 final GroupLinkState groupLinkState,
488 final GroupPermission addMemberPermission,
489 final GroupPermission editDetailsPermission,
490 final File avatarFile,
491 final Integer expirationTimer,
492 final Boolean isAnnouncementGroup
493 ) throws IOException {
494 SendGroupMessageResults result = null;
495 if (group.isPendingMember(account.getSelfRecipientId())) {
496 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
497 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
498 }
499
500 if (members != null) {
501 final var newMembers = new HashSet<>(members);
502 newMembers.removeAll(group.getMembers());
503 if (newMembers.size() > 0) {
504 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
505 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
506 }
507 }
508
509 if (removeMembers != null) {
510 var existingRemoveMembers = new HashSet<>(removeMembers);
511 existingRemoveMembers.retainAll(group.getMembers());
512 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
513 if (existingRemoveMembers.size() > 0) {
514 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
515 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
516 }
517
518 var pendingRemoveMembers = new HashSet<>(removeMembers);
519 pendingRemoveMembers.retainAll(group.getPendingMembers());
520 if (pendingRemoveMembers.size() > 0) {
521 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
522 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
523 }
524 }
525
526 if (admins != null) {
527 final var newAdmins = new HashSet<>(admins);
528 newAdmins.retainAll(group.getMembers());
529 newAdmins.removeAll(group.getAdminMembers());
530 if (newAdmins.size() > 0) {
531 for (var admin : newAdmins) {
532 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
533 result = sendUpdateGroupV2Message(group,
534 groupGroupChangePair.first(),
535 groupGroupChangePair.second());
536 }
537 }
538 }
539
540 if (removeAdmins != null) {
541 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
542 existingRemoveAdmins.retainAll(group.getAdminMembers());
543 if (existingRemoveAdmins.size() > 0) {
544 for (var admin : existingRemoveAdmins) {
545 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
546 result = sendUpdateGroupV2Message(group,
547 groupGroupChangePair.first(),
548 groupGroupChangePair.second());
549 }
550 }
551 }
552
553 if (resetGroupLink) {
554 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
555 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
556 }
557
558 if (groupLinkState != null) {
559 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
560 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
561 }
562
563 if (addMemberPermission != null) {
564 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
565 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
566 }
567
568 if (editDetailsPermission != null) {
569 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
570 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
571 }
572
573 if (expirationTimer != null) {
574 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
575 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
576 }
577
578 if (isAnnouncementGroup != null) {
579 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
580 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
581 }
582
583 if (name != null || description != null || avatarFile != null) {
584 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
585 if (avatarFile != null) {
586 avatarStore.storeGroupAvatar(group.getGroupId(),
587 outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
588 }
589 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
590 }
591
592 return result;
593 }
594
595 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
596 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
597 .withId(groupInfoV1.getGroupId().serialize())
598 .build();
599
600 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
601 groupInfoV1.removeMember(account.getSelfRecipientId());
602 account.getGroupStore().updateGroup(groupInfoV1);
603 return sendGroupMessage(messageBuilder,
604 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
605 }
606
607 private SendGroupMessageResults quitGroupV2(
608 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
609 ) throws LastGroupAdminException, IOException {
610 final var currentAdmins = groupInfoV2.getAdminMembers();
611 newAdmins.removeAll(currentAdmins);
612 newAdmins.retainAll(groupInfoV2.getMembers());
613 if (currentAdmins.contains(account.getSelfRecipientId())
614 && currentAdmins.size() == 1
615 && groupInfoV2.getMembers().size() > 1
616 && newAdmins.size() == 0) {
617 // Last admin can't leave the group, unless she's also the last member
618 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
619 }
620 final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins);
621 groupInfoV2.setGroup(groupGroupChangePair.first(), recipientResolver);
622 account.getGroupStore().updateGroup(groupInfoV2);
623
624 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
625 return sendGroupMessage(messageBuilder,
626 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()));
627 }
628
629 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
630 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
631 .withId(g.getGroupId().serialize())
632 .withName(g.name)
633 .withMembers(g.getMembers()
634 .stream()
635 .map(addressResolver::resolveSignalServiceAddress)
636 .collect(Collectors.toList()));
637
638 try {
639 final var attachment = createGroupAvatarAttachment(g.getGroupId());
640 if (attachment.isPresent()) {
641 group.withAvatar(attachment.get());
642 }
643 } catch (IOException e) {
644 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
645 }
646
647 return SignalServiceDataMessage.newBuilder()
648 .asGroupMessage(group.build())
649 .withExpiration(g.getMessageExpirationTimer());
650 }
651
652 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
653 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
654 .withRevision(g.getGroup().getRevision())
655 .withSignedGroupChange(signedGroupChange);
656 return SignalServiceDataMessage.newBuilder()
657 .asGroupMessage(group.build())
658 .withExpiration(g.getMessageExpirationTimer());
659 }
660
661 private SendGroupMessageResults sendUpdateGroupV2Message(
662 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
663 ) throws IOException {
664 final var selfRecipientId = account.getSelfRecipientId();
665 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
666 group.setGroup(newDecryptedGroup, recipientResolver);
667 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
668 account.getGroupStore().updateGroup(group);
669
670 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
671 return sendGroupMessage(messageBuilder, members);
672 }
673
674 private SendGroupMessageResults sendGroupMessage(
675 final SignalServiceDataMessage.Builder messageBuilder, final Set<RecipientId> members
676 ) throws IOException {
677 final var timestamp = System.currentTimeMillis();
678 messageBuilder.withTimestamp(timestamp);
679 final var results = sendHelper.sendGroupMessage(messageBuilder.build(), members);
680 return new SendGroupMessageResults(timestamp,
681 results.stream()
682 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
683 recipientResolver,
684 account.getRecipientStore()::resolveRecipientAddress))
685 .collect(Collectors.toList()));
686 }
687 }