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