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