]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Update libsignal-service
[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.DecryptedGroupChangeLog;
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.getChangeLogs()
486 .stream()
487 .map(DecryptedGroupChangeLog::getChange)
488 .filter(Objects::nonNull)
489 .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
490 .filter(Objects::nonNull)
491 .forEach(p -> {
492 final var serviceId = p.first();
493 final var profileKey = p.second();
494 final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
495 newProfileKeys.put(recipientId, profileKey);
496 });
497 if (!page.getPagingData().getHasMorePages()) {
498 break;
499 }
500 fromRevision = page.getPagingData().getNextPageRevision();
501 }
502
503 newProfileKeys.forEach(account.getProfileStore()::storeProfileKey);
504 }
505
506 private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
507 var g = getGroup(groupId);
508 if (g == null) {
509 throw new GroupNotFoundException(groupId);
510 }
511 if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
512 throw new NotAGroupMemberException(groupId, g.getTitle());
513 }
514 if (groupId instanceof GroupIdV2) {
515 // Refresh group before updating
516 return getGroup(groupId, true);
517 }
518 return g;
519 }
520
521 private SendGroupMessageResults updateGroupV1(
522 final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final byte[] avatarFile
523 ) throws IOException, AttachmentInvalidException {
524 updateGroupV1Details(gv1, name, members, avatarFile);
525
526 account.getGroupStore().updateGroup(gv1);
527
528 var messageBuilder = getGroupUpdateMessageBuilder(gv1);
529 return sendGroupMessage(messageBuilder,
530 gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
531 gv1.getDistributionId());
532 }
533
534 private void updateGroupV1Details(
535 final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final byte[] avatarFile
536 ) throws IOException {
537 if (name != null) {
538 g.name = name;
539 }
540
541 if (members != null) {
542 g.addMembers(members);
543 }
544
545 if (avatarFile != null) {
546 context.getAvatarStore().storeGroupAvatar(g.getGroupId(), outputStream -> outputStream.write(avatarFile));
547 }
548 }
549
550 /**
551 * Change the expiration timer for a group
552 */
553 private void setExpirationTimer(
554 GroupInfoV1 groupInfoV1, int messageExpirationTimer
555 ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
556 groupInfoV1.messageExpirationTime = messageExpirationTimer;
557 account.getGroupStore().updateGroup(groupInfoV1);
558 sendExpirationTimerUpdate(groupInfoV1.getGroupId());
559 }
560
561 private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
562 final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
563 context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty());
564 }
565
566 private SendGroupMessageResults updateGroupV2(
567 final GroupInfoV2 group,
568 final String name,
569 final String description,
570 final Set<RecipientId> members,
571 final Set<RecipientId> removeMembers,
572 final Set<RecipientId> admins,
573 final Set<RecipientId> removeAdmins,
574 final Set<RecipientId> banMembers,
575 final Set<RecipientId> unbanMembers,
576 final boolean resetGroupLink,
577 final GroupLinkState groupLinkState,
578 final GroupPermission addMemberPermission,
579 final GroupPermission editDetailsPermission,
580 final byte[] avatarFile,
581 final Integer expirationTimer,
582 final Boolean isAnnouncementGroup
583 ) throws IOException {
584 SendGroupMessageResults result = null;
585 final var groupV2Helper = context.getGroupV2Helper();
586 if (group.isPendingMember(account.getSelfRecipientId())) {
587 var groupGroupChangePair = groupV2Helper.acceptInvite(group);
588 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
589 }
590
591 if (members != null) {
592 final var requestingMembers = new HashSet<>(members);
593 requestingMembers.retainAll(group.getRequestingMembers());
594 if (!requestingMembers.isEmpty()) {
595 var groupGroupChangePair = groupV2Helper.approveJoinRequestMembers(group, requestingMembers);
596 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
597 }
598 final var newMembers = new HashSet<>(members);
599 newMembers.removeAll(group.getMembers());
600 newMembers.removeAll(group.getRequestingMembers());
601 if (!newMembers.isEmpty()) {
602 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
603 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
604 }
605 }
606
607 if (removeMembers != null) {
608 var existingRemoveMembers = new HashSet<>(removeMembers);
609 if (banMembers != null) {
610 existingRemoveMembers.addAll(banMembers);
611 }
612 existingRemoveMembers.retainAll(group.getMembers());
613 if (members != null) {
614 existingRemoveMembers.removeAll(members);
615 }
616 existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
617 if (!existingRemoveMembers.isEmpty()) {
618 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
619 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
620 }
621
622 var pendingRemoveMembers = new HashSet<>(removeMembers);
623 pendingRemoveMembers.retainAll(group.getPendingMembers());
624 if (!pendingRemoveMembers.isEmpty()) {
625 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
626 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
627 }
628 var requestingRemoveMembers = new HashSet<>(removeMembers);
629 requestingRemoveMembers.retainAll(group.getRequestingMembers());
630 if (!requestingRemoveMembers.isEmpty()) {
631 var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers);
632 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
633 }
634 }
635
636 if (admins != null) {
637 final var newAdmins = new HashSet<>(admins);
638 newAdmins.retainAll(group.getMembers());
639 newAdmins.removeAll(group.getAdminMembers());
640 if (!newAdmins.isEmpty()) {
641 for (var admin : newAdmins) {
642 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
643 result = sendUpdateGroupV2Message(group,
644 groupGroupChangePair.first(),
645 groupGroupChangePair.second());
646 }
647 }
648 }
649
650 if (removeAdmins != null) {
651 final var existingRemoveAdmins = new HashSet<>(removeAdmins);
652 existingRemoveAdmins.retainAll(group.getAdminMembers());
653 if (!existingRemoveAdmins.isEmpty()) {
654 for (var admin : existingRemoveAdmins) {
655 var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
656 result = sendUpdateGroupV2Message(group,
657 groupGroupChangePair.first(),
658 groupGroupChangePair.second());
659 }
660 }
661 }
662
663 if (banMembers != null) {
664 final var newlyBannedMembers = new HashSet<>(banMembers);
665 newlyBannedMembers.removeAll(group.getBannedMembers());
666 if (!newlyBannedMembers.isEmpty()) {
667 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
668 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
669 }
670 }
671
672 if (unbanMembers != null) {
673 var existingUnbanMembers = new HashSet<>(unbanMembers);
674 existingUnbanMembers.retainAll(group.getBannedMembers());
675 if (!existingUnbanMembers.isEmpty()) {
676 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
677 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
678 }
679 }
680
681 if (resetGroupLink) {
682 var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
683 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
684 }
685
686 if (groupLinkState != null) {
687 var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
688 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
689 }
690
691 if (addMemberPermission != null) {
692 var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
693 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
694 }
695
696 if (editDetailsPermission != null) {
697 var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
698 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
699 }
700
701 if (expirationTimer != null) {
702 var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
703 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
704 }
705
706 if (isAnnouncementGroup != null) {
707 var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
708 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
709 }
710
711 if (name != null || description != null || avatarFile != null) {
712 var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
713 if (avatarFile != null) {
714 context.getAvatarStore()
715 .storeGroupAvatar(group.getGroupId(), outputStream -> outputStream.write(avatarFile));
716 }
717 result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
718 }
719
720 return result;
721 }
722
723 private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
724 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
725 .withId(groupInfoV1.getGroupId().serialize())
726 .build();
727
728 var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
729 groupInfoV1.removeMember(account.getSelfRecipientId());
730 account.getGroupStore().updateGroup(groupInfoV1);
731 return sendGroupMessage(messageBuilder,
732 groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
733 groupInfoV1.getDistributionId());
734 }
735
736 private SendGroupMessageResults quitGroupV2(
737 final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
738 ) throws LastGroupAdminException, IOException {
739 final var currentAdmins = groupInfoV2.getAdminMembers();
740 newAdmins.removeAll(currentAdmins);
741 newAdmins.retainAll(groupInfoV2.getMembers());
742 if (currentAdmins.contains(account.getSelfRecipientId())
743 && currentAdmins.size() == 1
744 && groupInfoV2.getMembers().size() > 1
745 && newAdmins.isEmpty()) {
746 // Last admin can't leave the group, unless she's also the last member
747 throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
748 }
749 final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
750 groupInfoV2.setGroup(groupGroupChangePair.first());
751 account.getGroupStore().updateGroup(groupInfoV2);
752
753 var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().encode());
754 return sendGroupMessage(messageBuilder,
755 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
756 groupInfoV2.getDistributionId());
757 }
758
759 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
760 var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
761 .withId(g.getGroupId().serialize())
762 .withName(g.name)
763 .withMembers(g.getMembers()
764 .stream()
765 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
766 .toList());
767
768 try {
769 final var attachment = createGroupAvatarAttachment(g.getGroupId());
770 attachment.ifPresent(group::withAvatar);
771 } catch (IOException e) {
772 throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
773 }
774
775 return SignalServiceDataMessage.newBuilder()
776 .asGroupMessage(group.build())
777 .withExpiration(g.getMessageExpirationTimer());
778 }
779
780 private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
781 var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
782 .withRevision(g.getGroup().revision)
783 .withSignedGroupChange(signedGroupChange);
784 return SignalServiceDataMessage.newBuilder()
785 .asGroupMessage(group.build())
786 .withExpiration(g.getMessageExpirationTimer());
787 }
788
789 private SendGroupMessageResults sendUpdateGroupV2Message(
790 GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
791 ) throws IOException {
792 final var selfRecipientId = account.getSelfRecipientId();
793 final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
794 group.setGroup(newDecryptedGroup);
795 members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
796 account.getGroupStore().updateGroup(group);
797
798 final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode());
799 return sendGroupMessage(messageBuilder, members, group.getDistributionId());
800 }
801
802 private SendGroupMessageResults sendGroupMessage(
803 final SignalServiceDataMessage.Builder messageBuilder,
804 final Set<RecipientId> members,
805 final DistributionId distributionId
806 ) throws IOException {
807 final var timestamp = System.currentTimeMillis();
808 messageBuilder.withTimestamp(timestamp);
809 final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
810 return new SendGroupMessageResults(timestamp,
811 results.stream()
812 .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
813 account.getRecipientResolver(),
814 account.getRecipientAddressResolver()))
815 .toList());
816 }
817
818 private byte[] readAvatarBytes(final String avatarFile) throws IOException {
819 if (avatarFile == null) {
820 return null;
821 }
822 try (final var avatar = Utils.createStreamDetails(avatarFile).first()) {
823 return IOUtils.readFully(avatar.getStream());
824 }
825 }
826 }