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