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