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
, final SignalStorageManifest remoteManifest
, final boolean needsForcePush
222 ) throws IOException
, RetryLaterException
{
223 final WriteOperationResult remoteWriteOperation
;
224 try (final var connection
= account
.getAccountDatabase().getConnection()) {
225 connection
.setAutoCommit(false);
227 final var localStorageIds
= getAllLocalStorageIds(connection
);
228 final var idDifference
= findIdDifference(remoteManifest
.getStorageIds(), localStorageIds
);
229 logger
.debug("ID Difference :: " + idDifference
);
231 final var remoteDeletes
= idDifference
.remoteOnlyIds().stream().map(StorageId
::getRaw
).toList();
232 final var remoteInserts
= buildLocalStorageRecords(connection
, idDifference
.localOnlyIds());
233 // TODO check if local storage record proto matches remote, then reset to remote storage_id
235 remoteWriteOperation
= new WriteOperationResult(new SignalStorageManifest(remoteManifest
.getVersion() + 1,
236 account
.getDeviceId(),
237 localStorageIds
), remoteInserts
, remoteDeletes
);
240 } catch (SQLException e
) {
241 throw new RuntimeException("Failed to sync remote storage", e
);
244 if (remoteWriteOperation
.isEmpty()) {
245 logger
.debug("No remote writes needed. Still at version: " + remoteManifest
.getVersion());
249 logger
.debug("We have something to write remotely.");
250 logger
.debug("WriteOperationResult :: " + remoteWriteOperation
);
252 StorageSyncValidations
.validate(remoteWriteOperation
,
255 account
.getSelfRecipientAddress());
257 final Optional
<SignalStorageManifest
> conflict
;
259 conflict
= dependencies
.getAccountManager()
260 .writeStorageRecords(storageKey
,
261 remoteWriteOperation
.manifest(),
262 remoteWriteOperation
.inserts(),
263 remoteWriteOperation
.deletes());
264 } catch (InvalidKeyException e
) {
265 logger
.warn("Failed to decrypt conflicting storage manifest: {}", e
.getMessage());
266 throw new IOException(e
);
269 if (conflict
.isPresent()) {
270 logger
.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
271 throw new RetryLaterException();
274 logger
.debug("Saved new manifest. Now at version: " + remoteWriteOperation
.manifest().getVersion());
275 storeManifestLocally(remoteWriteOperation
.manifest());
280 private void forcePushToStorage(
281 final StorageKey storageServiceKey
282 ) throws IOException
, RetryLaterException
{
283 logger
.debug("Force pushing local state to remote storage");
285 final var currentVersion
= dependencies
.getAccountManager().getStorageManifestVersion();
286 final var newVersion
= currentVersion
+ 1;
287 final var newStorageRecords
= new ArrayList
<SignalStorageRecord
>();
288 final Map
<RecipientId
, StorageId
> newContactStorageIds
;
289 final Map
<GroupIdV1
, StorageId
> newGroupV1StorageIds
;
290 final Map
<GroupIdV2
, StorageId
> newGroupV2StorageIds
;
292 try (final var connection
= account
.getAccountDatabase().getConnection()) {
293 connection
.setAutoCommit(false);
295 final var recipientIds
= account
.getRecipientStore().getRecipientIds(connection
);
296 newContactStorageIds
= generateContactStorageIds(recipientIds
);
297 for (final var recipientId
: recipientIds
) {
298 final var storageId
= newContactStorageIds
.get(recipientId
);
299 if (storageId
.getType() == ManifestRecord
.Identifier
.Type
.ACCOUNT
.getValue()) {
300 final var recipient
= account
.getRecipientStore().getRecipient(connection
, recipientId
);
301 final var accountRecord
= StorageSyncModels
.localToRemoteRecord(account
.getConfigurationStore(),
303 account
.getUsernameLink(),
305 newStorageRecords
.add(accountRecord
);
307 final var recipient
= account
.getRecipientStore().getRecipient(connection
, recipientId
);
308 final var address
= recipient
.getAddress().getIdentifier();
309 final var identity
= account
.getIdentityKeyStore().getIdentityInfo(connection
, address
);
310 final var record = StorageSyncModels
.localToRemoteRecord(recipient
, identity
, storageId
.getRaw());
311 newStorageRecords
.add(record);
315 final var groupV1Ids
= account
.getGroupStore().getGroupV1Ids(connection
);
316 newGroupV1StorageIds
= generateGroupV1StorageIds(groupV1Ids
);
317 for (final var groupId
: groupV1Ids
) {
318 final var storageId
= newGroupV1StorageIds
.get(groupId
);
319 final var group
= account
.getGroupStore().getGroup(connection
, groupId
);
320 final var record = StorageSyncModels
.localToRemoteRecord(group
, storageId
.getRaw());
321 newStorageRecords
.add(record);
324 final var groupV2Ids
= account
.getGroupStore().getGroupV2Ids(connection
);
325 newGroupV2StorageIds
= generateGroupV2StorageIds(groupV2Ids
);
326 for (final var groupId
: groupV2Ids
) {
327 final var storageId
= newGroupV2StorageIds
.get(groupId
);
328 final var group
= account
.getGroupStore().getGroup(connection
, groupId
);
329 final var record = StorageSyncModels
.localToRemoteRecord(group
, storageId
.getRaw());
330 newStorageRecords
.add(record);
334 } catch (SQLException e
) {
335 throw new RuntimeException("Failed to sync remote storage", e
);
337 final var newStorageIds
= newStorageRecords
.stream().map(SignalStorageRecord
::getId
).toList();
339 final var manifest
= new SignalStorageManifest(newVersion
, account
.getDeviceId(), newStorageIds
);
341 StorageSyncValidations
.validateForcePush(manifest
, newStorageRecords
, account
.getSelfRecipientAddress());
343 final Optional
<SignalStorageManifest
> conflict
;
345 if (newVersion
> 1) {
346 logger
.trace("Force-pushing data. Inserting {} IDs.", newStorageRecords
.size());
347 conflict
= dependencies
.getAccountManager()
348 .resetStorageRecords(storageServiceKey
, manifest
, newStorageRecords
);
350 logger
.trace("First version, normal push. Inserting {} IDs.", newStorageRecords
.size());
351 conflict
= dependencies
.getAccountManager()
352 .writeStorageRecords(storageServiceKey
, manifest
, newStorageRecords
, Collections
.emptyList());
354 } catch (InvalidKeyException e
) {
355 logger
.debug("Hit an invalid key exception, which likely indicates a conflict.", e
);
356 throw new RetryLaterException();
359 if (conflict
.isPresent()) {
360 logger
.debug("Hit a conflict. Trying again.");
361 throw new RetryLaterException();
364 logger
.debug("Force push succeeded. Updating local manifest version to: " + manifest
.getVersion());
365 storeManifestLocally(manifest
);
367 try (final var connection
= account
.getAccountDatabase().getConnection()) {
368 connection
.setAutoCommit(false);
369 account
.getRecipientStore().updateStorageIds(connection
, newContactStorageIds
);
370 account
.getGroupStore().updateStorageIds(connection
, newGroupV1StorageIds
, newGroupV2StorageIds
);
372 // delete all unknown storage ids
373 account
.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection
);
375 } catch (SQLException e
) {
376 throw new RuntimeException("Failed to sync remote storage", e
);
380 private Map
<RecipientId
, StorageId
> generateContactStorageIds(List
<RecipientId
> recipientIds
) {
381 final var selfRecipientId
= account
.getSelfRecipientId();
382 return recipientIds
.stream().collect(Collectors
.toMap(recipientId
-> recipientId
, recipientId
-> {
383 if (recipientId
.equals(selfRecipientId
)) {
384 return StorageId
.forAccount(KeyUtils
.createRawStorageId());
386 return StorageId
.forContact(KeyUtils
.createRawStorageId());
391 private Map
<GroupIdV1
, StorageId
> generateGroupV1StorageIds(List
<GroupIdV1
> groupIds
) {
392 return groupIds
.stream()
393 .collect(Collectors
.toMap(recipientId
-> recipientId
,
394 recipientId
-> StorageId
.forGroupV1(KeyUtils
.createRawStorageId())));
397 private Map
<GroupIdV2
, StorageId
> generateGroupV2StorageIds(List
<GroupIdV2
> groupIds
) {
398 return groupIds
.stream()
399 .collect(Collectors
.toMap(recipientId
-> recipientId
,
400 recipientId
-> StorageId
.forGroupV2(KeyUtils
.createRawStorageId())));
403 private void storeManifestLocally(
404 final SignalStorageManifest remoteManifest
406 account
.setStorageManifestVersion(remoteManifest
.getVersion());
407 account
.setStorageManifest(remoteManifest
);
410 private List
<SignalStorageRecord
> getSignalStorageRecords(
411 final StorageKey storageKey
, final List
<StorageId
> storageIds
412 ) throws IOException
{
413 List
<SignalStorageRecord
> records
;
415 records
= dependencies
.getAccountManager().readStorageRecords(storageKey
, storageIds
);
416 } catch (InvalidKeyException e
) {
417 logger
.warn("Failed to read storage records, ignoring.");
423 private List
<StorageId
> getAllLocalStorageIds(final Connection connection
) throws SQLException
{
424 final var storageIds
= new ArrayList
<StorageId
>();
425 storageIds
.addAll(account
.getUnknownStorageIdStore().getUnknownStorageIds(connection
));
426 storageIds
.addAll(account
.getGroupStore().getStorageIds(connection
));
427 storageIds
.addAll(account
.getRecipientStore().getStorageIds(connection
));
428 storageIds
.add(account
.getRecipientStore().getSelfStorageId(connection
));
432 private List
<SignalStorageRecord
> buildLocalStorageRecords(
433 final Connection connection
, final List
<StorageId
> storageIds
434 ) throws SQLException
{
435 final var records
= new ArrayList
<SignalStorageRecord
>();
436 for (final var storageId
: storageIds
) {
437 final var record = buildLocalStorageRecord(connection
, storageId
);
438 if (record != null) {
445 private SignalStorageRecord
buildLocalStorageRecord(
446 Connection connection
, StorageId storageId
447 ) throws SQLException
{
448 return switch (ManifestRecord
.Identifier
.Type
.fromValue(storageId
.getType())) {
449 case ManifestRecord
.Identifier
.Type
.CONTACT
-> {
450 final var recipient
= account
.getRecipientStore().getRecipient(connection
, storageId
);
451 final var address
= recipient
.getAddress().getIdentifier();
452 final var identity
= account
.getIdentityKeyStore().getIdentityInfo(connection
, address
);
453 yield StorageSyncModels
.localToRemoteRecord(recipient
, identity
, storageId
.getRaw());
455 case ManifestRecord
.Identifier
.Type
.GROUPV1
-> {
456 final var groupV1
= account
.getGroupStore().getGroupV1(connection
, storageId
);
457 yield StorageSyncModels
.localToRemoteRecord(groupV1
, storageId
.getRaw());
459 case ManifestRecord
.Identifier
.Type
.GROUPV2
-> {
460 final var groupV2
= account
.getGroupStore().getGroupV2(connection
, storageId
);
461 yield StorageSyncModels
.localToRemoteRecord(groupV2
, storageId
.getRaw());
463 case ManifestRecord
.Identifier
.Type
.ACCOUNT
-> {
464 final var selfRecipient
= account
.getRecipientStore()
465 .getRecipient(connection
, account
.getSelfRecipientId());
466 yield StorageSyncModels
.localToRemoteRecord(account
.getConfigurationStore(),
468 account
.getUsernameLink(),
471 case null, default -> throw new AssertionError("Got unknown local storage record type: " + storageId
);
476 * Given a list of all the local and remote keys you know about, this will
477 * return a result telling
478 * you which keys are exclusively remote and which are exclusively local.
480 * @param remoteIds All remote keys available.
481 * @param localIds All local keys available.
482 * @return An object describing which keys are exclusive to the remote data set
484 * exclusive to the local data set.
486 private static IdDifferenceResult
findIdDifference(
487 Collection
<StorageId
> remoteIds
, Collection
<StorageId
> localIds
489 final var base64Encoder
= Base64
.getEncoder();
490 final var remoteByRawId
= remoteIds
.stream()
491 .collect(Collectors
.toMap(id
-> base64Encoder
.encodeToString(id
.getRaw()), id
-> id
));
492 final var localByRawId
= localIds
.stream()
493 .collect(Collectors
.toMap(id
-> base64Encoder
.encodeToString(id
.getRaw()), id
-> id
));
495 boolean hasTypeMismatch
= remoteByRawId
.size() != remoteIds
.size() || localByRawId
.size() != localIds
.size();
497 final var remoteOnlyRawIds
= SetUtil
.difference(remoteByRawId
.keySet(), localByRawId
.keySet());
498 final var localOnlyRawIds
= SetUtil
.difference(localByRawId
.keySet(), remoteByRawId
.keySet());
499 final var sharedRawIds
= SetUtil
.intersection(localByRawId
.keySet(), remoteByRawId
.keySet());
501 for (String rawId
: sharedRawIds
) {
502 final var remote
= remoteByRawId
.get(rawId
);
503 final var local
= localByRawId
.get(rawId
);
505 if (remote
.getType() != local
.getType() && local
.getType() != 0) {
506 remoteOnlyRawIds
.remove(rawId
);
507 localOnlyRawIds
.remove(rawId
);
508 hasTypeMismatch
= true;
509 logger
.debug("Remote type {} did not match local type {} for {}!",
516 final var remoteOnlyKeys
= remoteOnlyRawIds
.stream().map(remoteByRawId
::get
).toList();
517 final var localOnlyKeys
= localOnlyRawIds
.stream().map(localByRawId
::get
).toList();
519 return new IdDifferenceResult(remoteOnlyKeys
, localOnlyKeys
, hasTypeMismatch
);
522 private List
<StorageId
> processKnownRecords(
523 final Connection connection
, List
<SignalStorageRecord
> records
524 ) throws SQLException
{
525 final var unknownRecords
= new ArrayList
<StorageId
>();
527 final var accountRecordProcessor
= new AccountRecordProcessor(account
, connection
, context
.getJobExecutor());
528 final var contactRecordProcessor
= new ContactRecordProcessor(account
, connection
, context
.getJobExecutor());
529 final var groupV1RecordProcessor
= new GroupV1RecordProcessor(account
, connection
);
530 final var groupV2RecordProcessor
= new GroupV2RecordProcessor(account
, connection
);
532 for (final var record : records
) {
533 logger
.debug("Reading record of type {}", record.getType());
534 switch (ManifestRecord
.Identifier
.Type
.fromValue(record.getType())) {
535 case ACCOUNT
-> accountRecordProcessor
.process(record.getAccount().get());
536 case GROUPV1
-> groupV1RecordProcessor
.process(record.getGroupV1().get());
537 case GROUPV2
-> groupV2RecordProcessor
.process(record.getGroupV2().get());
538 case CONTACT
-> contactRecordProcessor
.process(record.getContact().get());
539 case null, default -> unknownRecords
.add(record.getId());
543 return unknownRecords
;
547 * hasTypeMismatches is True if there exist some keys that have matching raw ID's but different types, otherwise false.
549 private record IdDifferenceResult(
550 List
<StorageId
> remoteOnlyIds
, List
<StorageId
> localOnlyIds
, boolean hasTypeMismatches
553 public boolean isEmpty() {
554 return remoteOnlyIds
.isEmpty() && localOnlyIds
.isEmpty();
558 private static class RetryLaterException
extends Throwable
{}