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