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