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