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