]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
8c6b3c528ed3a1dea870ad379ae0b7c69e530f09
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / StorageHelper.java
1 package org.asamk.signal.manager.helper;
2
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;
25
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;
34 import java.util.Map;
35 import java.util.Optional;
36 import java.util.stream.Collectors;
37
38 public class StorageHelper {
39
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());
45
46 private final SignalAccount account;
47 private final SignalDependencies dependencies;
48 private final Context context;
49
50 public StorageHelper(final Context context) {
51 this.account = context.getAccount();
52 this.dependencies = context.getDependencies();
53 this.context = context;
54 }
55
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();
62 }
63 return;
64 }
65
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;
70 try {
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()) {
77 try {
78 forcePushToStorage(storageKey);
79 } catch (RetryLaterException rle) {
80 // TODO retry later
81 return;
82 }
83 }
84 return;
85 }
86
87 logger.trace("Manifest versions: local {}, remote {}", localManifestVersion, remoteManifest.getVersion());
88
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.");
95 }
96 logger.trace("Done reading data from remote storage");
97
98 if (localManifest != remoteManifest) {
99 storeManifestLocally(remoteManifest);
100 }
101
102 readRecordsWithPreviouslyUnknownTypes(storageKey);
103
104 logger.trace("Adding missing storageIds to local data");
105 account.getRecipientStore().setMissingStorageIds();
106 account.getGroupStore().setMissingStorageIds();
107
108 var needsMultiDeviceSync = false;
109 try {
110 needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush);
111 } catch (RetryLaterException e) {
112 // TODO retry later
113 return;
114 }
115
116 if (needsForcePush) {
117 logger.debug("Doing a force push.");
118 try {
119 forcePushToStorage(storageKey);
120 needsMultiDeviceSync = true;
121 } catch (RetryLaterException e) {
122 // TODO retry later
123 return;
124 }
125 }
126
127 if (needsMultiDeviceSync) {
128 context.getSyncHelper().sendSyncFetchStorageMessage();
129 }
130
131 logger.debug("Done syncing data with remote storage");
132 }
133
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);
142
143 var idDifference = findIdDifference(remoteManifest.getStorageIds(), localManifest.getStorageIds());
144
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;
148 }
149
150 logger.debug("Pre-Merge ID Difference :: " + idDifference);
151
152 if (!idDifference.localOnlyIds().isEmpty()) {
153 final var updated = account.getRecipientStore()
154 .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection, idDifference.localOnlyIds());
155
156 if (updated > 0) {
157 logger.warn(
158 "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
159 updated);
160 }
161 }
162
163 if (!idDifference.isEmpty()) {
164 final var remoteOnlyRecords = getSignalStorageRecords(storageKey, idDifference.remoteOnlyIds());
165
166 if (remoteOnlyRecords.size() != idDifference.remoteOnlyIds().size()) {
167 logger.debug("Could not find all remote-only records! Requested: "
168 + idDifference.remoteOnlyIds()
169 .size()
170 + ", Found: "
171 + remoteOnlyRecords.size()
172 + ". These stragglers should naturally get deleted during the sync.");
173 }
174
175 final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
176 final var unknownDeletes = idDifference.localOnlyIds()
177 .stream()
178 .filter(id -> !KNOWN_TYPES.contains(id.getType()))
179 .toList();
180
181 logger.debug("Storage ids with unknown type: {} inserts, {} deletes",
182 unknownInserts.size(),
183 unknownDeletes.size());
184
185 account.getUnknownStorageIdStore().addUnknownStorageIds(connection, unknownInserts);
186 account.getUnknownStorageIdStore().deleteUnknownStorageIds(connection, unknownDeletes);
187 } else {
188 logger.debug("Remote version was newer, but there were no remote-only IDs.");
189 }
190 connection.commit();
191 } catch (SQLException e) {
192 throw new RuntimeException("Failed to sync remote storage", e);
193 }
194 return needsForcePush;
195 }
196
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);
202
203 if (!knownUnknownIds.isEmpty()) {
204 logger.debug("We have " + knownUnknownIds.size() + " unknown records that we can now process.");
205
206 final var remote = getSignalStorageRecords(storageKey, knownUnknownIds);
207
208 logger.debug("Found " + remote.size() + " of the known-unknowns remotely.");
209
210 processKnownRecords(connection, remote);
211 account.getUnknownStorageIdStore()
212 .deleteUnknownStorageIds(connection, remote.stream().map(SignalStorageRecord::getId).toList());
213 }
214 connection.commit();
215 } catch (SQLException e) {
216 throw new RuntimeException("Failed to sync remote storage", e);
217 }
218 }
219
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);
228
229 final var localStorageIds = getAllLocalStorageIds(connection);
230 final var idDifference = findIdDifference(remoteManifest.getStorageIds(), localStorageIds);
231 logger.debug("ID Difference :: " + idDifference);
232
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
236
237 remoteWriteOperation = new WriteOperationResult(new SignalStorageManifest(remoteManifest.getVersion() + 1,
238 account.getDeviceId(),
239 localStorageIds), remoteInserts, remoteDeletes);
240
241 connection.commit();
242 } catch (SQLException e) {
243 throw new RuntimeException("Failed to sync remote storage", e);
244 }
245
246 if (remoteWriteOperation.isEmpty()) {
247 logger.debug("No remote writes needed. Still at version: " + remoteManifest.getVersion());
248 return false;
249 }
250
251 logger.debug("We have something to write remotely.");
252 logger.debug("WriteOperationResult :: " + remoteWriteOperation);
253
254 StorageSyncValidations.validate(remoteWriteOperation,
255 remoteManifest,
256 needsForcePush,
257 account.getSelfRecipientAddress());
258
259 final Optional<SignalStorageManifest> conflict;
260 try {
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);
269 }
270
271 if (conflict.isPresent()) {
272 logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
273 throw new RetryLaterException();
274 }
275
276 logger.debug("Saved new manifest. Now at version: " + remoteWriteOperation.manifest().getVersion());
277 storeManifestLocally(remoteWriteOperation.manifest());
278
279 return true;
280 }
281
282 private void forcePushToStorage(
283 final StorageKey storageServiceKey
284 ) throws IOException, RetryLaterException {
285 logger.debug("Force pushing local state to remote storage");
286
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;
293
294 try (final var connection = account.getAccountDatabase().getConnection()) {
295 connection.setAutoCommit(false);
296
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(),
304 recipient,
305 account.getUsernameLink(),
306 storageId.getRaw());
307 newStorageRecords.add(accountRecord);
308 } else {
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);
314 }
315 }
316
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);
324 }
325
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);
333 }
334
335 connection.commit();
336 } catch (SQLException e) {
337 throw new RuntimeException("Failed to sync remote storage", e);
338 }
339 final var newStorageIds = newStorageRecords.stream().map(SignalStorageRecord::getId).toList();
340
341 final var manifest = new SignalStorageManifest(newVersion, account.getDeviceId(), newStorageIds);
342
343 StorageSyncValidations.validateForcePush(manifest, newStorageRecords, account.getSelfRecipientAddress());
344
345 final Optional<SignalStorageManifest> conflict;
346 try {
347 if (newVersion > 1) {
348 logger.trace("Force-pushing data. Inserting {} IDs.", newStorageRecords.size());
349 conflict = dependencies.getAccountManager()
350 .resetStorageRecords(storageServiceKey, manifest, newStorageRecords);
351 } else {
352 logger.trace("First version, normal push. Inserting {} IDs.", newStorageRecords.size());
353 conflict = dependencies.getAccountManager()
354 .writeStorageRecords(storageServiceKey, manifest, newStorageRecords, Collections.emptyList());
355 }
356 } catch (InvalidKeyException e) {
357 logger.debug("Hit an invalid key exception, which likely indicates a conflict.", e);
358 throw new RetryLaterException();
359 }
360
361 if (conflict.isPresent()) {
362 logger.debug("Hit a conflict. Trying again.");
363 throw new RetryLaterException();
364 }
365
366 logger.debug("Force push succeeded. Updating local manifest version to: " + manifest.getVersion());
367 storeManifestLocally(manifest);
368
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);
373
374 // delete all unknown storage ids
375 account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
376 connection.commit();
377 } catch (SQLException e) {
378 throw new RuntimeException("Failed to sync remote storage", e);
379 }
380 }
381
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());
387 } else {
388 return StorageId.forContact(KeyUtils.createRawStorageId());
389 }
390 }));
391 }
392
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())));
397 }
398
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())));
403 }
404
405 private void storeManifestLocally(
406 final SignalStorageManifest remoteManifest
407 ) {
408 account.setStorageManifestVersion(remoteManifest.getVersion());
409 account.setStorageManifest(remoteManifest);
410 }
411
412 private List<SignalStorageRecord> getSignalStorageRecords(
413 final StorageKey storageKey,
414 final List<StorageId> storageIds
415 ) throws IOException {
416 List<SignalStorageRecord> records;
417 try {
418 records = dependencies.getAccountManager().readStorageRecords(storageKey, storageIds);
419 } catch (InvalidKeyException e) {
420 logger.warn("Failed to read storage records, ignoring.");
421 return List.of();
422 }
423 return records;
424 }
425
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));
432 return storageIds;
433 }
434
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) {
443 records.add(record);
444 }
445 }
446 return records;
447 }
448
449 private SignalStorageRecord buildLocalStorageRecord(
450 Connection connection,
451 StorageId storageId
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());
459 }
460 case ManifestRecord.Identifier.Type.GROUPV1 -> {
461 final var groupV1 = account.getGroupStore().getGroupV1(connection, storageId);
462 yield StorageSyncModels.localToRemoteRecord(groupV1, storageId.getRaw());
463 }
464 case ManifestRecord.Identifier.Type.GROUPV2 -> {
465 final var groupV2 = account.getGroupStore().getGroupV2(connection, storageId);
466 yield StorageSyncModels.localToRemoteRecord(groupV2, storageId.getRaw());
467 }
468 case ManifestRecord.Identifier.Type.ACCOUNT -> {
469 final var selfRecipient = account.getRecipientStore()
470 .getRecipient(connection, account.getSelfRecipientId());
471 yield StorageSyncModels.localToRemoteRecord(account.getConfigurationStore(),
472 selfRecipient,
473 account.getUsernameLink(),
474 storageId.getRaw());
475 }
476 case null, default -> throw new AssertionError("Got unknown local storage record type: " + storageId);
477 };
478 }
479
480 /**
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.
484 *
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
488 * and which keys are
489 * exclusive to the local data set.
490 */
491 private static IdDifferenceResult findIdDifference(
492 Collection<StorageId> remoteIds,
493 Collection<StorageId> localIds
494 ) {
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));
500
501 boolean hasTypeMismatch = remoteByRawId.size() != remoteIds.size() || localByRawId.size() != localIds.size();
502
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());
506
507 for (String rawId : sharedRawIds) {
508 final var remote = remoteByRawId.get(rawId);
509 final var local = localByRawId.get(rawId);
510
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 {}!",
516 remote.getType(),
517 local.getType(),
518 rawId);
519 }
520 }
521
522 final var remoteOnlyKeys = remoteOnlyRawIds.stream().map(remoteByRawId::get).toList();
523 final var localOnlyKeys = localOnlyRawIds.stream().map(localByRawId::get).toList();
524
525 return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
526 }
527
528 private List<StorageId> processKnownRecords(
529 final Connection connection,
530 List<SignalStorageRecord> records
531 ) throws SQLException {
532 final var unknownRecords = new ArrayList<StorageId>();
533
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);
538
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());
547 }
548 }
549
550 return unknownRecords;
551 }
552
553 /**
554 * hasTypeMismatches is True if there exist some keys that have matching raw ID's but different types, otherwise false.
555 */
556 private record IdDifferenceResult(
557 List<StorageId> remoteOnlyIds, List<StorageId> localOnlyIds, boolean hasTypeMismatches
558 ) {
559
560 public boolean isEmpty() {
561 return remoteOnlyIds.isEmpty() && localOnlyIds.isEmpty();
562 }
563 }
564
565 private static class RetryLaterException extends Throwable {}
566 }