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