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