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