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