]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
299a398073f3c44cecc1370f604bb6b21d708589
[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 com.fasterxml.jackson.databind.ObjectMapper;
4
5 import org.asamk.signal.manager.api.Pair;
6 import org.asamk.signal.manager.api.UnregisteredRecipientException;
7 import org.asamk.signal.manager.storage.Utils;
8 import org.asamk.signal.manager.storage.contacts.ContactsStore;
9 import org.asamk.signal.manager.storage.profiles.ProfileStore;
10 import org.signal.libsignal.zkgroup.InvalidInputException;
11 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
12 import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredential;
13 import org.slf4j.Logger;
14 import org.slf4j.LoggerFactory;
15 import org.whispersystems.signalservice.api.push.ACI;
16 import org.whispersystems.signalservice.api.push.ServiceId;
17 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
18 import org.whispersystems.signalservice.api.util.UuidUtil;
19
20 import java.io.ByteArrayInputStream;
21 import java.io.ByteArrayOutputStream;
22 import java.io.File;
23 import java.io.FileInputStream;
24 import java.io.FileNotFoundException;
25 import java.io.FileOutputStream;
26 import java.io.IOException;
27 import java.util.ArrayList;
28 import java.util.Base64;
29 import java.util.Collection;
30 import java.util.HashMap;
31 import java.util.List;
32 import java.util.Map;
33 import java.util.Objects;
34 import java.util.Optional;
35 import java.util.Set;
36 import java.util.UUID;
37 import java.util.function.Supplier;
38 import java.util.stream.Collectors;
39
40 public class RecipientStore implements RecipientResolver, ContactsStore, ProfileStore {
41
42 private final static Logger logger = LoggerFactory.getLogger(RecipientStore.class);
43
44 private final ObjectMapper objectMapper;
45 private final File file;
46 private final RecipientMergeHandler recipientMergeHandler;
47 private final SelfAddressProvider selfAddressProvider;
48
49 private final Map<RecipientId, Recipient> recipients;
50 private final Map<Long, Long> recipientsMerged = new HashMap<>();
51
52 private long lastId;
53 private boolean isBulkUpdating;
54
55 public static RecipientStore load(
56 File file, RecipientMergeHandler recipientMergeHandler, SelfAddressProvider selfAddressProvider
57 ) {
58 final var objectMapper = Utils.createStorageObjectMapper();
59 try (var inputStream = new FileInputStream(file)) {
60 final var storage = objectMapper.readValue(inputStream, Storage.class);
61
62 final var recipientStore = new RecipientStore(objectMapper,
63 file,
64 recipientMergeHandler,
65 selfAddressProvider,
66 new HashMap<>(),
67 storage.lastId);
68 final var recipients = storage.recipients.stream().map(r -> {
69 final var recipientId = new RecipientId(r.id, recipientStore);
70 final var address = new RecipientAddress(Optional.ofNullable(r.uuid).map(UuidUtil::parseOrThrow),
71 Optional.ofNullable(r.number));
72
73 Contact contact = null;
74 if (r.contact != null) {
75 contact = new Contact(r.contact.name,
76 r.contact.color,
77 r.contact.messageExpirationTime,
78 r.contact.blocked,
79 r.contact.archived,
80 r.contact.profileSharingEnabled);
81 }
82
83 ProfileKey profileKey = null;
84 if (r.profileKey != null) {
85 try {
86 profileKey = new ProfileKey(Base64.getDecoder().decode(r.profileKey));
87 } catch (InvalidInputException ignored) {
88 }
89 }
90
91 ProfileKeyCredential profileKeyCredential = null;
92 if (r.profileKeyCredential != null) {
93 try {
94 profileKeyCredential = new ProfileKeyCredential(Base64.getDecoder()
95 .decode(r.profileKeyCredential));
96 } catch (Throwable ignored) {
97 }
98 }
99
100 Profile profile = null;
101 if (r.profile != null) {
102 profile = new Profile(r.profile.lastUpdateTimestamp,
103 r.profile.givenName,
104 r.profile.familyName,
105 r.profile.about,
106 r.profile.aboutEmoji,
107 r.profile.avatarUrlPath,
108 r.profile.paymentAddress == null
109 ? null
110 : Base64.getDecoder().decode(r.profile.paymentAddress),
111 Profile.UnidentifiedAccessMode.valueOfOrUnknown(r.profile.unidentifiedAccessMode),
112 r.profile.capabilities.stream()
113 .map(Profile.Capability::valueOfOrNull)
114 .filter(Objects::nonNull)
115 .collect(Collectors.toSet()));
116 }
117
118 return new Recipient(recipientId, address, contact, profileKey, profileKeyCredential, profile);
119 }).collect(Collectors.toMap(Recipient::getRecipientId, r -> r));
120
121 recipientStore.addRecipients(recipients);
122
123 return recipientStore;
124 } catch (FileNotFoundException e) {
125 logger.trace("Creating new recipient store.");
126 return new RecipientStore(objectMapper,
127 file,
128 recipientMergeHandler,
129 selfAddressProvider,
130 new HashMap<>(),
131 0);
132 } catch (IOException e) {
133 logger.warn("Failed to load recipient store", e);
134 throw new RuntimeException(e);
135 }
136 }
137
138 private RecipientStore(
139 final ObjectMapper objectMapper,
140 final File file,
141 final RecipientMergeHandler recipientMergeHandler,
142 final SelfAddressProvider selfAddressProvider,
143 final Map<RecipientId, Recipient> recipients,
144 final long lastId
145 ) {
146 this.objectMapper = objectMapper;
147 this.file = file;
148 this.recipientMergeHandler = recipientMergeHandler;
149 this.selfAddressProvider = selfAddressProvider;
150 this.recipients = recipients;
151 this.lastId = lastId;
152 }
153
154 public void setBulkUpdating(final boolean bulkUpdating) {
155 isBulkUpdating = bulkUpdating;
156 if (!bulkUpdating) {
157 synchronized (recipients) {
158 saveLocked();
159 }
160 }
161 }
162
163 public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
164 synchronized (recipients) {
165 return getRecipient(recipientId).getAddress();
166 }
167 }
168
169 public Recipient getRecipient(RecipientId recipientId) {
170 synchronized (recipients) {
171 return recipients.get(recipientId);
172 }
173 }
174
175 public Collection<RecipientId> getRecipientIdsWithEnabledProfileSharing() {
176 synchronized (recipients) {
177 return recipients.values().stream().filter(r -> {
178 final var contact = r.getContact();
179 return contact != null && !contact.isBlocked() && contact.isProfileSharingEnabled();
180 }).map(Recipient::getRecipientId).toList();
181 }
182 }
183
184 @Override
185 public RecipientId resolveRecipient(ServiceId serviceId) {
186 return resolveRecipient(new RecipientAddress(serviceId.uuid()), false, false);
187 }
188
189 @Override
190 public RecipientId resolveRecipient(final long recipientId) {
191 final var recipient = getRecipient(new RecipientId(recipientId, this));
192 return recipient == null ? null : recipient.getRecipientId();
193 }
194
195 @Override
196 public RecipientId resolveRecipient(final String identifier) {
197 return resolveRecipient(Utils.getRecipientAddressFromIdentifier(identifier), false, false);
198 }
199
200 public RecipientId resolveRecipient(
201 final String number, Supplier<ACI> aciSupplier
202 ) throws UnregisteredRecipientException {
203 final Optional<Recipient> byNumber;
204 synchronized (recipients) {
205 byNumber = findByNumberLocked(number);
206 }
207 if (byNumber.isEmpty() || byNumber.get().getAddress().uuid().isEmpty()) {
208 final var aci = aciSupplier.get();
209 if (aci == null) {
210 throw new UnregisteredRecipientException(new RecipientAddress(null, number));
211 }
212
213 return resolveRecipient(new RecipientAddress(aci.uuid(), number), false, false);
214 }
215 return byNumber.get().getRecipientId();
216 }
217
218 public RecipientId resolveRecipient(RecipientAddress address) {
219 return resolveRecipient(address, false, false);
220 }
221
222 @Override
223 public RecipientId resolveRecipient(final SignalServiceAddress address) {
224 return resolveRecipient(new RecipientAddress(address), false, false);
225 }
226
227 public RecipientId resolveSelfRecipientTrusted(RecipientAddress address) {
228 return resolveRecipient(address, true, true);
229 }
230
231 public RecipientId resolveRecipientTrusted(RecipientAddress address) {
232 return resolveRecipient(address, true, false);
233 }
234
235 public RecipientId resolveRecipientTrusted(SignalServiceAddress address) {
236 return resolveRecipient(new RecipientAddress(address), true, false);
237 }
238
239 public List<RecipientId> resolveRecipientsTrusted(List<RecipientAddress> addresses) {
240 final List<RecipientId> recipientIds;
241 final List<Pair<RecipientId, RecipientId>> toBeMerged = new ArrayList<>();
242 synchronized (recipients) {
243 recipientIds = addresses.stream().map(address -> {
244 final var pair = resolveRecipientLocked(address, true, false);
245 if (pair.second().isPresent()) {
246 toBeMerged.add(new Pair<>(pair.first(), pair.second().get()));
247 }
248 return pair.first();
249 }).toList();
250 }
251 for (var pair : toBeMerged) {
252 recipientMergeHandler.mergeRecipients(pair.first(), pair.second());
253 }
254 return recipientIds;
255 }
256
257 @Override
258 public void storeContact(RecipientId recipientId, final Contact contact) {
259 synchronized (recipients) {
260 final var recipient = recipients.get(recipientId);
261 storeRecipientLocked(recipientId, Recipient.newBuilder(recipient).withContact(contact).build());
262 }
263 }
264
265 @Override
266 public Contact getContact(RecipientId recipientId) {
267 final var recipient = getRecipient(recipientId);
268 return recipient == null ? null : recipient.getContact();
269 }
270
271 @Override
272 public List<Pair<RecipientId, Contact>> getContacts() {
273 return recipients.entrySet()
274 .stream()
275 .filter(e -> e.getValue().getContact() != null)
276 .map(e -> new Pair<>(e.getKey(), e.getValue().getContact()))
277 .toList();
278 }
279
280 @Override
281 public void deleteContact(RecipientId recipientId) {
282 synchronized (recipients) {
283 final var recipient = recipients.get(recipientId);
284 storeRecipientLocked(recipientId, Recipient.newBuilder(recipient).withContact(null).build());
285 }
286 }
287
288 public void deleteRecipientData(RecipientId recipientId) {
289 synchronized (recipients) {
290 logger.debug("Deleting recipient data for {}", recipientId);
291 final var recipient = recipients.get(recipientId);
292 recipient.getAddress()
293 .uuid()
294 .ifPresent(uuid -> storeRecipientLocked(recipientId,
295 Recipient.newBuilder()
296 .withRecipientId(recipientId)
297 .withAddress(new RecipientAddress(uuid))
298 .build()));
299 }
300 }
301
302 @Override
303 public Profile getProfile(final RecipientId recipientId) {
304 final var recipient = getRecipient(recipientId);
305 return recipient == null ? null : recipient.getProfile();
306 }
307
308 @Override
309 public ProfileKey getProfileKey(final RecipientId recipientId) {
310 final var recipient = getRecipient(recipientId);
311 return recipient == null ? null : recipient.getProfileKey();
312 }
313
314 @Override
315 public ProfileKeyCredential getProfileKeyCredential(final RecipientId recipientId) {
316 final var recipient = getRecipient(recipientId);
317 return recipient == null ? null : recipient.getProfileKeyCredential();
318 }
319
320 @Override
321 public void storeProfile(RecipientId recipientId, final Profile profile) {
322 synchronized (recipients) {
323 final var recipient = recipients.get(recipientId);
324 storeRecipientLocked(recipientId, Recipient.newBuilder(recipient).withProfile(profile).build());
325 }
326 }
327
328 @Override
329 public void storeSelfProfileKey(final RecipientId recipientId, final ProfileKey profileKey) {
330 storeProfileKey(recipientId, profileKey, false);
331 }
332
333 @Override
334 public void storeProfileKey(RecipientId recipientId, final ProfileKey profileKey) {
335 storeProfileKey(recipientId, profileKey, true);
336 }
337
338 private void storeProfileKey(RecipientId recipientId, final ProfileKey profileKey, boolean resetProfile) {
339 synchronized (recipients) {
340 final var recipient = recipients.get(recipientId);
341 if (profileKey != null && profileKey.equals(recipient.getProfileKey()) && (
342 recipient.getProfile() == null || (
343 recipient.getProfile().getUnidentifiedAccessMode() != Profile.UnidentifiedAccessMode.UNKNOWN
344 && recipient.getProfile().getUnidentifiedAccessMode()
345 != Profile.UnidentifiedAccessMode.DISABLED
346 )
347 )) {
348 return;
349 }
350
351 final var builder = Recipient.newBuilder(recipient)
352 .withProfileKey(profileKey)
353 .withProfileKeyCredential(null);
354 if (resetProfile) {
355 builder.withProfile(recipient.getProfile() == null
356 ? null
357 : Profile.newBuilder(recipient.getProfile()).withLastUpdateTimestamp(0).build());
358 }
359 final var newRecipient = builder.build();
360 storeRecipientLocked(recipientId, newRecipient);
361 }
362 }
363
364 @Override
365 public void storeProfileKeyCredential(RecipientId recipientId, final ProfileKeyCredential profileKeyCredential) {
366 synchronized (recipients) {
367 final var recipient = recipients.get(recipientId);
368 storeRecipientLocked(recipientId,
369 Recipient.newBuilder(recipient).withProfileKeyCredential(profileKeyCredential).build());
370 }
371 }
372
373 public boolean isEmpty() {
374 synchronized (recipients) {
375 return recipients.isEmpty();
376 }
377 }
378
379 private void addRecipients(final Map<RecipientId, Recipient> recipients) {
380 this.recipients.putAll(recipients);
381 }
382
383 /**
384 * @param isHighTrust true, if the number/uuid connection was obtained from a trusted source.
385 * Has no effect, if the address contains only a number or a uuid.
386 */
387 private RecipientId resolveRecipient(RecipientAddress address, boolean isHighTrust, boolean isSelf) {
388 final Pair<RecipientId, Optional<RecipientId>> pair;
389 synchronized (recipients) {
390 pair = resolveRecipientLocked(address, isHighTrust, isSelf);
391 }
392
393 if (pair.second().isPresent()) {
394 recipientMergeHandler.mergeRecipients(pair.first(), pair.second().get());
395 }
396 return pair.first();
397 }
398
399 private Pair<RecipientId, Optional<RecipientId>> resolveRecipientLocked(
400 RecipientAddress address, boolean isHighTrust, boolean isSelf
401 ) {
402 if (isHighTrust && !isSelf) {
403 if (selfAddressProvider.getSelfAddress().matches(address)) {
404 isHighTrust = false;
405 }
406 }
407 final var byNumber = address.number().isEmpty()
408 ? Optional.<Recipient>empty()
409 : findByNumberLocked(address.number().get());
410 final var byUuid = address.uuid().isEmpty()
411 ? Optional.<Recipient>empty()
412 : findByUuidLocked(address.uuid().get());
413
414 if (byNumber.isEmpty() && byUuid.isEmpty()) {
415 logger.debug("Got new recipient, both uuid and number are unknown");
416
417 if (isHighTrust || address.uuid().isEmpty() || address.number().isEmpty()) {
418 return new Pair<>(addNewRecipientLocked(address), Optional.empty());
419 }
420
421 return new Pair<>(addNewRecipientLocked(new RecipientAddress(address.uuid().get())), Optional.empty());
422 }
423
424 if (!isHighTrust || address.uuid().isEmpty() || address.number().isEmpty() || byNumber.equals(byUuid)) {
425 return new Pair<>(byUuid.or(() -> byNumber).map(Recipient::getRecipientId).get(), Optional.empty());
426 }
427
428 if (byNumber.isEmpty()) {
429 logger.debug("Got recipient {} existing with uuid, updating with high trust number",
430 byUuid.get().getRecipientId());
431 updateRecipientAddressLocked(byUuid.get().getRecipientId(), address);
432 return new Pair<>(byUuid.get().getRecipientId(), Optional.empty());
433 }
434
435 final var byNumberRecipient = byNumber.get();
436
437 if (byUuid.isEmpty()) {
438 if (byNumberRecipient.getAddress().uuid().isPresent()) {
439 logger.debug(
440 "Got recipient {} existing with number, but different uuid, so stripping its number and adding new recipient",
441 byNumberRecipient.getRecipientId());
442
443 updateRecipientAddressLocked(byNumberRecipient.getRecipientId(),
444 new RecipientAddress(byNumberRecipient.getAddress().uuid().get()));
445 return new Pair<>(addNewRecipientLocked(address), Optional.empty());
446 }
447
448 logger.debug("Got recipient {} existing with number and no uuid, updating with high trust uuid",
449 byNumberRecipient.getRecipientId());
450 updateRecipientAddressLocked(byNumberRecipient.getRecipientId(), address);
451 return new Pair<>(byNumberRecipient.getRecipientId(), Optional.empty());
452 }
453
454 final var byUuidRecipient = byUuid.get();
455
456 if (byNumberRecipient.getAddress().uuid().isPresent()) {
457 logger.debug(
458 "Got separate recipients for high trust number {} and uuid {}, recipient for number has different uuid, so stripping its number",
459 byNumberRecipient.getRecipientId(),
460 byUuidRecipient.getRecipientId());
461
462 updateRecipientAddressLocked(byNumberRecipient.getRecipientId(),
463 new RecipientAddress(byNumberRecipient.getAddress().uuid().get()));
464 updateRecipientAddressLocked(byUuidRecipient.getRecipientId(), address);
465 return new Pair<>(byUuidRecipient.getRecipientId(), Optional.empty());
466 }
467
468 logger.debug("Got separate recipients for high trust number {} and uuid {}, need to merge them",
469 byNumberRecipient.getRecipientId(),
470 byUuidRecipient.getRecipientId());
471 updateRecipientAddressLocked(byUuidRecipient.getRecipientId(), address);
472 // Create a fixed RecipientId that won't update its id after merge
473 final var toBeMergedRecipientId = new RecipientId(byNumberRecipient.getRecipientId().id(), null);
474 mergeRecipientsLocked(byUuidRecipient.getRecipientId(), toBeMergedRecipientId);
475 return new Pair<>(byUuidRecipient.getRecipientId(), Optional.of(toBeMergedRecipientId));
476 }
477
478 private RecipientId addNewRecipientLocked(final RecipientAddress address) {
479 final var nextRecipientId = nextIdLocked();
480 logger.debug("Adding new recipient {} with address {}", nextRecipientId, address);
481 storeRecipientLocked(nextRecipientId, new Recipient(nextRecipientId, address, null, null, null, null));
482 return nextRecipientId;
483 }
484
485 private void updateRecipientAddressLocked(RecipientId recipientId, final RecipientAddress address) {
486 final var recipient = recipients.get(recipientId);
487 storeRecipientLocked(recipientId, Recipient.newBuilder(recipient).withAddress(address).build());
488 }
489
490 long getActualRecipientId(long recipientId) {
491 while (recipientsMerged.containsKey(recipientId)) {
492 final var newRecipientId = recipientsMerged.get(recipientId);
493 logger.debug("Using {} instead of {}, because recipients have been merged", newRecipientId, recipientId);
494 recipientId = newRecipientId;
495 }
496 return recipientId;
497 }
498
499 private void storeRecipientLocked(final RecipientId recipientId, final Recipient recipient) {
500 final var existingRecipient = recipients.get(recipientId);
501 if (existingRecipient == null || !existingRecipient.equals(recipient)) {
502 recipients.put(recipientId, recipient);
503 saveLocked();
504 }
505 }
506
507 private void mergeRecipientsLocked(RecipientId recipientId, RecipientId toBeMergedRecipientId) {
508 final var recipient = recipients.get(recipientId);
509 final var toBeMergedRecipient = recipients.get(toBeMergedRecipientId);
510 recipients.put(recipientId,
511 new Recipient(recipientId,
512 recipient.getAddress(),
513 recipient.getContact() != null ? recipient.getContact() : toBeMergedRecipient.getContact(),
514 recipient.getProfileKey() != null
515 ? recipient.getProfileKey()
516 : toBeMergedRecipient.getProfileKey(),
517 recipient.getProfileKeyCredential() != null
518 ? recipient.getProfileKeyCredential()
519 : toBeMergedRecipient.getProfileKeyCredential(),
520 recipient.getProfile() != null ? recipient.getProfile() : toBeMergedRecipient.getProfile()));
521 recipients.remove(toBeMergedRecipientId);
522 recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id());
523 saveLocked();
524 }
525
526 private Optional<Recipient> findByNumberLocked(final String number) {
527 return recipients.entrySet()
528 .stream()
529 .filter(entry -> entry.getValue().getAddress().number().isPresent() && number.equals(entry.getValue()
530 .getAddress()
531 .number()
532 .get()))
533 .findFirst()
534 .map(Map.Entry::getValue);
535 }
536
537 private Optional<Recipient> findByUuidLocked(final UUID uuid) {
538 return recipients.entrySet()
539 .stream()
540 .filter(entry -> entry.getValue().getAddress().uuid().isPresent() && uuid.equals(entry.getValue()
541 .getAddress()
542 .uuid()
543 .get()))
544 .findFirst()
545 .map(Map.Entry::getValue);
546 }
547
548 private RecipientId nextIdLocked() {
549 return new RecipientId(++this.lastId, this);
550 }
551
552 private void saveLocked() {
553 if (isBulkUpdating) {
554 return;
555 }
556 final var base64 = Base64.getEncoder();
557 var storage = new Storage(recipients.entrySet().stream().map(pair -> {
558 final var recipient = pair.getValue();
559 final var recipientContact = recipient.getContact();
560 final var contact = recipientContact == null
561 ? null
562 : new Storage.Recipient.Contact(recipientContact.getName(),
563 recipientContact.getColor(),
564 recipientContact.getMessageExpirationTime(),
565 recipientContact.isBlocked(),
566 recipientContact.isArchived(),
567 recipientContact.isProfileSharingEnabled());
568 final var recipientProfile = recipient.getProfile();
569 final var profile = recipientProfile == null
570 ? null
571 : new Storage.Recipient.Profile(recipientProfile.getLastUpdateTimestamp(),
572 recipientProfile.getGivenName(),
573 recipientProfile.getFamilyName(),
574 recipientProfile.getAbout(),
575 recipientProfile.getAboutEmoji(),
576 recipientProfile.getAvatarUrlPath(),
577 recipientProfile.getPaymentAddress() == null
578 ? null
579 : base64.encodeToString(recipientProfile.getPaymentAddress()),
580 recipientProfile.getUnidentifiedAccessMode().name(),
581 recipientProfile.getCapabilities().stream().map(Enum::name).collect(Collectors.toSet()));
582 return new Storage.Recipient(pair.getKey().id(),
583 recipient.getAddress().number().orElse(null),
584 recipient.getAddress().uuid().map(UUID::toString).orElse(null),
585 recipient.getProfileKey() == null
586 ? null
587 : base64.encodeToString(recipient.getProfileKey().serialize()),
588 recipient.getProfileKeyCredential() == null
589 ? null
590 : base64.encodeToString(recipient.getProfileKeyCredential().serialize()),
591 contact,
592 profile);
593 }).toList(), lastId);
594
595 // Write to memory first to prevent corrupting the file in case of serialization errors
596 try (var inMemoryOutput = new ByteArrayOutputStream()) {
597 objectMapper.writeValue(inMemoryOutput, storage);
598
599 var input = new ByteArrayInputStream(inMemoryOutput.toByteArray());
600 try (var outputStream = new FileOutputStream(file)) {
601 input.transferTo(outputStream);
602 }
603 } catch (Exception e) {
604 logger.error("Error saving recipient store file: {}", e.getMessage());
605 }
606 }
607
608 private record Storage(List<Recipient> recipients, long lastId) {
609
610 private record Recipient(
611 long id,
612 String number,
613 String uuid,
614 String profileKey,
615 String profileKeyCredential,
616 Storage.Recipient.Contact contact,
617 Storage.Recipient.Profile profile
618 ) {
619
620 private record Contact(
621 String name,
622 String color,
623 int messageExpirationTime,
624 boolean blocked,
625 boolean archived,
626 boolean profileSharingEnabled
627 ) {}
628
629 private record Profile(
630 long lastUpdateTimestamp,
631 String givenName,
632 String familyName,
633 String about,
634 String aboutEmoji,
635 String avatarUrlPath,
636 String paymentAddress,
637 String unidentifiedAccessMode,
638 Set<String> capabilities
639 ) {}
640 }
641 }
642
643 public interface RecipientMergeHandler {
644
645 void mergeRecipients(RecipientId recipientId, RecipientId toBeMergedRecipientId);
646 }
647 }