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