1 package org
.asamk
.signal
.manager
.helper
;
3 import org
.asamk
.signal
.manager
.api
.GroupIdV1
;
4 import org
.asamk
.signal
.manager
.api
.GroupIdV2
;
5 import org
.asamk
.signal
.manager
.internal
.SignalDependencies
;
6 import org
.asamk
.signal
.manager
.storage
.SignalAccount
;
7 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
8 import org
.asamk
.signal
.manager
.syncStorage
.AccountRecordProcessor
;
9 import org
.asamk
.signal
.manager
.syncStorage
.ContactRecordProcessor
;
10 import org
.asamk
.signal
.manager
.syncStorage
.GroupV1RecordProcessor
;
11 import org
.asamk
.signal
.manager
.syncStorage
.GroupV2RecordProcessor
;
12 import org
.asamk
.signal
.manager
.syncStorage
.StorageSyncModels
;
13 import org
.asamk
.signal
.manager
.syncStorage
.StorageSyncValidations
;
14 import org
.asamk
.signal
.manager
.syncStorage
.WriteOperationResult
;
15 import org
.asamk
.signal
.manager
.util
.KeyUtils
;
16 import org
.signal
.core
.util
.SetUtil
;
17 import org
.signal
.libsignal
.protocol
.InvalidKeyException
;
18 import org
.slf4j
.Logger
;
19 import org
.slf4j
.LoggerFactory
;
20 import org
.whispersystems
.signalservice
.api
.storage
.SignalStorageManifest
;
21 import org
.whispersystems
.signalservice
.api
.storage
.SignalStorageRecord
;
22 import org
.whispersystems
.signalservice
.api
.storage
.StorageId
;
23 import org
.whispersystems
.signalservice
.api
.storage
.StorageKey
;
24 import org
.whispersystems
.signalservice
.internal
.storage
.protos
.ManifestRecord
;
26 import java
.io
.IOException
;
27 import java
.sql
.Connection
;
28 import java
.sql
.SQLException
;
29 import java
.util
.ArrayList
;
30 import java
.util
.Base64
;
31 import java
.util
.Collection
;
32 import java
.util
.Collections
;
33 import java
.util
.List
;
35 import java
.util
.Optional
;
36 import java
.util
.stream
.Collectors
;
38 public class StorageHelper
{
40 private static final Logger logger
= LoggerFactory
.getLogger(StorageHelper
.class);
41 private static final List
<Integer
> KNOWN_TYPES
= List
.of(ManifestRecord
.Identifier
.Type
.CONTACT
.getValue(),
42 ManifestRecord
.Identifier
.Type
.GROUPV1
.getValue(),
43 ManifestRecord
.Identifier
.Type
.GROUPV2
.getValue(),
44 ManifestRecord
.Identifier
.Type
.ACCOUNT
.getValue());
46 private final SignalAccount account
;
47 private final SignalDependencies dependencies
;
48 private final Context context
;
50 public StorageHelper(final Context context
) {
51 this.account
= context
.getAccount();
52 this.dependencies
= context
.getDependencies();
53 this.context
= context
;
56 public void syncDataWithStorage() throws IOException
{
57 final var storageKey
= account
.getOrCreateStorageKey();
58 if (storageKey
== null) {
59 if (!account
.isPrimaryDevice()) {
60 logger
.debug("Storage key unknown, requesting from primary device.");
61 context
.getSyncHelper().requestSyncKeys();
66 logger
.trace("Reading manifest from remote storage");
67 final var localManifestVersion
= account
.getStorageManifestVersion();
68 final var localManifest
= account
.getStorageManifest().orElse(SignalStorageManifest
.EMPTY
);
69 SignalStorageManifest remoteManifest
;
71 remoteManifest
= dependencies
.getAccountManager()
72 .getStorageManifestIfDifferentVersion(storageKey
, localManifestVersion
)
73 .orElse(localManifest
);
74 } catch (InvalidKeyException e
) {
75 logger
.warn("Manifest couldn't be decrypted.");
76 if (account
.isPrimaryDevice()) {
78 forcePushToStorage(storageKey
);
79 } catch (RetryLaterException rle
) {
87 logger
.trace("Manifest versions: local {}, remote {}", localManifestVersion
, remoteManifest
.getVersion());
89 var needsForcePush
= false;
90 if (remoteManifest
.getVersion() > localManifestVersion
) {
91 logger
.trace("Remote version was newer, reading records.");
92 needsForcePush
= readDataFromStorage(storageKey
, localManifest
, remoteManifest
);
93 } else if (remoteManifest
.getVersion() < localManifest
.getVersion()) {
94 logger
.debug("Remote storage manifest version was older. User might have switched accounts.");
96 logger
.trace("Done reading data from remote storage");
98 if (localManifest
!= remoteManifest
) {
99 storeManifestLocally(remoteManifest
);
102 readRecordsWithPreviouslyUnknownTypes(storageKey
);
104 logger
.trace("Adding missing storageIds to local data");
105 account
.getRecipientStore().setMissingStorageIds();
106 account
.getGroupStore().setMissingStorageIds();
108 var needsMultiDeviceSync
= false;
110 needsMultiDeviceSync
= writeToStorage(storageKey
, remoteManifest
, needsForcePush
);
111 } catch (RetryLaterException e
) {
116 if (needsForcePush
) {
117 logger
.debug("Doing a force push.");
119 forcePushToStorage(storageKey
);
120 needsMultiDeviceSync
= true;
121 } catch (RetryLaterException e
) {
127 if (needsMultiDeviceSync
) {
128 context
.getSyncHelper().sendSyncFetchStorageMessage();
131 logger
.debug("Done syncing data with remote storage");
134 private boolean readDataFromStorage(
135 final StorageKey storageKey
,
136 final SignalStorageManifest localManifest
,
137 final SignalStorageManifest remoteManifest
138 ) throws IOException
{
139 var needsForcePush
= false;
140 try (final var connection
= account
.getAccountDatabase().getConnection()) {
141 connection
.setAutoCommit(false);
143 var idDifference
= findIdDifference(remoteManifest
.getStorageIds(), localManifest
.getStorageIds());
145 if (idDifference
.hasTypeMismatches() && account
.isPrimaryDevice()) {
146 logger
.debug("Found type mismatches in the ID sets! Scheduling a force push after this sync completes.");
147 needsForcePush
= true;
150 logger
.debug("Pre-Merge ID Difference :: " + idDifference
);
152 if (!idDifference
.localOnlyIds().isEmpty()) {
153 final var updated
= account
.getRecipientStore()
154 .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection
, idDifference
.localOnlyIds());
158 "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
163 if (!idDifference
.isEmpty()) {
164 final var remoteOnlyRecords
= getSignalStorageRecords(storageKey
, idDifference
.remoteOnlyIds());
166 if (remoteOnlyRecords
.size() != idDifference
.remoteOnlyIds().size()) {
167 logger
.debug("Could not find all remote-only records! Requested: "
168 + idDifference
.remoteOnlyIds()
171 + remoteOnlyRecords
.size()
172 + ". These stragglers should naturally get deleted during the sync.");
175 final var unknownInserts
= processKnownRecords(connection
, remoteOnlyRecords
);
176 final var unknownDeletes
= idDifference
.localOnlyIds()
178 .filter(id
-> !KNOWN_TYPES
.contains(id
.getType()))
181 logger
.debug("Storage ids with unknown type: {} inserts, {} deletes",
182 unknownInserts
.size(),
183 unknownDeletes
.size());
185 account
.getUnknownStorageIdStore().addUnknownStorageIds(connection
, unknownInserts
);
186 account
.getUnknownStorageIdStore().deleteUnknownStorageIds(connection
, unknownDeletes
);
188 logger
.debug("Remote version was newer, but there were no remote-only IDs.");
191 } catch (SQLException e
) {
192 throw new RuntimeException("Failed to sync remote storage", e
);
194 return needsForcePush
;
197 private void readRecordsWithPreviouslyUnknownTypes(final StorageKey storageKey
) throws IOException
{
198 try (final var connection
= account
.getAccountDatabase().getConnection()) {
199 connection
.setAutoCommit(false);
200 final var knownUnknownIds
= account
.getUnknownStorageIdStore()
201 .getUnknownStorageIds(connection
, KNOWN_TYPES
);
203 if (!knownUnknownIds
.isEmpty()) {
204 logger
.debug("We have " + knownUnknownIds
.size() + " unknown records that we can now process.");
206 final var remote
= getSignalStorageRecords(storageKey
, knownUnknownIds
);
208 logger
.debug("Found " + remote
.size() + " of the known-unknowns remotely.");
210 processKnownRecords(connection
, remote
);
211 account
.getUnknownStorageIdStore()
212 .deleteUnknownStorageIds(connection
, remote
.stream().map(SignalStorageRecord
::getId
).toList());
215 } catch (SQLException e
) {
216 throw new RuntimeException("Failed to sync remote storage", e
);
220 private boolean writeToStorage(
221 final StorageKey storageKey
,
222 final SignalStorageManifest remoteManifest
,
223 final boolean needsForcePush
224 ) throws IOException
, RetryLaterException
{
225 final WriteOperationResult remoteWriteOperation
;
226 try (final var connection
= account
.getAccountDatabase().getConnection()) {
227 connection
.setAutoCommit(false);
229 final var localStorageIds
= getAllLocalStorageIds(connection
);
230 final var idDifference
= findIdDifference(remoteManifest
.getStorageIds(), localStorageIds
);
231 logger
.debug("ID Difference :: " + idDifference
);
233 final var remoteDeletes
= idDifference
.remoteOnlyIds().stream().map(StorageId
::getRaw
).toList();
234 final var remoteInserts
= buildLocalStorageRecords(connection
, idDifference
.localOnlyIds());
235 // TODO check if local storage record proto matches remote, then reset to remote storage_id
237 remoteWriteOperation
= new WriteOperationResult(new SignalStorageManifest(remoteManifest
.getVersion() + 1,
238 account
.getDeviceId(),
239 localStorageIds
), remoteInserts
, remoteDeletes
);
242 } catch (SQLException e
) {
243 throw new RuntimeException("Failed to sync remote storage", e
);
246 if (remoteWriteOperation
.isEmpty()) {
247 logger
.debug("No remote writes needed. Still at version: " + remoteManifest
.getVersion());
251 logger
.debug("We have something to write remotely.");
252 logger
.debug("WriteOperationResult :: " + remoteWriteOperation
);
254 StorageSyncValidations
.validate(remoteWriteOperation
,
257 account
.getSelfRecipientAddress());
259 final Optional
<SignalStorageManifest
> conflict
;
261 conflict
= dependencies
.getAccountManager()
262 .writeStorageRecords(storageKey
,
263 remoteWriteOperation
.manifest(),
264 remoteWriteOperation
.inserts(),
265 remoteWriteOperation
.deletes());
266 } catch (InvalidKeyException e
) {
267 logger
.warn("Failed to decrypt conflicting storage manifest: {}", e
.getMessage());
268 throw new IOException(e
);
271 if (conflict
.isPresent()) {
272 logger
.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
273 throw new RetryLaterException();
276 logger
.debug("Saved new manifest. Now at version: " + remoteWriteOperation
.manifest().getVersion());
277 storeManifestLocally(remoteWriteOperation
.manifest());
282 private void forcePushToStorage(
283 final StorageKey storageServiceKey
284 ) throws IOException
, RetryLaterException
{
285 logger
.debug("Force pushing local state to remote storage");
287 final var currentVersion
= dependencies
.getAccountManager().getStorageManifestVersion();
288 final var newVersion
= currentVersion
+ 1;
289 final var newStorageRecords
= new ArrayList
<SignalStorageRecord
>();
290 final Map
<RecipientId
, StorageId
> newContactStorageIds
;
291 final Map
<GroupIdV1
, StorageId
> newGroupV1StorageIds
;
292 final Map
<GroupIdV2
, StorageId
> newGroupV2StorageIds
;
294 try (final var connection
= account
.getAccountDatabase().getConnection()) {
295 connection
.setAutoCommit(false);
297 final var recipientIds
= account
.getRecipientStore().getRecipientIds(connection
);
298 newContactStorageIds
= generateContactStorageIds(recipientIds
);
299 for (final var recipientId
: recipientIds
) {
300 final var storageId
= newContactStorageIds
.get(recipientId
);
301 if (storageId
.getType() == ManifestRecord
.Identifier
.Type
.ACCOUNT
.getValue()) {
302 final var recipient
= account
.getRecipientStore().getRecipient(connection
, recipientId
);
303 final var accountRecord
= StorageSyncModels
.localToRemoteRecord(account
.getConfigurationStore(),
305 account
.getUsernameLink(),
307 newStorageRecords
.add(accountRecord
);
309 final var recipient
= account
.getRecipientStore().getRecipient(connection
, recipientId
);
310 final var address
= recipient
.getAddress().getIdentifier();
311 final var identity
= account
.getIdentityKeyStore().getIdentityInfo(connection
, address
);
312 final var record = StorageSyncModels
.localToRemoteRecord(recipient
, identity
, storageId
.getRaw());
313 newStorageRecords
.add(record);
317 final var groupV1Ids
= account
.getGroupStore().getGroupV1Ids(connection
);
318 newGroupV1StorageIds
= generateGroupV1StorageIds(groupV1Ids
);
319 for (final var groupId
: groupV1Ids
) {
320 final var storageId
= newGroupV1StorageIds
.get(groupId
);
321 final var group
= account
.getGroupStore().getGroup(connection
, groupId
);
322 final var record = StorageSyncModels
.localToRemoteRecord(group
, storageId
.getRaw());
323 newStorageRecords
.add(record);
326 final var groupV2Ids
= account
.getGroupStore().getGroupV2Ids(connection
);
327 newGroupV2StorageIds
= generateGroupV2StorageIds(groupV2Ids
);
328 for (final var groupId
: groupV2Ids
) {
329 final var storageId
= newGroupV2StorageIds
.get(groupId
);
330 final var group
= account
.getGroupStore().getGroup(connection
, groupId
);
331 final var record = StorageSyncModels
.localToRemoteRecord(group
, storageId
.getRaw());
332 newStorageRecords
.add(record);
336 } catch (SQLException e
) {
337 throw new RuntimeException("Failed to sync remote storage", e
);
339 final var newStorageIds
= newStorageRecords
.stream().map(SignalStorageRecord
::getId
).toList();
341 final var manifest
= new SignalStorageManifest(newVersion
, account
.getDeviceId(), newStorageIds
);
343 StorageSyncValidations
.validateForcePush(manifest
, newStorageRecords
, account
.getSelfRecipientAddress());
345 final Optional
<SignalStorageManifest
> conflict
;
347 if (newVersion
> 1) {
348 logger
.trace("Force-pushing data. Inserting {} IDs.", newStorageRecords
.size());
349 conflict
= dependencies
.getAccountManager()
350 .resetStorageRecords(storageServiceKey
, manifest
, newStorageRecords
);
352 logger
.trace("First version, normal push. Inserting {} IDs.", newStorageRecords
.size());
353 conflict
= dependencies
.getAccountManager()
354 .writeStorageRecords(storageServiceKey
, manifest
, newStorageRecords
, Collections
.emptyList());
356 } catch (InvalidKeyException e
) {
357 logger
.debug("Hit an invalid key exception, which likely indicates a conflict.", e
);
358 throw new RetryLaterException();
361 if (conflict
.isPresent()) {
362 logger
.debug("Hit a conflict. Trying again.");
363 throw new RetryLaterException();
366 logger
.debug("Force push succeeded. Updating local manifest version to: " + manifest
.getVersion());
367 storeManifestLocally(manifest
);
369 try (final var connection
= account
.getAccountDatabase().getConnection()) {
370 connection
.setAutoCommit(false);
371 account
.getRecipientStore().updateStorageIds(connection
, newContactStorageIds
);
372 account
.getGroupStore().updateStorageIds(connection
, newGroupV1StorageIds
, newGroupV2StorageIds
);
374 // delete all unknown storage ids
375 account
.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection
);
377 } catch (SQLException e
) {
378 throw new RuntimeException("Failed to sync remote storage", e
);
382 private Map
<RecipientId
, StorageId
> generateContactStorageIds(List
<RecipientId
> recipientIds
) {
383 final var selfRecipientId
= account
.getSelfRecipientId();
384 return recipientIds
.stream().collect(Collectors
.toMap(recipientId
-> recipientId
, recipientId
-> {
385 if (recipientId
.equals(selfRecipientId
)) {
386 return StorageId
.forAccount(KeyUtils
.createRawStorageId());
388 return StorageId
.forContact(KeyUtils
.createRawStorageId());
393 private Map
<GroupIdV1
, StorageId
> generateGroupV1StorageIds(List
<GroupIdV1
> groupIds
) {
394 return groupIds
.stream()
395 .collect(Collectors
.toMap(recipientId
-> recipientId
,
396 recipientId
-> StorageId
.forGroupV1(KeyUtils
.createRawStorageId())));
399 private Map
<GroupIdV2
, StorageId
> generateGroupV2StorageIds(List
<GroupIdV2
> groupIds
) {
400 return groupIds
.stream()
401 .collect(Collectors
.toMap(recipientId
-> recipientId
,
402 recipientId
-> StorageId
.forGroupV2(KeyUtils
.createRawStorageId())));
405 private void storeManifestLocally(
406 final SignalStorageManifest remoteManifest
408 account
.setStorageManifestVersion(remoteManifest
.getVersion());
409 account
.setStorageManifest(remoteManifest
);
412 private List
<SignalStorageRecord
> getSignalStorageRecords(
413 final StorageKey storageKey
,
414 final List
<StorageId
> storageIds
415 ) throws IOException
{
416 List
<SignalStorageRecord
> records
;
418 records
= dependencies
.getAccountManager().readStorageRecords(storageKey
, storageIds
);
419 } catch (InvalidKeyException e
) {
420 logger
.warn("Failed to read storage records, ignoring.");
426 private List
<StorageId
> getAllLocalStorageIds(final Connection connection
) throws SQLException
{
427 final var storageIds
= new ArrayList
<StorageId
>();
428 storageIds
.addAll(account
.getUnknownStorageIdStore().getUnknownStorageIds(connection
));
429 storageIds
.addAll(account
.getGroupStore().getStorageIds(connection
));
430 storageIds
.addAll(account
.getRecipientStore().getStorageIds(connection
));
431 storageIds
.add(account
.getRecipientStore().getSelfStorageId(connection
));
435 private List
<SignalStorageRecord
> buildLocalStorageRecords(
436 final Connection connection
,
437 final List
<StorageId
> storageIds
438 ) throws SQLException
{
439 final var records
= new ArrayList
<SignalStorageRecord
>();
440 for (final var storageId
: storageIds
) {
441 final var record = buildLocalStorageRecord(connection
, storageId
);
442 if (record != null) {
449 private SignalStorageRecord
buildLocalStorageRecord(
450 Connection connection
,
452 ) throws SQLException
{
453 return switch (ManifestRecord
.Identifier
.Type
.fromValue(storageId
.getType())) {
454 case ManifestRecord
.Identifier
.Type
.CONTACT
-> {
455 final var recipient
= account
.getRecipientStore().getRecipient(connection
, storageId
);
456 final var address
= recipient
.getAddress().getIdentifier();
457 final var identity
= account
.getIdentityKeyStore().getIdentityInfo(connection
, address
);
458 yield StorageSyncModels
.localToRemoteRecord(recipient
, identity
, storageId
.getRaw());
460 case ManifestRecord
.Identifier
.Type
.GROUPV1
-> {
461 final var groupV1
= account
.getGroupStore().getGroupV1(connection
, storageId
);
462 yield StorageSyncModels
.localToRemoteRecord(groupV1
, storageId
.getRaw());
464 case ManifestRecord
.Identifier
.Type
.GROUPV2
-> {
465 final var groupV2
= account
.getGroupStore().getGroupV2(connection
, storageId
);
466 yield StorageSyncModels
.localToRemoteRecord(groupV2
, storageId
.getRaw());
468 case ManifestRecord
.Identifier
.Type
.ACCOUNT
-> {
469 final var selfRecipient
= account
.getRecipientStore()
470 .getRecipient(connection
, account
.getSelfRecipientId());
471 yield StorageSyncModels
.localToRemoteRecord(account
.getConfigurationStore(),
473 account
.getUsernameLink(),
476 case null, default -> throw new AssertionError("Got unknown local storage record type: " + storageId
);
481 * Given a list of all the local and remote keys you know about, this will
482 * return a result telling
483 * you which keys are exclusively remote and which are exclusively local.
485 * @param remoteIds All remote keys available.
486 * @param localIds All local keys available.
487 * @return An object describing which keys are exclusive to the remote data set
489 * exclusive to the local data set.
491 private static IdDifferenceResult
findIdDifference(
492 Collection
<StorageId
> remoteIds
,
493 Collection
<StorageId
> localIds
495 final var base64Encoder
= Base64
.getEncoder();
496 final var remoteByRawId
= remoteIds
.stream()
497 .collect(Collectors
.toMap(id
-> base64Encoder
.encodeToString(id
.getRaw()), id
-> id
));
498 final var localByRawId
= localIds
.stream()
499 .collect(Collectors
.toMap(id
-> base64Encoder
.encodeToString(id
.getRaw()), id
-> id
));
501 boolean hasTypeMismatch
= remoteByRawId
.size() != remoteIds
.size() || localByRawId
.size() != localIds
.size();
503 final var remoteOnlyRawIds
= SetUtil
.difference(remoteByRawId
.keySet(), localByRawId
.keySet());
504 final var localOnlyRawIds
= SetUtil
.difference(localByRawId
.keySet(), remoteByRawId
.keySet());
505 final var sharedRawIds
= SetUtil
.intersection(localByRawId
.keySet(), remoteByRawId
.keySet());
507 for (String rawId
: sharedRawIds
) {
508 final var remote
= remoteByRawId
.get(rawId
);
509 final var local
= localByRawId
.get(rawId
);
511 if (remote
.getType() != local
.getType() && local
.getType() != 0) {
512 remoteOnlyRawIds
.remove(rawId
);
513 localOnlyRawIds
.remove(rawId
);
514 hasTypeMismatch
= true;
515 logger
.debug("Remote type {} did not match local type {} for {}!",
522 final var remoteOnlyKeys
= remoteOnlyRawIds
.stream().map(remoteByRawId
::get
).toList();
523 final var localOnlyKeys
= localOnlyRawIds
.stream().map(localByRawId
::get
).toList();
525 return new IdDifferenceResult(remoteOnlyKeys
, localOnlyKeys
, hasTypeMismatch
);
528 private List
<StorageId
> processKnownRecords(
529 final Connection connection
,
530 List
<SignalStorageRecord
> records
531 ) throws SQLException
{
532 final var unknownRecords
= new ArrayList
<StorageId
>();
534 final var accountRecordProcessor
= new AccountRecordProcessor(account
, connection
, context
.getJobExecutor());
535 final var contactRecordProcessor
= new ContactRecordProcessor(account
, connection
, context
.getJobExecutor());
536 final var groupV1RecordProcessor
= new GroupV1RecordProcessor(account
, connection
);
537 final var groupV2RecordProcessor
= new GroupV2RecordProcessor(account
, connection
);
539 for (final var record : records
) {
540 logger
.debug("Reading record of type {}", record.getType());
541 switch (ManifestRecord
.Identifier
.Type
.fromValue(record.getType())) {
542 case ACCOUNT
-> accountRecordProcessor
.process(record.getAccount().get());
543 case GROUPV1
-> groupV1RecordProcessor
.process(record.getGroupV1().get());
544 case GROUPV2
-> groupV2RecordProcessor
.process(record.getGroupV2().get());
545 case CONTACT
-> contactRecordProcessor
.process(record.getContact().get());
546 case null, default -> unknownRecords
.add(record.getId());
550 return unknownRecords
;
554 * hasTypeMismatches is True if there exist some keys that have matching raw ID's but different types, otherwise false.
556 private record IdDifferenceResult(
557 List
<StorageId
> remoteOnlyIds
, List
<StorageId
> localOnlyIds
, boolean hasTypeMismatches
560 public boolean isEmpty() {
561 return remoteOnlyIds
.isEmpty() && localOnlyIds
.isEmpty();
565 private static class RetryLaterException
extends Throwable
{}