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