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