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