]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
45da42b12a59e642b1f3ea9a7375ff4fffb69279
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / recipients / RecipientStore.java
1 package org.asamk.signal.manager.storage.recipients;
2
3 import org.asamk.signal.manager.api.Pair;
4 import org.asamk.signal.manager.api.UnregisteredRecipientException;
5 import org.asamk.signal.manager.storage.Database;
6 import org.asamk.signal.manager.storage.Utils;
7 import org.asamk.signal.manager.storage.contacts.ContactsStore;
8 import org.asamk.signal.manager.storage.profiles.ProfileStore;
9 import org.signal.libsignal.zkgroup.InvalidInputException;
10 import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
11 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
12 import org.slf4j.Logger;
13 import org.slf4j.LoggerFactory;
14 import org.whispersystems.signalservice.api.push.ACI;
15 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
16 import org.whispersystems.signalservice.api.util.UuidUtil;
17
18 import java.sql.Connection;
19 import java.sql.ResultSet;
20 import java.sql.SQLException;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Collection;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.Objects;
28 import java.util.Optional;
29 import java.util.Set;
30 import java.util.UUID;
31 import java.util.function.Supplier;
32 import java.util.stream.Collectors;
33
34 public class RecipientStore implements RecipientIdCreator, RecipientResolver, RecipientTrustedResolver, ContactsStore, ProfileStore {
35
36 private final static Logger logger = LoggerFactory.getLogger(RecipientStore.class);
37 private static final String TABLE_RECIPIENT = "recipient";
38 private static final String SQL_IS_CONTACT = "r.given_name IS NOT NULL OR r.family_name IS NOT NULL OR r.expiration_time > 0 OR r.profile_sharing = TRUE OR r.color IS NOT NULL OR r.blocked = TRUE OR r.archived = TRUE";
39
40 private final RecipientMergeHandler recipientMergeHandler;
41 private final SelfAddressProvider selfAddressProvider;
42 private final Database database;
43
44 private final Object recipientsLock = new Object();
45 private final Map<Long, Long> recipientsMerged = new HashMap<>();
46
47 public static void createSql(Connection connection) throws SQLException {
48 // When modifying the CREATE statement here, also add a migration in AccountDatabase.java
49 try (final var statement = connection.createStatement()) {
50 statement.executeUpdate("""
51 CREATE TABLE recipient (
52 _id INTEGER PRIMARY KEY AUTOINCREMENT,
53 number TEXT UNIQUE,
54 uuid BLOB UNIQUE,
55 profile_key BLOB,
56 profile_key_credential BLOB,
57
58 given_name TEXT,
59 family_name TEXT,
60 color TEXT,
61
62 expiration_time INTEGER NOT NULL DEFAULT 0,
63 blocked INTEGER NOT NULL DEFAULT FALSE,
64 archived INTEGER NOT NULL DEFAULT FALSE,
65 profile_sharing INTEGER NOT NULL DEFAULT FALSE,
66
67 profile_last_update_timestamp INTEGER NOT NULL DEFAULT 0,
68 profile_given_name TEXT,
69 profile_family_name TEXT,
70 profile_about TEXT,
71 profile_about_emoji TEXT,
72 profile_avatar_url_path TEXT,
73 profile_mobile_coin_address BLOB,
74 profile_unidentified_access_mode TEXT,
75 profile_capabilities TEXT
76 ) STRICT;
77 """);
78 }
79 }
80
81 public RecipientStore(
82 final RecipientMergeHandler recipientMergeHandler,
83 final SelfAddressProvider selfAddressProvider,
84 final Database database
85 ) {
86 this.recipientMergeHandler = recipientMergeHandler;
87 this.selfAddressProvider = selfAddressProvider;
88 this.database = database;
89 }
90
91 public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
92 final var sql = (
93 """
94 SELECT r.number, r.uuid
95 FROM %s r
96 WHERE r._id = ?
97 """
98 ).formatted(TABLE_RECIPIENT);
99 try (final var connection = database.getConnection()) {
100 try (final var statement = connection.prepareStatement(sql)) {
101 statement.setLong(1, recipientId.id());
102 return Utils.executeQuerySingleRow(statement, this::getRecipientAddressFromResultSet);
103 }
104 } catch (SQLException e) {
105 throw new RuntimeException("Failed read from recipient store", e);
106 }
107 }
108
109 public Collection<RecipientId> getRecipientIdsWithEnabledProfileSharing() {
110 final var sql = (
111 """
112 SELECT r._id
113 FROM %s r
114 WHERE r.blocked = FALSE AND r.profile_sharing = TRUE
115 """
116 ).formatted(TABLE_RECIPIENT);
117 try (final var connection = database.getConnection()) {
118 try (final var statement = connection.prepareStatement(sql)) {
119 try (var result = Utils.executeQueryForStream(statement, this::getRecipientIdFromResultSet)) {
120 return result.toList();
121 }
122 }
123 } catch (SQLException e) {
124 throw new RuntimeException("Failed read from recipient store", e);
125 }
126 }
127
128 @Override
129 public RecipientId resolveRecipient(final long rawRecipientId) {
130 final var sql = (
131 """
132 SELECT r._id
133 FROM %s r
134 WHERE r._id = ?
135 """
136 ).formatted(TABLE_RECIPIENT);
137 try (final var connection = database.getConnection()) {
138 try (final var statement = connection.prepareStatement(sql)) {
139 statement.setLong(1, rawRecipientId);
140 return Utils.executeQueryForOptional(statement, this::getRecipientIdFromResultSet).orElse(null);
141 }
142 } catch (SQLException e) {
143 throw new RuntimeException("Failed read from recipient store", e);
144 }
145 }
146
147 /**
148 * Should only be used for recipientIds from the database.
149 * Where the foreign key relations ensure a valid recipientId.
150 */
151 @Override
152 public RecipientId create(final long recipientId) {
153 return new RecipientId(recipientId, this);
154 }
155
156 public RecipientId resolveRecipient(
157 final String number, Supplier<ACI> aciSupplier
158 ) throws UnregisteredRecipientException {
159 final Optional<RecipientWithAddress> byNumber;
160 try (final var connection = database.getConnection()) {
161 byNumber = findByNumber(connection, number);
162 } catch (SQLException e) {
163 throw new RuntimeException("Failed read from recipient store", e);
164 }
165 if (byNumber.isEmpty() || byNumber.get().address().uuid().isEmpty()) {
166 final var aci = aciSupplier.get();
167 if (aci == null) {
168 throw new UnregisteredRecipientException(new RecipientAddress(null, number));
169 }
170
171 return resolveRecipient(new RecipientAddress(aci.uuid(), number), false, false);
172 }
173 return byNumber.get().id();
174 }
175
176 public RecipientId resolveRecipient(RecipientAddress address) {
177 return resolveRecipient(address, false, false);
178 }
179
180 @Override
181 public RecipientId resolveSelfRecipientTrusted(RecipientAddress address) {
182 return resolveRecipient(address, true, true);
183 }
184
185 public RecipientId resolveRecipientTrusted(RecipientAddress address) {
186 return resolveRecipient(address, true, false);
187 }
188
189 @Override
190 public RecipientId resolveRecipientTrusted(SignalServiceAddress address) {
191 return resolveRecipient(new RecipientAddress(address), true, false);
192 }
193
194 @Override
195 public void storeContact(RecipientId recipientId, final Contact contact) {
196 try (final var connection = database.getConnection()) {
197 storeContact(connection, recipientId, contact);
198 } catch (SQLException e) {
199 throw new RuntimeException("Failed update recipient store", e);
200 }
201 }
202
203 @Override
204 public Contact getContact(RecipientId recipientId) {
205 try (final var connection = database.getConnection()) {
206 return getContact(connection, recipientId);
207 } catch (SQLException e) {
208 throw new RuntimeException("Failed read from recipient store", e);
209 }
210 }
211
212 @Override
213 public List<Pair<RecipientId, Contact>> getContacts() {
214 final var sql = (
215 """
216 SELECT r._id, r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived
217 FROM %s r
218 WHERE (r.number IS NOT NULL OR r.uuid IS NOT NULL) AND %s
219 """
220 ).formatted(TABLE_RECIPIENT, SQL_IS_CONTACT);
221 try (final var connection = database.getConnection()) {
222 try (final var statement = connection.prepareStatement(sql)) {
223 try (var result = Utils.executeQueryForStream(statement,
224 resultSet -> new Pair<>(getRecipientIdFromResultSet(resultSet),
225 getContactFromResultSet(resultSet)))) {
226 return result.toList();
227 }
228 }
229 } catch (SQLException e) {
230 throw new RuntimeException("Failed read from recipient store", e);
231 }
232 }
233
234 public List<Recipient> getRecipients(
235 boolean onlyContacts, Optional<Boolean> blocked, Set<RecipientId> recipientIds, Optional<String> name
236 ) {
237 final var sqlWhere = new ArrayList<String>();
238 if (onlyContacts) {
239 sqlWhere.add("(" + SQL_IS_CONTACT + ")");
240 }
241 if (blocked.isPresent()) {
242 sqlWhere.add("r.blocked = ?");
243 }
244 if (!recipientIds.isEmpty()) {
245 final var recipientIdsCommaSeparated = recipientIds.stream()
246 .map(recipientId -> String.valueOf(recipientId.id()))
247 .collect(Collectors.joining(","));
248 sqlWhere.add("r._id IN (" + recipientIdsCommaSeparated + ")");
249 }
250 final var sql = (
251 """
252 SELECT r._id,
253 r.number, r.uuid,
254 r.profile_key, r.profile_key_credential,
255 r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived,
256 r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities
257 FROM %s r
258 WHERE (r.number IS NOT NULL OR r.uuid IS NOT NULL) AND %s
259 """
260 ).formatted(TABLE_RECIPIENT, sqlWhere.size() == 0 ? "TRUE" : String.join(" AND ", sqlWhere));
261 try (final var connection = database.getConnection()) {
262 try (final var statement = connection.prepareStatement(sql)) {
263 if (blocked.isPresent()) {
264 statement.setBoolean(1, blocked.get());
265 }
266 try (var result = Utils.executeQueryForStream(statement, this::getRecipientFromResultSet)) {
267 return result.filter(r -> name.isEmpty() || (
268 r.getContact() != null && name.get().equals(r.getContact().getName())
269 ) || (r.getProfile() != null && name.get().equals(r.getProfile().getDisplayName()))).toList();
270 }
271 }
272 } catch (SQLException e) {
273 throw new RuntimeException("Failed read from recipient store", e);
274 }
275 }
276
277 @Override
278 public void deleteContact(RecipientId recipientId) {
279 storeContact(recipientId, null);
280 }
281
282 public void deleteRecipientData(RecipientId recipientId) {
283 logger.debug("Deleting recipient data for {}", recipientId);
284 try (final var connection = database.getConnection()) {
285 connection.setAutoCommit(false);
286 storeContact(connection, recipientId, null);
287 storeProfile(connection, recipientId, null);
288 storeProfileKey(connection, recipientId, null, false);
289 storeExpiringProfileKeyCredential(connection, recipientId, null);
290 deleteRecipient(connection, recipientId);
291 connection.commit();
292 } catch (SQLException e) {
293 throw new RuntimeException("Failed update recipient store", e);
294 }
295 }
296
297 @Override
298 public Profile getProfile(final RecipientId recipientId) {
299 try (final var connection = database.getConnection()) {
300 return getProfile(connection, recipientId);
301 } catch (SQLException e) {
302 throw new RuntimeException("Failed read from recipient store", e);
303 }
304 }
305
306 @Override
307 public ProfileKey getProfileKey(final RecipientId recipientId) {
308 try (final var connection = database.getConnection()) {
309 return getProfileKey(connection, recipientId);
310 } catch (SQLException e) {
311 throw new RuntimeException("Failed read from recipient store", e);
312 }
313 }
314
315 @Override
316 public ExpiringProfileKeyCredential getExpiringProfileKeyCredential(final RecipientId recipientId) {
317 try (final var connection = database.getConnection()) {
318 return getExpiringProfileKeyCredential(connection, recipientId);
319 } catch (SQLException e) {
320 throw new RuntimeException("Failed read from recipient store", e);
321 }
322 }
323
324 @Override
325 public void storeProfile(RecipientId recipientId, final Profile profile) {
326 try (final var connection = database.getConnection()) {
327 storeProfile(connection, recipientId, profile);
328 } catch (SQLException e) {
329 throw new RuntimeException("Failed update recipient store", e);
330 }
331 }
332
333 @Override
334 public void storeSelfProfileKey(final RecipientId recipientId, final ProfileKey profileKey) {
335 try (final var connection = database.getConnection()) {
336 storeProfileKey(connection, recipientId, profileKey, false);
337 } catch (SQLException e) {
338 throw new RuntimeException("Failed update recipient store", e);
339 }
340 }
341
342 @Override
343 public void storeProfileKey(RecipientId recipientId, final ProfileKey profileKey) {
344 try (final var connection = database.getConnection()) {
345 storeProfileKey(connection, recipientId, profileKey, true);
346 } catch (SQLException e) {
347 throw new RuntimeException("Failed update recipient store", e);
348 }
349 }
350
351 @Override
352 public void storeExpiringProfileKeyCredential(
353 RecipientId recipientId, final ExpiringProfileKeyCredential profileKeyCredential
354 ) {
355 try (final var connection = database.getConnection()) {
356 storeExpiringProfileKeyCredential(connection, recipientId, profileKeyCredential);
357 } catch (SQLException e) {
358 throw new RuntimeException("Failed update recipient store", e);
359 }
360 }
361
362 void addLegacyRecipients(final Map<RecipientId, Recipient> recipients) {
363 logger.debug("Migrating legacy recipients to database");
364 long start = System.nanoTime();
365 final var sql = (
366 """
367 INSERT INTO %s (_id, number, uuid)
368 VALUES (?, ?, ?)
369 """
370 ).formatted(TABLE_RECIPIENT);
371 try (final var connection = database.getConnection()) {
372 connection.setAutoCommit(false);
373 try (final var statement = connection.prepareStatement("DELETE FROM %s".formatted(TABLE_RECIPIENT))) {
374 statement.executeUpdate();
375 }
376 try (final var statement = connection.prepareStatement(sql)) {
377 for (final var recipient : recipients.values()) {
378 statement.setLong(1, recipient.getRecipientId().id());
379 statement.setString(2, recipient.getAddress().number().orElse(null));
380 statement.setBytes(3, recipient.getAddress().uuid().map(UuidUtil::toByteArray).orElse(null));
381 statement.executeUpdate();
382 }
383 }
384 logger.debug("Initial inserts took {}ms", (System.nanoTime() - start) / 1000000);
385
386 for (final var recipient : recipients.values()) {
387 if (recipient.getContact() != null) {
388 storeContact(connection, recipient.getRecipientId(), recipient.getContact());
389 }
390 if (recipient.getProfile() != null) {
391 storeProfile(connection, recipient.getRecipientId(), recipient.getProfile());
392 }
393 if (recipient.getProfileKey() != null) {
394 storeProfileKey(connection, recipient.getRecipientId(), recipient.getProfileKey(), false);
395 }
396 if (recipient.getExpiringProfileKeyCredential() != null) {
397 storeExpiringProfileKeyCredential(connection,
398 recipient.getRecipientId(),
399 recipient.getExpiringProfileKeyCredential());
400 }
401 }
402 connection.commit();
403 } catch (SQLException e) {
404 throw new RuntimeException("Failed update recipient store", e);
405 }
406 logger.debug("Complete recipients migration took {}ms", (System.nanoTime() - start) / 1000000);
407 }
408
409 long getActualRecipientId(long recipientId) {
410 while (recipientsMerged.containsKey(recipientId)) {
411 final var newRecipientId = recipientsMerged.get(recipientId);
412 logger.debug("Using {} instead of {}, because recipients have been merged", newRecipientId, recipientId);
413 recipientId = newRecipientId;
414 }
415 return recipientId;
416 }
417
418 private void storeContact(
419 final Connection connection, final RecipientId recipientId, final Contact contact
420 ) throws SQLException {
421 final var sql = (
422 """
423 UPDATE %s
424 SET given_name = ?, family_name = ?, expiration_time = ?, profile_sharing = ?, color = ?, blocked = ?, archived = ?
425 WHERE _id = ?
426 """
427 ).formatted(TABLE_RECIPIENT);
428 try (final var statement = connection.prepareStatement(sql)) {
429 statement.setString(1, contact == null ? null : contact.getGivenName());
430 statement.setString(2, contact == null ? null : contact.getFamilyName());
431 statement.setInt(3, contact == null ? 0 : contact.getMessageExpirationTime());
432 statement.setBoolean(4, contact != null && contact.isProfileSharingEnabled());
433 statement.setString(5, contact == null ? null : contact.getColor());
434 statement.setBoolean(6, contact != null && contact.isBlocked());
435 statement.setBoolean(7, contact != null && contact.isArchived());
436 statement.setLong(8, recipientId.id());
437 statement.executeUpdate();
438 }
439 }
440
441 private void storeExpiringProfileKeyCredential(
442 final Connection connection,
443 final RecipientId recipientId,
444 final ExpiringProfileKeyCredential profileKeyCredential
445 ) throws SQLException {
446 final var sql = (
447 """
448 UPDATE %s
449 SET profile_key_credential = ?
450 WHERE _id = ?
451 """
452 ).formatted(TABLE_RECIPIENT);
453 try (final var statement = connection.prepareStatement(sql)) {
454 statement.setBytes(1, profileKeyCredential == null ? null : profileKeyCredential.serialize());
455 statement.setLong(2, recipientId.id());
456 statement.executeUpdate();
457 }
458 }
459
460 private void storeProfile(
461 final Connection connection, final RecipientId recipientId, final Profile profile
462 ) throws SQLException {
463 final var sql = (
464 """
465 UPDATE %s
466 SET profile_last_update_timestamp = ?, profile_given_name = ?, profile_family_name = ?, profile_about = ?, profile_about_emoji = ?, profile_avatar_url_path = ?, profile_mobile_coin_address = ?, profile_unidentified_access_mode = ?, profile_capabilities = ?
467 WHERE _id = ?
468 """
469 ).formatted(TABLE_RECIPIENT);
470 try (final var statement = connection.prepareStatement(sql)) {
471 statement.setLong(1, profile == null ? 0 : profile.getLastUpdateTimestamp());
472 statement.setString(2, profile == null ? null : profile.getGivenName());
473 statement.setString(3, profile == null ? null : profile.getFamilyName());
474 statement.setString(4, profile == null ? null : profile.getAbout());
475 statement.setString(5, profile == null ? null : profile.getAboutEmoji());
476 statement.setString(6, profile == null ? null : profile.getAvatarUrlPath());
477 statement.setBytes(7, profile == null ? null : profile.getMobileCoinAddress());
478 statement.setString(8, profile == null ? null : profile.getUnidentifiedAccessMode().name());
479 statement.setString(9,
480 profile == null
481 ? null
482 : profile.getCapabilities().stream().map(Enum::name).collect(Collectors.joining(",")));
483 statement.setLong(10, recipientId.id());
484 statement.executeUpdate();
485 }
486 }
487
488 private void storeProfileKey(
489 Connection connection, RecipientId recipientId, final ProfileKey profileKey, boolean resetProfile
490 ) throws SQLException {
491 if (profileKey != null) {
492 final var recipientProfileKey = getProfileKey(recipientId);
493 if (profileKey.equals(recipientProfileKey)) {
494 final var recipientProfile = getProfile(recipientId);
495 if (recipientProfile == null || (
496 recipientProfile.getUnidentifiedAccessMode() != Profile.UnidentifiedAccessMode.UNKNOWN
497 && recipientProfile.getUnidentifiedAccessMode()
498 != Profile.UnidentifiedAccessMode.DISABLED
499 )) {
500 return;
501 }
502 }
503 }
504
505 final var sql = (
506 """
507 UPDATE %s
508 SET profile_key = ?, profile_key_credential = NULL%s
509 WHERE _id = ?
510 """
511 ).formatted(TABLE_RECIPIENT, resetProfile ? ", profile_last_update_timestamp = 0" : "");
512 try (final var statement = connection.prepareStatement(sql)) {
513 statement.setBytes(1, profileKey == null ? null : profileKey.serialize());
514 statement.setLong(2, recipientId.id());
515 statement.executeUpdate();
516 }
517 }
518
519 /**
520 * @param isHighTrust true, if the number/uuid connection was obtained from a trusted source.
521 * Has no effect, if the address contains only a number or a uuid.
522 */
523 private RecipientId resolveRecipient(RecipientAddress address, boolean isHighTrust, boolean isSelf) {
524 final Pair<RecipientId, Optional<RecipientId>> pair;
525 synchronized (recipientsLock) {
526 try (final var connection = database.getConnection()) {
527 connection.setAutoCommit(false);
528 pair = resolveRecipientLocked(connection, address, isHighTrust, isSelf);
529 connection.commit();
530 } catch (SQLException e) {
531 throw new RuntimeException("Failed update recipient store", e);
532 }
533 }
534
535 if (pair.second().isPresent()) {
536 recipientMergeHandler.mergeRecipients(pair.first(), pair.second().get());
537 try (final var connection = database.getConnection()) {
538 deleteRecipient(connection, pair.second().get());
539 } catch (SQLException e) {
540 throw new RuntimeException("Failed update recipient store", e);
541 }
542 }
543 return pair.first();
544 }
545
546 private Pair<RecipientId, Optional<RecipientId>> resolveRecipientLocked(
547 Connection connection, RecipientAddress address, boolean isHighTrust, boolean isSelf
548 ) throws SQLException {
549 if (isHighTrust && !isSelf) {
550 if (selfAddressProvider.getSelfAddress().matches(address)) {
551 isHighTrust = false;
552 }
553 }
554 final var byNumber = address.number().isEmpty()
555 ? Optional.<RecipientWithAddress>empty()
556 : findByNumber(connection, address.number().get());
557 final var byUuid = address.uuid().isEmpty()
558 ? Optional.<RecipientWithAddress>empty()
559 : findByUuid(connection, address.uuid().get());
560
561 if (byNumber.isEmpty() && byUuid.isEmpty()) {
562 logger.debug("Got new recipient, both uuid and number are unknown");
563
564 if (isHighTrust || address.uuid().isEmpty() || address.number().isEmpty()) {
565 return new Pair<>(addNewRecipient(connection, address), Optional.empty());
566 }
567
568 return new Pair<>(addNewRecipient(connection, new RecipientAddress(address.uuid().get())),
569 Optional.empty());
570 }
571
572 if (!isHighTrust || address.uuid().isEmpty() || address.number().isEmpty() || byNumber.equals(byUuid)) {
573 return new Pair<>(byUuid.or(() -> byNumber).map(RecipientWithAddress::id).get(), Optional.empty());
574 }
575
576 if (byNumber.isEmpty()) {
577 logger.debug("Got recipient {} existing with uuid, updating with high trust number", byUuid.get().id());
578 updateRecipientAddress(connection, byUuid.get().id(), address);
579 return new Pair<>(byUuid.get().id(), Optional.empty());
580 }
581
582 final var byNumberRecipient = byNumber.get();
583
584 if (byUuid.isEmpty()) {
585 if (byNumberRecipient.address().uuid().isPresent()) {
586 logger.debug(
587 "Got recipient {} existing with number, but different uuid, so stripping its number and adding new recipient",
588 byNumberRecipient.id());
589
590 updateRecipientAddress(connection,
591 byNumberRecipient.id(),
592 new RecipientAddress(byNumberRecipient.address().uuid().get()));
593 return new Pair<>(addNewRecipient(connection, address), Optional.empty());
594 }
595
596 logger.debug("Got recipient {} existing with number and no uuid, updating with high trust uuid",
597 byNumberRecipient.id());
598 updateRecipientAddress(connection, byNumberRecipient.id(), address);
599 return new Pair<>(byNumberRecipient.id(), Optional.empty());
600 }
601
602 final var byUuidRecipient = byUuid.get();
603
604 if (byNumberRecipient.address().uuid().isPresent()) {
605 logger.debug(
606 "Got separate recipients for high trust number {} and uuid {}, recipient for number has different uuid, so stripping its number",
607 byNumberRecipient.id(),
608 byUuidRecipient.id());
609
610 updateRecipientAddress(connection,
611 byNumberRecipient.id(),
612 new RecipientAddress(byNumberRecipient.address().uuid().get()));
613 updateRecipientAddress(connection, byUuidRecipient.id(), address);
614 return new Pair<>(byUuidRecipient.id(), Optional.empty());
615 }
616
617 logger.debug("Got separate recipients for high trust number {} and uuid {}, need to merge them",
618 byNumberRecipient.id(),
619 byUuidRecipient.id());
620 // Create a fixed RecipientId that won't update its id after merge
621 final var toBeMergedRecipientId = new RecipientId(byNumberRecipient.id().id(), null);
622 mergeRecipientsLocked(connection, byUuidRecipient.id(), toBeMergedRecipientId);
623 removeRecipientAddress(connection, toBeMergedRecipientId);
624 updateRecipientAddress(connection, byUuidRecipient.id(), address);
625 return new Pair<>(byUuidRecipient.id(), Optional.of(toBeMergedRecipientId));
626 }
627
628 private RecipientId addNewRecipient(
629 final Connection connection, final RecipientAddress address
630 ) throws SQLException {
631 final var sql = (
632 """
633 INSERT INTO %s (number, uuid)
634 VALUES (?, ?)
635 """
636 ).formatted(TABLE_RECIPIENT);
637 try (final var statement = connection.prepareStatement(sql)) {
638 statement.setString(1, address.number().orElse(null));
639 statement.setBytes(2, address.uuid().map(UuidUtil::toByteArray).orElse(null));
640 statement.executeUpdate();
641 final var generatedKeys = statement.getGeneratedKeys();
642 if (generatedKeys.next()) {
643 final var recipientId = new RecipientId(generatedKeys.getLong(1), this);
644 logger.debug("Added new recipient {} with address {}", recipientId, address);
645 return recipientId;
646 } else {
647 throw new RuntimeException("Failed to add new recipient to database");
648 }
649 }
650 }
651
652 private void removeRecipientAddress(Connection connection, RecipientId recipientId) throws SQLException {
653 final var sql = (
654 """
655 UPDATE %s
656 SET number = NULL, uuid = NULL
657 WHERE _id = ?
658 """
659 ).formatted(TABLE_RECIPIENT);
660 try (final var statement = connection.prepareStatement(sql)) {
661 statement.setLong(1, recipientId.id());
662 statement.executeUpdate();
663 }
664 }
665
666 private void updateRecipientAddress(
667 Connection connection, RecipientId recipientId, final RecipientAddress address
668 ) throws SQLException {
669 final var sql = (
670 """
671 UPDATE %s
672 SET number = ?, uuid = ?
673 WHERE _id = ?
674 """
675 ).formatted(TABLE_RECIPIENT);
676 try (final var statement = connection.prepareStatement(sql)) {
677 statement.setString(1, address.number().orElse(null));
678 statement.setBytes(2, address.uuid().map(UuidUtil::toByteArray).orElse(null));
679 statement.setLong(3, recipientId.id());
680 statement.executeUpdate();
681 }
682 }
683
684 private void deleteRecipient(final Connection connection, final RecipientId recipientId) throws SQLException {
685 final var sql = (
686 """
687 DELETE FROM %s
688 WHERE _id = ?
689 """
690 ).formatted(TABLE_RECIPIENT);
691 try (final var statement = connection.prepareStatement(sql)) {
692 statement.setLong(1, recipientId.id());
693 statement.executeUpdate();
694 }
695 }
696
697 private void mergeRecipientsLocked(
698 Connection connection, RecipientId recipientId, RecipientId toBeMergedRecipientId
699 ) throws SQLException {
700 final var contact = getContact(connection, recipientId);
701 if (contact == null) {
702 final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
703 storeContact(connection, recipientId, toBeMergedContact);
704 }
705
706 final var profileKey = getProfileKey(connection, recipientId);
707 if (profileKey == null) {
708 final var toBeMergedProfileKey = getProfileKey(connection, toBeMergedRecipientId);
709 storeProfileKey(connection, recipientId, toBeMergedProfileKey, false);
710 }
711
712 final var profileKeyCredential = getExpiringProfileKeyCredential(connection, recipientId);
713 if (profileKeyCredential == null) {
714 final var toBeMergedProfileKeyCredential = getExpiringProfileKeyCredential(connection,
715 toBeMergedRecipientId);
716 storeExpiringProfileKeyCredential(connection, recipientId, toBeMergedProfileKeyCredential);
717 }
718
719 final var profile = getProfile(connection, recipientId);
720 if (profile == null) {
721 final var toBeMergedProfile = getProfile(connection, toBeMergedRecipientId);
722 storeProfile(connection, recipientId, toBeMergedProfile);
723 }
724
725 recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id());
726 }
727
728 private Optional<RecipientWithAddress> findByNumber(
729 final Connection connection, final String number
730 ) throws SQLException {
731 final var sql = """
732 SELECT r._id, r.number, r.uuid
733 FROM %s r
734 WHERE r.number = ?
735 """.formatted(TABLE_RECIPIENT);
736 try (final var statement = connection.prepareStatement(sql)) {
737 statement.setString(1, number);
738 return Utils.executeQueryForOptional(statement, this::getRecipientWithAddressFromResultSet);
739 }
740 }
741
742 private Optional<RecipientWithAddress> findByUuid(
743 final Connection connection, final UUID uuid
744 ) throws SQLException {
745 final var sql = """
746 SELECT r._id, r.number, r.uuid
747 FROM %s r
748 WHERE r.uuid = ?
749 """.formatted(TABLE_RECIPIENT);
750 try (final var statement = connection.prepareStatement(sql)) {
751 statement.setBytes(1, UuidUtil.toByteArray(uuid));
752 return Utils.executeQueryForOptional(statement, this::getRecipientWithAddressFromResultSet);
753 }
754 }
755
756 private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
757 final var sql = (
758 """
759 SELECT r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived
760 FROM %s r
761 WHERE r._id = ? AND (%s)
762 """
763 ).formatted(TABLE_RECIPIENT, SQL_IS_CONTACT);
764 try (final var statement = connection.prepareStatement(sql)) {
765 statement.setLong(1, recipientId.id());
766 return Utils.executeQueryForOptional(statement, this::getContactFromResultSet).orElse(null);
767 }
768 }
769
770 private ProfileKey getProfileKey(final Connection connection, final RecipientId recipientId) throws SQLException {
771 final var sql = (
772 """
773 SELECT r.profile_key
774 FROM %s r
775 WHERE r._id = ?
776 """
777 ).formatted(TABLE_RECIPIENT);
778 try (final var statement = connection.prepareStatement(sql)) {
779 statement.setLong(1, recipientId.id());
780 return Utils.executeQueryForOptional(statement, this::getProfileKeyFromResultSet).orElse(null);
781 }
782 }
783
784 private ExpiringProfileKeyCredential getExpiringProfileKeyCredential(
785 final Connection connection, final RecipientId recipientId
786 ) throws SQLException {
787 final var sql = (
788 """
789 SELECT r.profile_key_credential
790 FROM %s r
791 WHERE r._id = ?
792 """
793 ).formatted(TABLE_RECIPIENT);
794 try (final var statement = connection.prepareStatement(sql)) {
795 statement.setLong(1, recipientId.id());
796 return Utils.executeQueryForOptional(statement, this::getExpiringProfileKeyCredentialFromResultSet)
797 .orElse(null);
798 }
799 }
800
801 private Profile getProfile(final Connection connection, final RecipientId recipientId) throws SQLException {
802 final var sql = (
803 """
804 SELECT r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities
805 FROM %s r
806 WHERE r._id = ? AND r.profile_capabilities IS NOT NULL
807 """
808 ).formatted(TABLE_RECIPIENT);
809 try (final var statement = connection.prepareStatement(sql)) {
810 statement.setLong(1, recipientId.id());
811 return Utils.executeQueryForOptional(statement, this::getProfileFromResultSet).orElse(null);
812 }
813 }
814
815 private RecipientAddress getRecipientAddressFromResultSet(ResultSet resultSet) throws SQLException {
816 final var uuid = Optional.ofNullable(resultSet.getBytes("uuid")).map(UuidUtil::parseOrNull);
817 final var number = Optional.ofNullable(resultSet.getString("number"));
818 return new RecipientAddress(uuid, number);
819 }
820
821 private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException {
822 return new RecipientId(resultSet.getLong("_id"), this);
823 }
824
825 private RecipientWithAddress getRecipientWithAddressFromResultSet(final ResultSet resultSet) throws SQLException {
826 return new RecipientWithAddress(getRecipientIdFromResultSet(resultSet),
827 getRecipientAddressFromResultSet(resultSet));
828 }
829
830 private Recipient getRecipientFromResultSet(final ResultSet resultSet) throws SQLException {
831 return new Recipient(getRecipientIdFromResultSet(resultSet),
832 getRecipientAddressFromResultSet(resultSet),
833 getContactFromResultSet(resultSet),
834 getProfileKeyFromResultSet(resultSet),
835 getExpiringProfileKeyCredentialFromResultSet(resultSet),
836 getProfileFromResultSet(resultSet));
837 }
838
839 private Contact getContactFromResultSet(ResultSet resultSet) throws SQLException {
840 return new Contact(resultSet.getString("given_name"),
841 resultSet.getString("family_name"),
842 resultSet.getString("color"),
843 resultSet.getInt("expiration_time"),
844 resultSet.getBoolean("blocked"),
845 resultSet.getBoolean("archived"),
846 resultSet.getBoolean("profile_sharing"));
847 }
848
849 private Profile getProfileFromResultSet(ResultSet resultSet) throws SQLException {
850 final var profileCapabilities = resultSet.getString("profile_capabilities");
851 final var profileUnidentifiedAccessMode = resultSet.getString("profile_unidentified_access_mode");
852 return new Profile(resultSet.getLong("profile_last_update_timestamp"),
853 resultSet.getString("profile_given_name"),
854 resultSet.getString("profile_family_name"),
855 resultSet.getString("profile_about"),
856 resultSet.getString("profile_about_emoji"),
857 resultSet.getString("profile_avatar_url_path"),
858 resultSet.getBytes("profile_mobile_coin_address"),
859 profileUnidentifiedAccessMode == null
860 ? Profile.UnidentifiedAccessMode.UNKNOWN
861 : Profile.UnidentifiedAccessMode.valueOfOrUnknown(profileUnidentifiedAccessMode),
862 profileCapabilities == null
863 ? Set.of()
864 : Arrays.stream(profileCapabilities.split(","))
865 .map(Profile.Capability::valueOfOrNull)
866 .filter(Objects::nonNull)
867 .collect(Collectors.toSet()));
868 }
869
870 private ProfileKey getProfileKeyFromResultSet(ResultSet resultSet) throws SQLException {
871 final var profileKey = resultSet.getBytes("profile_key");
872
873 if (profileKey == null) {
874 return null;
875 }
876 try {
877 return new ProfileKey(profileKey);
878 } catch (InvalidInputException ignored) {
879 return null;
880 }
881 }
882
883 private ExpiringProfileKeyCredential getExpiringProfileKeyCredentialFromResultSet(ResultSet resultSet) throws SQLException {
884 final var profileKeyCredential = resultSet.getBytes("profile_key_credential");
885
886 if (profileKeyCredential == null) {
887 return null;
888 }
889 try {
890 return new ExpiringProfileKeyCredential(profileKeyCredential);
891 } catch (Throwable ignored) {
892 return null;
893 }
894 }
895
896 public interface RecipientMergeHandler {
897
898 void mergeRecipients(RecipientId recipientId, RecipientId toBeMergedRecipientId);
899 }
900
901 private record RecipientWithAddress(RecipientId id, RecipientAddress address) {}
902 }