]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java
Send sync message for read/viewed receipt messages
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / SyncHelper.java
1 package org.asamk.signal.manager.helper;
2
3 import org.asamk.signal.manager.api.Contact;
4 import org.asamk.signal.manager.api.GroupId;
5 import org.asamk.signal.manager.api.MessageEnvelope.Sync.MessageRequestResponse;
6 import org.asamk.signal.manager.api.TrustLevel;
7 import org.asamk.signal.manager.storage.SignalAccount;
8 import org.asamk.signal.manager.storage.groups.GroupInfoV1;
9 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
10 import org.asamk.signal.manager.storage.recipients.RecipientId;
11 import org.asamk.signal.manager.storage.stickers.StickerPack;
12 import org.asamk.signal.manager.util.AttachmentUtils;
13 import org.asamk.signal.manager.util.IOUtils;
14 import org.asamk.signal.manager.util.MimeUtils;
15 import org.jetbrains.annotations.NotNull;
16 import org.signal.libsignal.protocol.IdentityKey;
17 import org.slf4j.Logger;
18 import org.slf4j.LoggerFactory;
19 import org.whispersystems.signalservice.api.messages.SendMessageResult;
20 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
21 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
22 import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
23 import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage;
24 import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage;
25 import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
26 import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact;
27 import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsInputStream;
28 import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsOutputStream;
29 import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroup;
30 import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsInputStream;
31 import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsOutputStream;
32 import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage;
33 import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage;
34 import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage;
35 import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage;
36 import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
37 import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
38 import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
39 import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
40 import org.whispersystems.signalservice.api.push.ServiceId;
41 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
42 import org.whispersystems.signalservice.internal.push.SyncMessage;
43 import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
44
45 import java.io.FileInputStream;
46 import java.io.FileOutputStream;
47 import java.io.IOException;
48 import java.io.InputStream;
49 import java.io.OutputStream;
50 import java.nio.file.Files;
51 import java.util.ArrayList;
52 import java.util.List;
53 import java.util.Optional;
54 import java.util.stream.Collectors;
55 import java.util.stream.Stream;
56
57 public class SyncHelper {
58
59 private static final Logger logger = LoggerFactory.getLogger(SyncHelper.class);
60
61 private final Context context;
62 private final SignalAccount account;
63
64 public SyncHelper(final Context context) {
65 this.context = context;
66 this.account = context.getAccount();
67 }
68
69 public void requestAllSyncData() {
70 requestSyncData(SyncMessage.Request.Type.GROUPS);
71 requestSyncData(SyncMessage.Request.Type.CONTACTS);
72 requestSyncData(SyncMessage.Request.Type.BLOCKED);
73 requestSyncData(SyncMessage.Request.Type.CONFIGURATION);
74 requestSyncKeys();
75 requestSyncPniIdentity();
76 }
77
78 public void requestSyncKeys() {
79 requestSyncData(SyncMessage.Request.Type.KEYS);
80 }
81
82 public void requestSyncPniIdentity() {
83 requestSyncData(SyncMessage.Request.Type.PNI_IDENTITY);
84 }
85
86 public SendMessageResult sendSyncFetchProfileMessage() {
87 return context.getSendHelper()
88 .sendSyncMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.LOCAL_PROFILE));
89 }
90
91 public void sendSyncFetchStorageMessage() {
92 context.getSendHelper()
93 .sendSyncMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.STORAGE_MANIFEST));
94 }
95
96 public void sendSyncReceiptMessage(ServiceId sender, SignalServiceReceiptMessage receiptMessage) {
97 if (receiptMessage.isReadReceipt()) {
98 final var readMessages = receiptMessage.getTimestamps()
99 .stream()
100 .map(t -> new ReadMessage(sender, t))
101 .toList();
102 context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forRead(readMessages));
103 } else if (receiptMessage.isViewedReceipt()) {
104 final var viewedMessages = receiptMessage.getTimestamps()
105 .stream()
106 .map(t -> new ViewedMessage(sender, t))
107 .toList();
108 context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forViewed(viewedMessages));
109 }
110 }
111
112 public void sendGroups() throws IOException {
113 var groupsFile = IOUtils.createTempFile();
114
115 try {
116 try (OutputStream fos = new FileOutputStream(groupsFile)) {
117 var out = new DeviceGroupsOutputStream(fos);
118 for (var record : account.getGroupStore().getGroups()) {
119 if (record instanceof GroupInfoV1 groupInfo) {
120 out.write(new DeviceGroup(groupInfo.getGroupId().serialize(),
121 Optional.ofNullable(groupInfo.name),
122 groupInfo.getMembers()
123 .stream()
124 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
125 .toList(),
126 context.getGroupHelper().createGroupAvatarAttachment(groupInfo.getGroupId()),
127 groupInfo.isMember(account.getSelfRecipientId()),
128 Optional.of(groupInfo.messageExpirationTime),
129 Optional.ofNullable(groupInfo.color),
130 groupInfo.blocked,
131 Optional.empty(),
132 groupInfo.archived));
133 }
134 }
135 }
136
137 if (groupsFile.exists() && groupsFile.length() > 0) {
138 try (var groupsFileStream = new FileInputStream(groupsFile)) {
139 final var uploadSpec = context.getDependencies()
140 .getMessageSender()
141 .getResumableUploadSpec()
142 .toProto();
143 var attachmentStream = SignalServiceAttachment.newStreamBuilder()
144 .withStream(groupsFileStream)
145 .withContentType(MimeUtils.OCTET_STREAM)
146 .withLength(groupsFile.length())
147 .withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
148 .build();
149
150 context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forGroups(attachmentStream));
151 }
152 }
153 } finally {
154 try {
155 Files.delete(groupsFile.toPath());
156 } catch (IOException e) {
157 logger.warn("Failed to delete groups temp file “{}”, ignoring: {}", groupsFile, e.getMessage());
158 }
159 }
160 }
161
162 public void sendContacts() throws IOException {
163 var contactsFile = IOUtils.createTempFile();
164
165 try {
166 try (OutputStream fos = new FileOutputStream(contactsFile)) {
167 var out = new DeviceContactsOutputStream(fos);
168 for (var contactPair : account.getContactStore().getContacts()) {
169 final var recipientId = contactPair.first();
170 final var contact = contactPair.second();
171 final var address = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
172
173 out.write(getDeviceContact(address, recipientId, contact));
174 }
175
176 if (account.getProfileKey() != null) {
177 // Send our own profile key as well
178 final var address = account.getSelfRecipientAddress();
179 final var recipientId = account.getSelfRecipientId();
180 final var contact = account.getContactStore().getContact(recipientId);
181 out.write(getDeviceContact(address, recipientId, contact));
182 }
183 }
184
185 if (contactsFile.exists() && contactsFile.length() > 0) {
186 try (var contactsFileStream = new FileInputStream(contactsFile)) {
187 final var uploadSpec = context.getDependencies()
188 .getMessageSender()
189 .getResumableUploadSpec()
190 .toProto();
191 var attachmentStream = SignalServiceAttachment.newStreamBuilder()
192 .withStream(contactsFileStream)
193 .withContentType(MimeUtils.OCTET_STREAM)
194 .withLength(contactsFile.length())
195 .withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
196 .build();
197
198 context.getSendHelper()
199 .sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream,
200 true)));
201 }
202 }
203 } finally {
204 try {
205 Files.delete(contactsFile.toPath());
206 } catch (IOException e) {
207 logger.warn("Failed to delete contacts temp file “{}”, ignoring: {}", contactsFile, e.getMessage());
208 }
209 }
210 }
211
212 @NotNull
213 private DeviceContact getDeviceContact(
214 final RecipientAddress address, final RecipientId recipientId, final Contact contact
215 ) throws IOException {
216 var currentIdentity = address.serviceId().isEmpty()
217 ? null
218 : account.getIdentityKeyStore().getIdentityInfo(address.serviceId().get());
219 VerifiedMessage verifiedMessage = null;
220 if (currentIdentity != null) {
221 verifiedMessage = new VerifiedMessage(address.toSignalServiceAddress(),
222 currentIdentity.getIdentityKey(),
223 currentIdentity.getTrustLevel().toVerifiedState(),
224 currentIdentity.getDateAddedTimestamp());
225 }
226
227 var profileKey = account.getProfileStore().getProfileKey(recipientId);
228 return new DeviceContact(address.aci(),
229 address.number(),
230 Optional.ofNullable(contact == null ? null : contact.getName()),
231 createContactAvatarAttachment(address),
232 Optional.ofNullable(contact == null ? null : contact.color()),
233 Optional.ofNullable(verifiedMessage),
234 Optional.ofNullable(profileKey),
235 Optional.ofNullable(contact == null ? null : contact.messageExpirationTime()),
236 Optional.empty(),
237 contact != null && contact.isArchived());
238 }
239
240 public SendMessageResult sendBlockedList() {
241 var addresses = new ArrayList<SignalServiceAddress>();
242 for (var record : account.getContactStore().getContacts()) {
243 if (record.second().isBlocked()) {
244 addresses.add(context.getRecipientHelper().resolveSignalServiceAddress(record.first()));
245 }
246 }
247 var groupIds = new ArrayList<byte[]>();
248 for (var record : account.getGroupStore().getGroups()) {
249 if (record.isBlocked()) {
250 groupIds.add(record.getGroupId().serialize());
251 }
252 }
253 return context.getSendHelper()
254 .sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds)));
255 }
256
257 public SendMessageResult sendVerifiedMessage(
258 SignalServiceAddress destination, IdentityKey identityKey, TrustLevel trustLevel
259 ) {
260 var verifiedMessage = new VerifiedMessage(destination,
261 identityKey,
262 trustLevel.toVerifiedState(),
263 System.currentTimeMillis());
264 return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forVerified(verifiedMessage));
265 }
266
267 public SendMessageResult sendKeysMessage() {
268 var keysMessage = new KeysMessage(Optional.ofNullable(account.getOrCreateStorageKey()),
269 Optional.ofNullable(account.getOrCreatePinMasterKey()));
270 return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage));
271 }
272
273 public SendMessageResult sendStickerOperationsMessage(
274 List<StickerPack> installStickers, List<StickerPack> removeStickers
275 ) {
276 var installStickerMessages = installStickers.stream().map(s -> getStickerPackOperationMessage(s, true));
277 var removeStickerMessages = removeStickers.stream().map(s -> getStickerPackOperationMessage(s, false));
278 var stickerMessages = Stream.concat(installStickerMessages, removeStickerMessages).toList();
279 return context.getSendHelper()
280 .sendSyncMessage(SignalServiceSyncMessage.forStickerPackOperations(stickerMessages));
281 }
282
283 private static StickerPackOperationMessage getStickerPackOperationMessage(
284 final StickerPack s, final boolean installed
285 ) {
286 return new StickerPackOperationMessage(s.packId().serialize(),
287 s.packKey(),
288 installed ? StickerPackOperationMessage.Type.INSTALL : StickerPackOperationMessage.Type.REMOVE);
289 }
290
291 public SendMessageResult sendConfigurationMessage() {
292 final var config = account.getConfigurationStore();
293 var configurationMessage = new ConfigurationMessage(Optional.ofNullable(config.getReadReceipts()),
294 Optional.ofNullable(config.getUnidentifiedDeliveryIndicators()),
295 Optional.ofNullable(config.getTypingIndicators()),
296 Optional.ofNullable(config.getLinkPreviews()));
297 return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forConfiguration(configurationMessage));
298 }
299
300 public void handleSyncDeviceGroups(final InputStream input) {
301 final var s = new DeviceGroupsInputStream(input);
302 DeviceGroup g;
303 while (true) {
304 try {
305 g = s.read();
306 } catch (IOException e) {
307 logger.warn("Sync groups contained invalid group, ignoring: {}", e.getMessage());
308 continue;
309 }
310 if (g == null) {
311 break;
312 }
313 var syncGroup = account.getGroupStore().getOrCreateGroupV1(GroupId.v1(g.getId()));
314 if (syncGroup != null) {
315 if (g.getName().isPresent()) {
316 syncGroup.name = g.getName().get();
317 }
318 syncGroup.addMembers(g.getMembers()
319 .stream()
320 .map(account.getRecipientResolver()::resolveRecipient)
321 .collect(Collectors.toSet()));
322 if (!g.isActive()) {
323 syncGroup.removeMember(account.getSelfRecipientId());
324 } else {
325 // Add ourself to the member set as it's marked as active
326 syncGroup.addMembers(List.of(account.getSelfRecipientId()));
327 }
328 syncGroup.blocked = g.isBlocked();
329 if (g.getColor().isPresent()) {
330 syncGroup.color = g.getColor().get();
331 }
332
333 if (g.getAvatar().isPresent()) {
334 context.getGroupHelper().downloadGroupAvatar(syncGroup.getGroupId(), g.getAvatar().get());
335 }
336 syncGroup.archived = g.isArchived();
337 account.getGroupStore().updateGroup(syncGroup);
338 }
339 }
340 }
341
342 public void handleSyncDeviceContacts(final InputStream input) throws IOException {
343 final var s = new DeviceContactsInputStream(input);
344 DeviceContact c;
345 while (true) {
346 try {
347 c = s.read();
348 } catch (IOException e) {
349 if (e.getMessage() != null && e.getMessage().contains("Missing contact address!")) {
350 logger.warn("Sync contacts contained invalid contact, ignoring: {}", e.getMessage());
351 continue;
352 } else {
353 throw e;
354 }
355 }
356 if (c == null || (c.getAci().isEmpty() && c.getE164().isEmpty())) {
357 break;
358 }
359 final var address = new RecipientAddress(c.getAci(), Optional.empty(), c.getE164(), Optional.empty());
360 if (address.matches(account.getSelfRecipientAddress()) && c.getProfileKey().isPresent()) {
361 account.setProfileKey(c.getProfileKey().get());
362 }
363 final var recipientId = account.getRecipientTrustedResolver().resolveRecipientTrusted(address);
364 var contact = account.getContactStore().getContact(recipientId);
365 final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
366 if (c.getName().isPresent() && (
367 contact == null || (
368 contact.givenName() == null && contact.familyName() == null
369 )
370 )) {
371 builder.withGivenName(c.getName().get());
372 builder.withFamilyName(null);
373 }
374 if (c.getColor().isPresent()) {
375 builder.withColor(c.getColor().get());
376 }
377 if (c.getProfileKey().isPresent()) {
378 account.getProfileStore().storeProfileKey(recipientId, c.getProfileKey().get());
379 }
380 if (c.getVerified().isPresent()) {
381 final var verifiedMessage = c.getVerified().get();
382 account.getIdentityKeyStore()
383 .setIdentityTrustLevel(verifiedMessage.getDestination().getServiceId(),
384 verifiedMessage.getIdentityKey(),
385 TrustLevel.fromVerifiedState(verifiedMessage.getVerified()));
386 }
387 if (c.getExpirationTimer().isPresent()) {
388 builder.withMessageExpirationTime(c.getExpirationTimer().get());
389 }
390 builder.withIsArchived(c.isArchived());
391 account.getContactStore().storeContact(recipientId, builder.build());
392
393 if (c.getAvatar().isPresent()) {
394 downloadContactAvatar(c.getAvatar().get(), address);
395 }
396 }
397 }
398
399 public SendMessageResult sendMessageRequestResponse(
400 final MessageRequestResponse.Type type, final GroupId groupId
401 ) {
402 final var response = MessageRequestResponseMessage.forGroup(groupId.serialize(), localToRemoteType(type));
403 return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forMessageRequestResponse(response));
404 }
405
406 public SendMessageResult sendMessageRequestResponse(
407 final MessageRequestResponse.Type type, final RecipientId recipientId
408 ) {
409 final var address = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
410 if (address.serviceId().isEmpty()) {
411 return null;
412 }
413 context.getContactHelper()
414 .setContactProfileSharing(recipientId,
415 type == MessageRequestResponse.Type.ACCEPT
416 || type == MessageRequestResponse.Type.UNBLOCK_AND_ACCEPT);
417 final var response = MessageRequestResponseMessage.forIndividual(address.serviceId().get(),
418 localToRemoteType(type));
419 return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forMessageRequestResponse(response));
420 }
421
422 private SendMessageResult requestSyncData(final SyncMessage.Request.Type type) {
423 var r = new SyncMessage.Request.Builder().type(type).build();
424 var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r));
425 return context.getSendHelper().sendSyncMessage(message);
426 }
427
428 private Optional<SignalServiceAttachmentStream> createContactAvatarAttachment(RecipientAddress address) throws IOException {
429 final var streamDetails = context.getAvatarStore().retrieveContactAvatar(address);
430 if (streamDetails == null) {
431 return Optional.empty();
432 }
433
434 final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec().toProto();
435 return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
436 }
437
438 private void downloadContactAvatar(SignalServiceAttachment avatar, RecipientAddress address) {
439 try {
440 context.getAvatarStore()
441 .storeContactAvatar(address,
442 outputStream -> context.getAttachmentHelper().retrieveAttachment(avatar, outputStream));
443 } catch (IOException e) {
444 logger.warn("Failed to download avatar for contact {}, ignoring: {}", address, e.getMessage());
445 }
446 }
447
448 private MessageRequestResponseMessage.Type localToRemoteType(final MessageRequestResponse.Type type) {
449 return switch (type) {
450 case UNKNOWN -> MessageRequestResponseMessage.Type.UNKNOWN;
451 case ACCEPT -> MessageRequestResponseMessage.Type.ACCEPT;
452 case DELETE -> MessageRequestResponseMessage.Type.DELETE;
453 case BLOCK -> MessageRequestResponseMessage.Type.BLOCK;
454 case BLOCK_AND_DELETE -> MessageRequestResponseMessage.Type.BLOCK_AND_DELETE;
455 case UNBLOCK_AND_ACCEPT -> MessageRequestResponseMessage.Type.UNBLOCK_AND_ACCEPT;
456 case SPAM -> MessageRequestResponseMessage.Type.SPAM;
457 case BLOCK_AND_SPAM -> MessageRequestResponseMessage.Type.BLOCK_AND_SPAM;
458 };
459 }
460 }