1 package org
.asamk
.signal
.manager
.storage
.recipients
;
3 import com
.fasterxml
.jackson
.databind
.ObjectMapper
;
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
.ExpiringProfileKeyCredential
;
12 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKey
;
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
;
20 import java
.io
.ByteArrayInputStream
;
21 import java
.io
.ByteArrayOutputStream
;
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
;
33 import java
.util
.Objects
;
34 import java
.util
.Optional
;
36 import java
.util
.UUID
;
37 import java
.util
.function
.Supplier
;
38 import java
.util
.stream
.Collectors
;
40 public class RecipientStore
implements RecipientResolver
, RecipientTrustedResolver
, ContactsStore
, ProfileStore
{
42 private final static Logger logger
= LoggerFactory
.getLogger(RecipientStore
.class);
44 private final ObjectMapper objectMapper
;
45 private final File file
;
46 private final RecipientMergeHandler recipientMergeHandler
;
47 private final SelfAddressProvider selfAddressProvider
;
49 private final Map
<RecipientId
, Recipient
> recipients
;
50 private final Map
<Long
, Long
> recipientsMerged
= new HashMap
<>();
53 private boolean isBulkUpdating
;
55 public static RecipientStore
load(
56 File file
, RecipientMergeHandler recipientMergeHandler
, SelfAddressProvider selfAddressProvider
58 final var objectMapper
= Utils
.createStorageObjectMapper();
59 try (var inputStream
= new FileInputStream(file
)) {
60 final var storage
= objectMapper
.readValue(inputStream
, Storage
.class);
62 final var recipientStore
= new RecipientStore(objectMapper
,
64 recipientMergeHandler
,
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
));
73 Contact contact
= null;
74 if (r
.contact
!= null) {
75 contact
= new Contact(r
.contact
.name
,
78 r
.contact
.messageExpirationTime
,
81 r
.contact
.profileSharingEnabled
);
84 ProfileKey profileKey
= null;
85 if (r
.profileKey
!= null) {
87 profileKey
= new ProfileKey(Base64
.getDecoder().decode(r
.profileKey
));
88 } catch (InvalidInputException ignored
) {
92 ExpiringProfileKeyCredential expiringProfileKeyCredential
= null;
93 if (r
.expiringProfileKeyCredential
!= null) {
95 expiringProfileKeyCredential
= new ExpiringProfileKeyCredential(Base64
.getDecoder()
96 .decode(r
.expiringProfileKeyCredential
));
97 } catch (Throwable ignored
) {
101 Profile profile
= null;
102 if (r
.profile
!= null) {
103 profile
= new Profile(r
.profile
.lastUpdateTimestamp
,
105 r
.profile
.familyName
,
107 r
.profile
.aboutEmoji
,
108 r
.profile
.avatarUrlPath
,
109 r
.profile
.mobileCoinAddress
== null
111 : Base64
.getDecoder().decode(r
.profile
.mobileCoinAddress
),
112 Profile
.UnidentifiedAccessMode
.valueOfOrUnknown(r
.profile
.unidentifiedAccessMode
),
113 r
.profile
.capabilities
.stream()
114 .map(Profile
.Capability
::valueOfOrNull
)
115 .filter(Objects
::nonNull
)
116 .collect(Collectors
.toSet()));
119 return new Recipient(recipientId
, address
, contact
, profileKey
, expiringProfileKeyCredential
, profile
);
120 }).collect(Collectors
.toMap(Recipient
::getRecipientId
, r
-> r
));
122 recipientStore
.addRecipients(recipients
);
124 return recipientStore
;
125 } catch (FileNotFoundException e
) {
126 logger
.trace("Creating new recipient store.");
127 return new RecipientStore(objectMapper
,
129 recipientMergeHandler
,
133 } catch (IOException e
) {
134 logger
.warn("Failed to load recipient store", e
);
135 throw new RuntimeException(e
);
139 private RecipientStore(
140 final ObjectMapper objectMapper
,
142 final RecipientMergeHandler recipientMergeHandler
,
143 final SelfAddressProvider selfAddressProvider
,
144 final Map
<RecipientId
, Recipient
> recipients
,
147 this.objectMapper
= objectMapper
;
149 this.recipientMergeHandler
= recipientMergeHandler
;
150 this.selfAddressProvider
= selfAddressProvider
;
151 this.recipients
= recipients
;
152 this.lastId
= lastId
;
155 public void setBulkUpdating(final boolean bulkUpdating
) {
156 isBulkUpdating
= bulkUpdating
;
158 synchronized (recipients
) {
164 public RecipientAddress
resolveRecipientAddress(RecipientId recipientId
) {
165 synchronized (recipients
) {
166 return getRecipient(recipientId
).getAddress();
170 public Recipient
getRecipient(RecipientId recipientId
) {
171 synchronized (recipients
) {
172 return recipients
.get(recipientId
);
176 public Collection
<RecipientId
> getRecipientIdsWithEnabledProfileSharing() {
177 synchronized (recipients
) {
178 return recipients
.values().stream().filter(r
-> {
179 final var contact
= r
.getContact();
180 return contact
!= null && !contact
.isBlocked() && contact
.isProfileSharingEnabled();
181 }).map(Recipient
::getRecipientId
).toList();
186 public RecipientId
resolveRecipient(ServiceId serviceId
) {
187 return resolveRecipient(new RecipientAddress(serviceId
.uuid()), false, false);
191 public RecipientId
resolveRecipient(final long recipientId
) {
192 final var recipient
= getRecipient(new RecipientId(recipientId
, this));
193 return recipient
== null ?
null : recipient
.getRecipientId();
197 public RecipientId
resolveRecipient(final String identifier
) {
198 return resolveRecipient(Utils
.getRecipientAddressFromIdentifier(identifier
), false, false);
201 public RecipientId
resolveRecipient(
202 final String number
, Supplier
<ACI
> aciSupplier
203 ) throws UnregisteredRecipientException
{
204 final Optional
<Recipient
> byNumber
;
205 synchronized (recipients
) {
206 byNumber
= findByNumberLocked(number
);
208 if (byNumber
.isEmpty() || byNumber
.get().getAddress().uuid().isEmpty()) {
209 final var aci
= aciSupplier
.get();
211 throw new UnregisteredRecipientException(new RecipientAddress(null, number
));
214 return resolveRecipient(new RecipientAddress(aci
.uuid(), number
), false, false);
216 return byNumber
.get().getRecipientId();
219 public RecipientId
resolveRecipient(RecipientAddress address
) {
220 return resolveRecipient(address
, false, false);
224 public RecipientId
resolveRecipient(final SignalServiceAddress address
) {
225 return resolveRecipient(new RecipientAddress(address
), false, false);
229 public RecipientId
resolveSelfRecipientTrusted(RecipientAddress address
) {
230 return resolveRecipient(address
, true, true);
233 public RecipientId
resolveRecipientTrusted(RecipientAddress address
) {
234 return resolveRecipient(address
, true, false);
238 public RecipientId
resolveRecipientTrusted(SignalServiceAddress address
) {
239 return resolveRecipient(new RecipientAddress(address
), true, false);
242 public List
<RecipientId
> resolveRecipientsTrusted(List
<RecipientAddress
> addresses
) {
243 final List
<RecipientId
> recipientIds
;
244 final List
<Pair
<RecipientId
, RecipientId
>> toBeMerged
= new ArrayList
<>();
245 synchronized (recipients
) {
246 recipientIds
= addresses
.stream().map(address
-> {
247 final var pair
= resolveRecipientLocked(address
, true, false);
248 if (pair
.second().isPresent()) {
249 toBeMerged
.add(new Pair
<>(pair
.first(), pair
.second().get()));
254 for (var pair
: toBeMerged
) {
255 recipientMergeHandler
.mergeRecipients(pair
.first(), pair
.second());
261 public void storeContact(RecipientId recipientId
, final Contact contact
) {
262 synchronized (recipients
) {
263 final var recipient
= recipients
.get(recipientId
);
264 storeRecipientLocked(recipientId
, Recipient
.newBuilder(recipient
).withContact(contact
).build());
269 public Contact
getContact(RecipientId recipientId
) {
270 final var recipient
= getRecipient(recipientId
);
271 return recipient
== null ?
null : recipient
.getContact();
275 public List
<Pair
<RecipientId
, Contact
>> getContacts() {
276 return recipients
.entrySet()
278 .filter(e
-> e
.getValue().getContact() != null)
279 .map(e
-> new Pair
<>(e
.getKey(), e
.getValue().getContact()))
283 public List
<Recipient
> getRecipients(
284 boolean onlyContacts
, Optional
<Boolean
> blocked
, Set
<RecipientId
> recipientIds
, Optional
<String
> name
286 return recipients
.values()
288 .filter(r
-> !onlyContacts
|| r
.getContact() != null)
289 .filter(r
-> blocked
.isEmpty() || (
291 r
.getContact() != null && r
.getContact().isBlocked()
294 .filter(r
-> recipientIds
.isEmpty() || (recipientIds
.contains(r
.getRecipientId())))
295 .filter(r
-> name
.isEmpty()
296 || (r
.getContact() != null && name
.get().equals(r
.getContact().getName()))
297 || (r
.getProfile() != null && name
.get().equals(r
.getProfile().getDisplayName())))
302 public void deleteContact(RecipientId recipientId
) {
303 synchronized (recipients
) {
304 final var recipient
= recipients
.get(recipientId
);
305 storeRecipientLocked(recipientId
, Recipient
.newBuilder(recipient
).withContact(null).build());
309 public void deleteRecipientData(RecipientId recipientId
) {
310 synchronized (recipients
) {
311 logger
.debug("Deleting recipient data for {}", recipientId
);
312 final var recipient
= recipients
.get(recipientId
);
313 recipient
.getAddress()
315 .ifPresent(uuid
-> storeRecipientLocked(recipientId
,
316 Recipient
.newBuilder()
317 .withRecipientId(recipientId
)
318 .withAddress(new RecipientAddress(uuid
))
324 public Profile
getProfile(final RecipientId recipientId
) {
325 final var recipient
= getRecipient(recipientId
);
326 return recipient
== null ?
null : recipient
.getProfile();
330 public ProfileKey
getProfileKey(final RecipientId recipientId
) {
331 final var recipient
= getRecipient(recipientId
);
332 return recipient
== null ?
null : recipient
.getProfileKey();
336 public ExpiringProfileKeyCredential
getExpiringProfileKeyCredential(final RecipientId recipientId
) {
337 final var recipient
= getRecipient(recipientId
);
338 return recipient
== null ?
null : recipient
.getExpiringProfileKeyCredential();
342 public void storeProfile(RecipientId recipientId
, final Profile profile
) {
343 synchronized (recipients
) {
344 final var recipient
= recipients
.get(recipientId
);
345 storeRecipientLocked(recipientId
, Recipient
.newBuilder(recipient
).withProfile(profile
).build());
350 public void storeSelfProfileKey(final RecipientId recipientId
, final ProfileKey profileKey
) {
351 storeProfileKey(recipientId
, profileKey
, false);
355 public void storeProfileKey(RecipientId recipientId
, final ProfileKey profileKey
) {
356 storeProfileKey(recipientId
, profileKey
, true);
359 private void storeProfileKey(RecipientId recipientId
, final ProfileKey profileKey
, boolean resetProfile
) {
360 synchronized (recipients
) {
361 final var recipient
= recipients
.get(recipientId
);
362 if (profileKey
!= null && profileKey
.equals(recipient
.getProfileKey()) && (
363 recipient
.getProfile() == null || (
364 recipient
.getProfile().getUnidentifiedAccessMode() != Profile
.UnidentifiedAccessMode
.UNKNOWN
365 && recipient
.getProfile().getUnidentifiedAccessMode()
366 != Profile
.UnidentifiedAccessMode
.DISABLED
372 final var builder
= Recipient
.newBuilder(recipient
)
373 .withProfileKey(profileKey
)
374 .withExpiringProfileKeyCredential(null);
376 builder
.withProfile(recipient
.getProfile() == null
378 : Profile
.newBuilder(recipient
.getProfile()).withLastUpdateTimestamp(0).build());
380 final var newRecipient
= builder
.build();
381 storeRecipientLocked(recipientId
, newRecipient
);
386 public void storeExpiringProfileKeyCredential(
387 RecipientId recipientId
, final ExpiringProfileKeyCredential expiringProfileKeyCredential
389 synchronized (recipients
) {
390 final var recipient
= recipients
.get(recipientId
);
391 storeRecipientLocked(recipientId
,
392 Recipient
.newBuilder(recipient
)
393 .withExpiringProfileKeyCredential(expiringProfileKeyCredential
)
398 public boolean isEmpty() {
399 synchronized (recipients
) {
400 return recipients
.isEmpty();
404 private void addRecipients(final Map
<RecipientId
, Recipient
> recipients
) {
405 this.recipients
.putAll(recipients
);
409 * @param isHighTrust true, if the number/uuid connection was obtained from a trusted source.
410 * Has no effect, if the address contains only a number or a uuid.
412 private RecipientId
resolveRecipient(RecipientAddress address
, boolean isHighTrust
, boolean isSelf
) {
413 final Pair
<RecipientId
, Optional
<RecipientId
>> pair
;
414 synchronized (recipients
) {
415 pair
= resolveRecipientLocked(address
, isHighTrust
, isSelf
);
418 if (pair
.second().isPresent()) {
419 recipientMergeHandler
.mergeRecipients(pair
.first(), pair
.second().get());
424 private Pair
<RecipientId
, Optional
<RecipientId
>> resolveRecipientLocked(
425 RecipientAddress address
, boolean isHighTrust
, boolean isSelf
427 if (isHighTrust
&& !isSelf
) {
428 if (selfAddressProvider
.getSelfAddress().matches(address
)) {
432 final var byNumber
= address
.number().isEmpty()
433 ? Optional
.<Recipient
>empty()
434 : findByNumberLocked(address
.number().get());
435 final var byUuid
= address
.uuid().isEmpty()
436 ? Optional
.<Recipient
>empty()
437 : findByUuidLocked(address
.uuid().get());
439 if (byNumber
.isEmpty() && byUuid
.isEmpty()) {
440 logger
.debug("Got new recipient, both uuid and number are unknown");
442 if (isHighTrust
|| address
.uuid().isEmpty() || address
.number().isEmpty()) {
443 return new Pair
<>(addNewRecipientLocked(address
), Optional
.empty());
446 return new Pair
<>(addNewRecipientLocked(new RecipientAddress(address
.uuid().get())), Optional
.empty());
449 if (!isHighTrust
|| address
.uuid().isEmpty() || address
.number().isEmpty() || byNumber
.equals(byUuid
)) {
450 return new Pair
<>(byUuid
.or(() -> byNumber
).map(Recipient
::getRecipientId
).get(), Optional
.empty());
453 if (byNumber
.isEmpty()) {
454 logger
.debug("Got recipient {} existing with uuid, updating with high trust number",
455 byUuid
.get().getRecipientId());
456 updateRecipientAddressLocked(byUuid
.get().getRecipientId(), address
);
457 return new Pair
<>(byUuid
.get().getRecipientId(), Optional
.empty());
460 final var byNumberRecipient
= byNumber
.get();
462 if (byUuid
.isEmpty()) {
463 if (byNumberRecipient
.getAddress().uuid().isPresent()) {
465 "Got recipient {} existing with number, but different uuid, so stripping its number and adding new recipient",
466 byNumberRecipient
.getRecipientId());
468 updateRecipientAddressLocked(byNumberRecipient
.getRecipientId(),
469 new RecipientAddress(byNumberRecipient
.getAddress().uuid().get()));
470 return new Pair
<>(addNewRecipientLocked(address
), Optional
.empty());
473 logger
.debug("Got recipient {} existing with number and no uuid, updating with high trust uuid",
474 byNumberRecipient
.getRecipientId());
475 updateRecipientAddressLocked(byNumberRecipient
.getRecipientId(), address
);
476 return new Pair
<>(byNumberRecipient
.getRecipientId(), Optional
.empty());
479 final var byUuidRecipient
= byUuid
.get();
481 if (byNumberRecipient
.getAddress().uuid().isPresent()) {
483 "Got separate recipients for high trust number {} and uuid {}, recipient for number has different uuid, so stripping its number",
484 byNumberRecipient
.getRecipientId(),
485 byUuidRecipient
.getRecipientId());
487 updateRecipientAddressLocked(byNumberRecipient
.getRecipientId(),
488 new RecipientAddress(byNumberRecipient
.getAddress().uuid().get()));
489 updateRecipientAddressLocked(byUuidRecipient
.getRecipientId(), address
);
490 return new Pair
<>(byUuidRecipient
.getRecipientId(), Optional
.empty());
493 logger
.debug("Got separate recipients for high trust number {} and uuid {}, need to merge them",
494 byNumberRecipient
.getRecipientId(),
495 byUuidRecipient
.getRecipientId());
496 updateRecipientAddressLocked(byUuidRecipient
.getRecipientId(), address
);
497 // Create a fixed RecipientId that won't update its id after merge
498 final var toBeMergedRecipientId
= new RecipientId(byNumberRecipient
.getRecipientId().id(), null);
499 mergeRecipientsLocked(byUuidRecipient
.getRecipientId(), toBeMergedRecipientId
);
500 return new Pair
<>(byUuidRecipient
.getRecipientId(), Optional
.of(toBeMergedRecipientId
));
503 private RecipientId
addNewRecipientLocked(final RecipientAddress address
) {
504 final var nextRecipientId
= nextIdLocked();
505 logger
.debug("Adding new recipient {} with address {}", nextRecipientId
, address
);
506 storeRecipientLocked(nextRecipientId
, new Recipient(nextRecipientId
, address
, null, null, null, null));
507 return nextRecipientId
;
510 private void updateRecipientAddressLocked(RecipientId recipientId
, final RecipientAddress address
) {
511 final var recipient
= recipients
.get(recipientId
);
512 storeRecipientLocked(recipientId
, Recipient
.newBuilder(recipient
).withAddress(address
).build());
515 long getActualRecipientId(long recipientId
) {
516 while (recipientsMerged
.containsKey(recipientId
)) {
517 final var newRecipientId
= recipientsMerged
.get(recipientId
);
518 logger
.debug("Using {} instead of {}, because recipients have been merged", newRecipientId
, recipientId
);
519 recipientId
= newRecipientId
;
524 private void storeRecipientLocked(final RecipientId recipientId
, final Recipient recipient
) {
525 final var existingRecipient
= recipients
.get(recipientId
);
526 if (existingRecipient
== null || !existingRecipient
.equals(recipient
)) {
527 recipients
.put(recipientId
, recipient
);
532 private void mergeRecipientsLocked(RecipientId recipientId
, RecipientId toBeMergedRecipientId
) {
533 final var recipient
= recipients
.get(recipientId
);
534 final var toBeMergedRecipient
= recipients
.get(toBeMergedRecipientId
);
535 recipients
.put(recipientId
,
536 new Recipient(recipientId
,
537 recipient
.getAddress(),
538 recipient
.getContact() != null ? recipient
.getContact() : toBeMergedRecipient
.getContact(),
539 recipient
.getProfileKey() != null
540 ? recipient
.getProfileKey()
541 : toBeMergedRecipient
.getProfileKey(),
542 recipient
.getExpiringProfileKeyCredential() != null
543 ? recipient
.getExpiringProfileKeyCredential()
544 : toBeMergedRecipient
.getExpiringProfileKeyCredential(),
545 recipient
.getProfile() != null ? recipient
.getProfile() : toBeMergedRecipient
.getProfile()));
546 recipients
.remove(toBeMergedRecipientId
);
547 recipientsMerged
.put(toBeMergedRecipientId
.id(), recipientId
.id());
551 private Optional
<Recipient
> findByNumberLocked(final String number
) {
552 return recipients
.entrySet()
554 .filter(entry
-> entry
.getValue().getAddress().number().isPresent() && number
.equals(entry
.getValue()
559 .map(Map
.Entry
::getValue
);
562 private Optional
<Recipient
> findByUuidLocked(final UUID uuid
) {
563 return recipients
.entrySet()
565 .filter(entry
-> entry
.getValue().getAddress().uuid().isPresent() && uuid
.equals(entry
.getValue()
570 .map(Map
.Entry
::getValue
);
573 private RecipientId
nextIdLocked() {
574 return new RecipientId(++this.lastId
, this);
577 private void saveLocked() {
578 if (isBulkUpdating
) {
581 final var base64
= Base64
.getEncoder();
582 var storage
= new Storage(recipients
.entrySet().stream().map(pair
-> {
583 final var recipient
= pair
.getValue();
584 final var recipientContact
= recipient
.getContact();
585 final var contact
= recipientContact
== null
587 : new Storage
.Recipient
.Contact(recipientContact
.getGivenName(),
588 recipientContact
.getFamilyName(),
589 recipientContact
.getColor(),
590 recipientContact
.getMessageExpirationTime(),
591 recipientContact
.isBlocked(),
592 recipientContact
.isArchived(),
593 recipientContact
.isProfileSharingEnabled());
594 final var recipientProfile
= recipient
.getProfile();
595 final var profile
= recipientProfile
== null
597 : new Storage
.Recipient
.Profile(recipientProfile
.getLastUpdateTimestamp(),
598 recipientProfile
.getGivenName(),
599 recipientProfile
.getFamilyName(),
600 recipientProfile
.getAbout(),
601 recipientProfile
.getAboutEmoji(),
602 recipientProfile
.getAvatarUrlPath(),
603 recipientProfile
.getMobileCoinAddress() == null
605 : base64
.encodeToString(recipientProfile
.getMobileCoinAddress()),
606 recipientProfile
.getUnidentifiedAccessMode().name(),
607 recipientProfile
.getCapabilities().stream().map(Enum
::name
).collect(Collectors
.toSet()));
608 return new Storage
.Recipient(pair
.getKey().id(),
609 recipient
.getAddress().number().orElse(null),
610 recipient
.getAddress().uuid().map(UUID
::toString
).orElse(null),
611 recipient
.getProfileKey() == null
613 : base64
.encodeToString(recipient
.getProfileKey().serialize()),
614 recipient
.getExpiringProfileKeyCredential() == null
616 : base64
.encodeToString(recipient
.getExpiringProfileKeyCredential().serialize()),
619 }).toList(), lastId
);
621 // Write to memory first to prevent corrupting the file in case of serialization errors
622 try (var inMemoryOutput
= new ByteArrayOutputStream()) {
623 objectMapper
.writeValue(inMemoryOutput
, storage
);
625 var input
= new ByteArrayInputStream(inMemoryOutput
.toByteArray());
626 try (var outputStream
= new FileOutputStream(file
)) {
627 input
.transferTo(outputStream
);
629 } catch (Exception e
) {
630 logger
.error("Error saving recipient store file: {}", e
.getMessage());
634 private record Storage(List
<Recipient
> recipients
, long lastId
) {
636 private record Recipient(
641 String expiringProfileKeyCredential
,
642 Storage
.Recipient
.Contact contact
,
643 Storage
.Recipient
.Profile profile
646 private record Contact(
650 int messageExpirationTime
,
653 boolean profileSharingEnabled
656 private record Profile(
657 long lastUpdateTimestamp
,
662 String avatarUrlPath
,
663 String mobileCoinAddress
,
664 String unidentifiedAccessMode
,
665 Set
<String
> capabilities
670 public interface RecipientMergeHandler
{
672 void mergeRecipients(RecipientId recipientId
, RecipientId toBeMergedRecipientId
);