diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index bb65b3e9..adf3548b 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -54,6 +54,9 @@
+
+
+
diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts
index 2e5feb3a..ca37c1f3 100644
--- a/lib/build.gradle.kts
+++ b/lib/build.gradle.kts
@@ -21,6 +21,12 @@ dependencies {
implementation("org.slf4j", "slf4j-api", "2.0.3")
implementation("org.xerial", "sqlite-jdbc", "3.39.3.0")
implementation("com.zaxxer", "HikariCP", "5.0.1")
+
+ testImplementation("org.junit.jupiter", "junit-jupiter", "5.9.0")
+}
+
+tasks.named("test") {
+ useJUnitPlatform()
}
configurations {
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java
index 80cf7687..bd42ae5f 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java
@@ -56,16 +56,19 @@ import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
+import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
+import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collectors;
public final class IncomingMessageHandler {
@@ -194,7 +197,18 @@ public final class IncomingMessageHandler {
if (content != null) {
// Store uuid if we don't have it already
// address/uuid is validated by unidentified sender certificate
- account.getRecipientTrustedResolver().resolveRecipientTrusted(content.getSender());
+
+ boolean handledPniSignature = false;
+ if (content.getPniSignatureMessage().isPresent()) {
+ final var message = content.getPniSignatureMessage().get();
+ final var senderAddress = getSenderAddress(envelope, content);
+ if (senderAddress != null) {
+ handledPniSignature = handlePniSignatureMessage(message, senderAddress);
+ }
+ }
+ if (!handledPniSignature) {
+ account.getRecipientTrustedResolver().resolveRecipientTrusted(content.getSender());
+ }
}
if (envelope.isReceipt()) {
final var senderDeviceAddress = getSender(envelope, content);
@@ -215,8 +229,9 @@ public final class IncomingMessageHandler {
logger.info("Ignoring a message from blocked user/group: {}", envelope.getTimestamp());
return List.of();
} else if (notAllowedToSendToGroup) {
+ final var senderAddress = getSenderAddress(envelope, content);
logger.info("Ignoring a group message from an unauthorized sender (no member or admin): {} {}",
- (envelope.hasSourceUuid() ? envelope.getSourceAddress() : content.getSender()).getIdentifier(),
+ senderAddress == null ? null : senderAddress.getIdentifier(),
envelope.getTimestamp());
return List.of();
} else {
@@ -323,6 +338,32 @@ public final class IncomingMessageHandler {
return actions;
}
+ private boolean handlePniSignatureMessage(
+ final SignalServicePniSignatureMessage message, final SignalServiceAddress senderAddress
+ ) {
+ final var aci = ACI.from(senderAddress.getServiceId());
+ final var aciIdentity = account.getIdentityKeyStore().getIdentityInfo(aci);
+ final var pni = message.getPni();
+ final var pniIdentity = account.getIdentityKeyStore().getIdentityInfo(pni);
+
+ if (aciIdentity == null || pniIdentity == null || aci.equals(pni)) {
+ return false;
+ }
+
+ final var verified = pniIdentity.getIdentityKey()
+ .verifyAlternateIdentity(aciIdentity.getIdentityKey(), message.getSignature());
+
+ if (!verified) {
+ logger.debug("Invalid PNI signature of ACI {} with PNI {}", aci, pni);
+ return false;
+ }
+
+ logger.debug("Verified association of ACI {} with PNI {}", aci, pni);
+ account.getRecipientTrustedResolver()
+ .resolveRecipientTrusted(Optional.of(aci), Optional.of(pni), senderAddress.getNumber());
+ return true;
+ }
+
private void handleDecryptionErrorMessage(
final List actions,
final RecipientId sender,
@@ -585,12 +626,8 @@ public final class IncomingMessageHandler {
}
private boolean isMessageBlocked(SignalServiceEnvelope envelope, SignalServiceContent content) {
- SignalServiceAddress source;
- if (!envelope.isUnidentifiedSender() && envelope.hasSourceUuid()) {
- source = envelope.getSourceAddress();
- } else if (content != null) {
- source = content.getSender();
- } else {
+ SignalServiceAddress source = getSenderAddress(envelope, content);
+ if (source == null) {
return false;
}
final var recipientId = context.getRecipientHelper().resolveRecipient(source);
@@ -608,12 +645,8 @@ public final class IncomingMessageHandler {
}
private boolean isNotAllowedToSendToGroup(SignalServiceEnvelope envelope, SignalServiceContent content) {
- SignalServiceAddress source;
- if (!envelope.isUnidentifiedSender() && envelope.hasSourceUuid()) {
- source = envelope.getSourceAddress();
- } else if (content != null) {
- source = content.getSender();
- } else {
+ SignalServiceAddress source = getSenderAddress(envelope, content);
+ if (source == null) {
return false;
}
@@ -853,6 +886,16 @@ public final class IncomingMessageHandler {
this.account.getProfileStore().storeProfileKey(source, profileKey);
}
+ private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
+ if (!envelope.isUnidentifiedSender() && envelope.hasSourceUuid()) {
+ return envelope.getSourceAddress();
+ } else if (content != null) {
+ return content.getSender();
+ } else {
+ return null;
+ }
+ }
+
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
if (!envelope.isUnidentifiedSender() && envelope.hasSourceUuid()) {
return new DeviceAddress(context.getRecipientHelper().resolveRecipient(envelope.getSourceAddress()),
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
index 3c168860..c13be697 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
@@ -22,7 +22,7 @@ import java.sql.SQLException;
public class AccountDatabase extends Database {
private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
- private static final long DATABASE_VERSION = 10;
+ private static final long DATABASE_VERSION = 11;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@@ -288,5 +288,13 @@ public class AccountDatabase extends Database {
""");
}
}
+ if (oldVersion < 11) {
+ logger.debug("Updating database: Adding pni field");
+ try (final var statement = connection.createStatement()) {
+ statement.executeUpdate("""
+ ALTER TABLE recipient ADD COLUMN pni BLOB;
+ """);
+ }
+ }
}
}
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
index db919269..1759649e 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
@@ -102,7 +102,7 @@ public class SignalAccount implements Closeable {
private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class);
private static final int MINIMUM_STORAGE_VERSION = 1;
- private static final int CURRENT_STORAGE_VERSION = 5;
+ private static final int CURRENT_STORAGE_VERSION = 6;
private final Object LOCK = new Object();
@@ -634,6 +634,9 @@ public class SignalAccount implements Closeable {
migratedLegacyConfig = true;
}
}
+ if (previousStorageVersion < 6) {
+ getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
+ }
final var legacyAciPreKeysPath = getAciPreKeysPath(dataPath, accountPath);
if (legacyAciPreKeysPath.exists()) {
LegacyPreKeyStore.migrate(legacyAciPreKeysPath, getAciPreKeyStore());
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelper.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelper.java
new file mode 100644
index 00000000..0afde555
--- /dev/null
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelper.java
@@ -0,0 +1,140 @@
+package org.asamk.signal.manager.storage.recipients;
+
+import org.asamk.signal.manager.api.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.SQLException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class MergeRecipientHelper {
+
+ private final static Logger logger = LoggerFactory.getLogger(MergeRecipientHelper.class);
+
+ static Pair> resolveRecipientTrustedLocked(
+ Store store, RecipientAddress address
+ ) throws SQLException {
+ // address has serviceId and number, optionally also pni
+
+ final var recipients = store.findAllByAddress(address);
+
+ if (recipients.isEmpty()) {
+ logger.debug("Got new recipient, serviceId, PNI and number are unknown");
+ return new Pair<>(store.addNewRecipient(address), List.of());
+ }
+
+ if (recipients.size() == 1) {
+ final var recipient = recipients.stream().findFirst().get();
+ if (recipient.address().hasIdentifiersOf(address)) {
+ return new Pair<>(recipient.id(), List.of());
+ }
+
+ if (recipient.address().serviceId().isEmpty() || (
+ recipient.address().serviceId().equals(address.serviceId())
+ ) || (
+ recipient.address().pni().isPresent() && recipient.address().pni().equals(address.serviceId())
+ ) || (
+ recipient.address().serviceId().equals(address.pni())
+ ) || (
+ address.pni().isPresent() && address.pni().equals(recipient.address().pni())
+ )) {
+ logger.debug("Got existing recipient {}, updating with high trust address", recipient.id());
+ store.updateRecipientAddress(recipient.id(), recipient.address().withIdentifiersFrom(address));
+ return new Pair<>(recipient.id(), List.of());
+ }
+
+ logger.debug(
+ "Got recipient {} existing with number/pni, but different serviceId, so stripping its number and adding new recipient",
+ recipient.id());
+ store.updateRecipientAddress(recipient.id(), recipient.address().removeIdentifiersFrom(address));
+
+ return new Pair<>(store.addNewRecipient(address), List.of());
+ }
+
+ var resultingRecipient = recipients.stream()
+ .filter(r -> r.address().serviceId().equals(address.serviceId()) || r.address()
+ .pni()
+ .equals(address.serviceId()))
+ .findFirst();
+ if (resultingRecipient.isEmpty() && address.pni().isPresent()) {
+ resultingRecipient = recipients.stream().filter(r -> r.address().serviceId().equals(address.pni()) || (
+ address.serviceId().equals(address.pni()) && r.address().pni().equals(address.pni())
+ )).findFirst();
+ }
+
+ final Set remainingRecipients;
+ if (resultingRecipient.isEmpty()) {
+ remainingRecipients = recipients;
+ } else {
+ remainingRecipients = new HashSet<>(recipients);
+ remainingRecipients.remove(resultingRecipient.get());
+ }
+
+ final var recipientsToBeMerged = new HashSet();
+ final var recipientsToBeStripped = new HashSet();
+ for (final var recipient : remainingRecipients) {
+ if (!recipient.address().hasAdditionalIdentifiersThan(address)) {
+ recipientsToBeMerged.add(recipient);
+ continue;
+ }
+
+ if (recipient.address().hasOnlyPniAndNumber()) {
+ // PNI and phone number are linked by the server
+ recipientsToBeMerged.add(recipient);
+ continue;
+ }
+
+ recipientsToBeStripped.add(recipient);
+ }
+
+ logger.debug("Got separate recipients for high trust identifiers {}, need to merge ({}) and strip ({})",
+ address,
+ recipientsToBeMerged.stream().map(r -> r.id().toString()).collect(Collectors.joining(", ")),
+ recipientsToBeStripped.stream().map(r -> r.id().toString()).collect(Collectors.joining(", ")));
+
+ RecipientAddress finalAddress = resultingRecipient.map(RecipientWithAddress::address).orElse(null);
+ for (final var recipient : recipientsToBeMerged) {
+ if (finalAddress == null) {
+ finalAddress = recipient.address();
+ } else {
+ finalAddress = finalAddress.withIdentifiersFrom(recipient.address());
+ }
+ store.removeRecipientAddress(recipient.id());
+ }
+ if (finalAddress == null) {
+ finalAddress = address;
+ } else {
+ finalAddress = finalAddress.withIdentifiersFrom(address);
+ }
+
+ for (final var recipient : recipientsToBeStripped) {
+ store.updateRecipientAddress(recipient.id(), recipient.address().removeIdentifiersFrom(address));
+ }
+
+ // Create fixed RecipientIds that won't update its id after merged
+ final var toBeMergedRecipientIds = recipientsToBeMerged.stream()
+ .map(r -> new RecipientId(r.id().id(), null))
+ .toList();
+
+ if (resultingRecipient.isPresent()) {
+ store.updateRecipientAddress(resultingRecipient.get().id(), finalAddress);
+ return new Pair<>(resultingRecipient.get().id(), toBeMergedRecipientIds);
+ }
+
+ return new Pair<>(store.addNewRecipient(finalAddress), toBeMergedRecipientIds);
+ }
+
+ public interface Store {
+
+ Set findAllByAddress(final RecipientAddress address) throws SQLException;
+
+ RecipientId addNewRecipient(final RecipientAddress address) throws SQLException;
+
+ void updateRecipientAddress(RecipientId recipientId, final RecipientAddress address) throws SQLException;
+
+ void removeRecipientAddress(RecipientId recipientId) throws SQLException;
+ }
+}
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java
index c3b18299..7dc193a9 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java
@@ -24,6 +24,14 @@ public record RecipientAddress(Optional serviceId, Optional pni,
if (serviceId.isEmpty() && pni.isPresent()) {
serviceId = Optional.of(pni.get());
}
+ if (serviceId.isPresent() && serviceId.get() instanceof PNI sPNI) {
+ if (pni.isPresent() && !sPNI.equals(pni.get())) {
+ throw new AssertionError("Must not have two different PNIs!");
+ }
+ if (pni.isEmpty()) {
+ pni = Optional.of(sPNI);
+ }
+ }
if (serviceId.isEmpty() && number.isEmpty()) {
throw new AssertionError("Must have either a ServiceId or E164 number!");
}
@@ -49,6 +57,22 @@ public record RecipientAddress(Optional serviceId, Optional pni,
this(Optional.of(serviceId), Optional.empty());
}
+ public RecipientAddress withIdentifiersFrom(RecipientAddress address) {
+ return new RecipientAddress((
+ this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni)
+ ) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId,
+ address.pni.or(this::pni),
+ address.number.or(this::number));
+ }
+
+ public RecipientAddress removeIdentifiersFrom(RecipientAddress address) {
+ return new RecipientAddress(address.serviceId.equals(this.serviceId) || address.pni.equals(this.serviceId)
+ ? Optional.empty()
+ : this.serviceId,
+ address.pni.equals(this.pni) || address.serviceId.equals(this.pni) ? Optional.empty() : this.pni,
+ address.number.equals(this.number) ? Optional.empty() : this.number);
+ }
+
public ServiceId getServiceId() {
return serviceId.orElse(ServiceId.UNKNOWN);
}
@@ -89,6 +113,42 @@ public record RecipientAddress(Optional serviceId, Optional pni,
);
}
+ public boolean hasSingleIdentifier() {
+ return serviceId().isEmpty() || number.isEmpty();
+ }
+
+ public boolean hasIdentifiersOf(RecipientAddress address) {
+ return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.equals(pni))
+ && (address.pni.isEmpty() || address.pni.equals(pni))
+ && (address.number.isEmpty() || address.number.equals(number));
+ }
+
+ public boolean hasAdditionalIdentifiersThan(RecipientAddress address) {
+ return (
+ serviceId.isPresent() && (
+ address.serviceId.isEmpty() || (
+ !address.serviceId.equals(serviceId) && !address.pni.equals(serviceId)
+ )
+ )
+ ) || (
+ pni.isPresent() && !address.serviceId.equals(pni) && (
+ address.pni.isEmpty() || !address.pni.equals(pni)
+ )
+ ) || (
+ number.isPresent() && (
+ address.number.isEmpty() || !address.number.equals(number)
+ )
+ );
+ }
+
+ public boolean hasOnlyPniAndNumber() {
+ return pni.isPresent() && serviceId.equals(pni) && number.isPresent();
+ }
+
+ public boolean isServiceIdPNI() {
+ return serviceId.isPresent() && (pni.isPresent() && serviceId.equals(pni));
+ }
+
public SignalServiceAddress toSignalServiceAddress() {
return new SignalServiceAddress(getServiceId(), number);
}
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
index 1b601980..81470e83 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
@@ -53,6 +53,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
_id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT UNIQUE,
uuid BLOB UNIQUE,
+ pni BLOB UNIQUE,
profile_key BLOB,
profile_key_credential BLOB,
@@ -92,7 +93,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
final var sql = (
"""
- SELECT r.number, r.uuid
+ SELECT r.number, r.uuid, r.pni
FROM %s r
WHERE r._id = ?
"""
@@ -246,7 +247,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Optional aci, final Optional pni, final Optional number
) {
final var serviceId = aci.map(a -> (ServiceId) a).or(() -> pni);
- return resolveRecipientTrusted(new RecipientAddress(serviceId, number), false);
+ return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number), false);
}
@Override
@@ -308,7 +309,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
SELECT r._id,
- r.number, r.uuid,
+ r.number, r.uuid, r.pni,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived,
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
@@ -601,21 +602,33 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
private RecipientId resolveRecipientTrusted(RecipientAddress address, boolean isSelf) {
- final Pair> pair;
+ final Pair> pair;
synchronized (recipientsLock) {
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
- pair = resolveRecipientTrustedLocked(connection, address, isSelf);
+ if (address.hasSingleIdentifier() || (
+ !isSelf && selfAddressProvider.getSelfAddress().matches(address)
+ )) {
+ pair = new Pair<>(resolveRecipientLocked(connection, address), List.of());
+ } else {
+ pair = MergeRecipientHelper.resolveRecipientTrustedLocked(new HelperStore(connection), address);
+
+ for (final var toBeMergedRecipientId : pair.second()) {
+ mergeRecipientsLocked(connection, pair.first(), toBeMergedRecipientId);
+ }
+ }
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update recipient store", e);
}
}
- if (pair.second().isPresent()) {
+ if (pair.second().size() > 0) {
try (final var connection = database.getConnection()) {
- recipientMergeHandler.mergeRecipients(connection, pair.first(), pair.second().get());
- deleteRecipient(connection, pair.second().get());
+ for (final var toBeMergedRecipientId : pair.second()) {
+ recipientMergeHandler.mergeRecipients(connection, pair.first(), toBeMergedRecipientId);
+ deleteRecipient(connection, toBeMergedRecipientId);
+ }
} catch (SQLException e) {
throw new RuntimeException("Failed update recipient store", e);
}
@@ -623,82 +636,6 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
return pair.first();
}
- private Pair> resolveRecipientTrustedLocked(
- Connection connection, RecipientAddress address, boolean isSelf
- ) throws SQLException {
- if (!isSelf) {
- if (selfAddressProvider.getSelfAddress().matches(address)) {
- return new Pair<>(resolveRecipientLocked(connection, address), Optional.empty());
- }
- }
- final var byNumber = address.number().isEmpty()
- ? Optional.empty()
- : findByNumber(connection, address.number().get());
- final var byUuid = address.serviceId().isEmpty()
- ? Optional.empty()
- : findByServiceId(connection, address.serviceId().get());
-
- if (byNumber.isEmpty() && byUuid.isEmpty()) {
- logger.debug("Got new recipient, both uuid and number are unknown");
- return new Pair<>(addNewRecipient(connection, address), Optional.empty());
- }
-
- if (address.serviceId().isEmpty() || address.number().isEmpty() || byNumber.equals(byUuid)) {
- return new Pair<>(byUuid.or(() -> byNumber).map(RecipientWithAddress::id).get(), Optional.empty());
- }
-
- if (byNumber.isEmpty()) {
- logger.debug("Got recipient {} existing with uuid, updating with high trust number", byUuid.get().id());
- updateRecipientAddress(connection, byUuid.get().id(), address);
- return new Pair<>(byUuid.get().id(), Optional.empty());
- }
-
- final var byNumberRecipient = byNumber.get();
-
- if (byUuid.isEmpty()) {
- if (byNumberRecipient.address().serviceId().isPresent()) {
- logger.debug(
- "Got recipient {} existing with number, but different uuid, so stripping its number and adding new recipient",
- byNumberRecipient.id());
-
- updateRecipientAddress(connection,
- byNumberRecipient.id(),
- new RecipientAddress(byNumberRecipient.address().serviceId().get()));
- return new Pair<>(addNewRecipient(connection, address), Optional.empty());
- }
-
- logger.debug("Got recipient {} existing with number and no uuid, updating with high trust uuid",
- byNumberRecipient.id());
- updateRecipientAddress(connection, byNumberRecipient.id(), address);
- return new Pair<>(byNumberRecipient.id(), Optional.empty());
- }
-
- final var byUuidRecipient = byUuid.get();
-
- if (byNumberRecipient.address().serviceId().isPresent()) {
- logger.debug(
- "Got separate recipients for high trust number {} and uuid {}, recipient for number has different uuid, so stripping its number",
- byNumberRecipient.id(),
- byUuidRecipient.id());
-
- updateRecipientAddress(connection,
- byNumberRecipient.id(),
- new RecipientAddress(byNumberRecipient.address().serviceId().get()));
- updateRecipientAddress(connection, byUuidRecipient.id(), address);
- return new Pair<>(byUuidRecipient.id(), Optional.empty());
- }
-
- logger.debug("Got separate recipients for high trust number {} and uuid {}, need to merge them",
- byNumberRecipient.id(),
- byUuidRecipient.id());
- // Create a fixed RecipientId that won't update its id after merge
- final var toBeMergedRecipientId = new RecipientId(byNumberRecipient.id().id(), null);
- mergeRecipientsLocked(connection, byUuidRecipient.id(), toBeMergedRecipientId);
- removeRecipientAddress(connection, toBeMergedRecipientId);
- updateRecipientAddress(connection, byUuidRecipient.id(), address);
- return new Pair<>(byUuidRecipient.id(), Optional.of(toBeMergedRecipientId));
- }
-
private RecipientId resolveRecipientLocked(
Connection connection, RecipientAddress address
) throws SQLException {
@@ -762,13 +699,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
) throws SQLException {
final var sql = (
"""
- INSERT INTO %s (number, uuid)
- VALUES (?, ?)
+ INSERT INTO %s (number, uuid, pni)
+ VALUES (?, ?, ?)
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setString(1, address.number().orElse(null));
statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
+ statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.executeUpdate();
final var generatedKeys = statement.getGeneratedKeys();
if (generatedKeys.next()) {
@@ -785,7 +723,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
- SET number = NULL, uuid = NULL
+ SET number = NULL, uuid = NULL, pni = NULL
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@@ -801,14 +739,15 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
- SET number = ?, uuid = ?
+ SET number = ?, uuid = ?, pni = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setString(1, address.number().orElse(null));
statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
- statement.setLong(3, recipientId.id());
+ statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null));
+ statement.setLong(4, recipientId.id());
statement.executeUpdate();
}
}
@@ -861,9 +800,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final String number
) throws SQLException {
final var sql = """
- SELECT r._id, r.number, r.uuid
+ SELECT r._id, r.number, r.uuid, r.pni
FROM %s r
WHERE r.number = ?
+ LIMIT 1
""".formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setString(1, number);
@@ -875,9 +815,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final ServiceId serviceId
) throws SQLException {
final var sql = """
- SELECT r._id, r.number, r.uuid
+ SELECT r._id, r.number, r.uuid, r.pni
FROM %s r
- WHERE r.uuid = ?
+ WHERE r.uuid = ? OR r.pni = ?
+ LIMIT 1
""".formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, UuidUtil.toByteArray(serviceId.uuid()));
@@ -885,6 +826,25 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
+ private Set findAllByAddress(
+ final Connection connection, final RecipientAddress address
+ ) throws SQLException {
+ final var sql = """
+ SELECT r._id, r.number, r.uuid, r.pni
+ FROM %s r
+ WHERE r.uuid = ?1 OR r.pni = ?1 OR
+ r.uuid = ?2 OR r.pni = ?2 OR
+ r.number = ?3
+ """.formatted(TABLE_RECIPIENT);
+ try (final var statement = connection.prepareStatement(sql)) {
+ statement.setBytes(1, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
+ statement.setBytes(2, address.pni().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
+ statement.setString(3, address.number().orElse(null));
+ return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet)
+ .collect(Collectors.toSet());
+ }
+ }
+
private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = (
"""
@@ -946,8 +906,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private RecipientAddress getRecipientAddressFromResultSet(ResultSet resultSet) throws SQLException {
final var serviceId = Optional.ofNullable(resultSet.getBytes("uuid")).map(ServiceId::parseOrNull);
+ final var pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull);
final var number = Optional.ofNullable(resultSet.getString("number"));
- return new RecipientAddress(serviceId, Optional.empty(), number);
+ return new RecipientAddress(serviceId, pni, number);
}
private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException {
@@ -1032,5 +993,34 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
) throws SQLException;
}
- private record RecipientWithAddress(RecipientId id, RecipientAddress address) {}
+ private class HelperStore implements MergeRecipientHelper.Store {
+
+ private final Connection connection;
+
+ public HelperStore(final Connection connection) {
+ this.connection = connection;
+ }
+
+ @Override
+ public Set findAllByAddress(final RecipientAddress address) throws SQLException {
+ return RecipientStore.this.findAllByAddress(connection, address);
+ }
+
+ @Override
+ public RecipientId addNewRecipient(final RecipientAddress address) throws SQLException {
+ return RecipientStore.this.addNewRecipient(connection, address);
+ }
+
+ @Override
+ public void updateRecipientAddress(
+ final RecipientId recipientId, final RecipientAddress address
+ ) throws SQLException {
+ RecipientStore.this.updateRecipientAddress(connection, recipientId, address);
+ }
+
+ @Override
+ public void removeRecipientAddress(final RecipientId recipientId) throws SQLException {
+ RecipientStore.this.removeRecipientAddress(connection, recipientId);
+ }
+ }
}
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientWithAddress.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientWithAddress.java
new file mode 100644
index 00000000..6885a8af
--- /dev/null
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientWithAddress.java
@@ -0,0 +1,3 @@
+package org.asamk.signal.manager.storage.recipients;
+
+record RecipientWithAddress(RecipientId id, RecipientAddress address) {}
diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelperTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelperTest.java
new file mode 100644
index 00000000..42a57d35
--- /dev/null
+++ b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/MergeRecipientHelperTest.java
@@ -0,0 +1,330 @@
+package org.asamk.signal.manager.storage.recipients;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.whispersystems.signalservice.api.push.PNI;
+import org.whispersystems.signalservice.api.push.ServiceId;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class MergeRecipientHelperTest {
+
+ static final ServiceId SERVICE_ID_A = ServiceId.from(UUID.randomUUID());
+ static final ServiceId SERVICE_ID_B = ServiceId.from(UUID.randomUUID());
+ static final ServiceId SERVICE_ID_C = ServiceId.from(UUID.randomUUID());
+ static final PNI PNI_A = PNI.from(UUID.randomUUID());
+ static final PNI PNI_B = PNI.from(UUID.randomUUID());
+ static final PNI PNI_C = PNI.from(UUID.randomUUID());
+ static final String NUMBER_A = "+AAA";
+ static final String NUMBER_B = "+BBB";
+ static final String NUMBER_C = "+CCC";
+
+ static final PartialAddresses ADDR_A = new PartialAddresses(SERVICE_ID_A, PNI_A, NUMBER_A);
+ static final PartialAddresses ADDR_B = new PartialAddresses(SERVICE_ID_B, PNI_B, NUMBER_B);
+
+ static T[] testInstancesNone = new T[]{
+ // 1
+ new T(Set.of(), ADDR_A.FULL, Set.of(rec(1000000, ADDR_A.FULL))),
+ new T(Set.of(), ADDR_A.ACI_NUM, Set.of(rec(1000000, ADDR_A.ACI_NUM))),
+ new T(Set.of(), ADDR_A.ACI_PNI, Set.of(rec(1000000, ADDR_A.ACI_PNI))),
+ new T(Set.of(), ADDR_A.PNI_S_NUM, Set.of(rec(1000000, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(), ADDR_A.PNI_NUM, Set.of(rec(1000000, ADDR_A.PNI_NUM))),
+ };
+
+ static T[] testInstancesSingle = new T[]{
+ // 1
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI_NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S_NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI_PNI)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+
+ // 10
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.PNI), rec(1000000, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S)),
+ ADDR_A.ACI_NUM,
+ Set.of(rec(1, ADDR_A.PNI_S), rec(1000000, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.NUM)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_NUM)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_NUM)),
+ ADDR_A.ACI_NUM,
+ Set.of(rec(1, ADDR_A.PNI), rec(1000000, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S_NUM)),
+ ADDR_A.ACI_NUM,
+ Set.of(rec(1, ADDR_A.PNI_S), rec(1000000, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_PNI)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.FULL))),
+
+ // 19
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.ACI), rec(1000000, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.NUM)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(1000000, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_NUM)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S_NUM)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_PNI)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.FULL))),
+
+ // 28
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(1000000, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.NUM)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(1000000, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_NUM)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S_NUM)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI_PNI)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.FULL))),
+
+ // 37
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.PNI)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.NUM)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.NUM), rec(1000000, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.ACI_NUM)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_NUM)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S_NUM)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI_PNI)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+
+ new T(Set.of(rec(1, ADDR_A.FULL)), ADDR_B.FULL, Set.of(rec(1, ADDR_A.FULL), rec(1000000, ADDR_B.FULL))),
+ };
+
+ static T[] testInstancesTwo = new T[]{
+ // 1
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.ACI_NUM)), ADDR_A.FULL, Set.of(rec(2, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.NUM)), ADDR_A.FULL, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.ACI_NUM)), ADDR_A.FULL, Set.of(rec(2, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.PNI_S)), ADDR_A.FULL, Set.of(rec(2, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.ACI_PNI)), ADDR_A.FULL, Set.of(rec(2, ADDR_A.FULL))),
+
+ // 12
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.NUM)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.ACI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM)), ADDR_A.ACI_NUM, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM)),
+ ADDR_A.ACI_NUM,
+ Set.of(rec(1, ADDR_A.ACI_NUM), rec(2, ADDR_A.PNI_S))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.ACI_PNI)), ADDR_A.ACI_NUM, Set.of(rec(2, ADDR_A.FULL))),
+
+ // 16
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM)),
+ ADDR_A.PNI_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM)),
+ ADDR_A.PNI_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.NUM)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_NUM,
+ Set.of(rec(1, ADDR_A.PNI_NUM), rec(2, ADDR_A.ACI))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.NUM)), ADDR_A.PNI_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_NUM,
+ Set.of(rec(1, ADDR_A.PNI_NUM), rec(2, ADDR_A.ACI))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.PNI_S)), ADDR_A.PNI_NUM, Set.of(rec(2, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.ACI_PNI)), ADDR_A.PNI_NUM, Set.of(rec(2, ADDR_A.FULL))),
+
+ // 24
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.NUM)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.PNI_NUM), rec(2, ADDR_A.ACI))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.NUM)), ADDR_A.PNI_S_NUM, Set.of(rec(1, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.ACI_NUM)),
+ ADDR_A.PNI_S_NUM,
+ Set.of(rec(1, ADDR_A.PNI_S_NUM), rec(2, ADDR_A.ACI))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.PNI_S)), ADDR_A.PNI_S_NUM, Set.of(rec(2, ADDR_A.PNI_S_NUM))),
+ new T(Set.of(rec(1, ADDR_A.NUM), rec(2, ADDR_A.ACI_PNI)), ADDR_A.PNI_S_NUM, Set.of(rec(2, ADDR_A.FULL))),
+
+ // 32
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.ACI_PNI))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_NUM)), ADDR_A.ACI_PNI, Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI_S_NUM)),
+ ADDR_A.ACI_PNI,
+ Set.of(rec(1, ADDR_A.ACI_PNI), rec(2, ADDR_A.NUM))),
+ new T(Set.of(rec(1, ADDR_A.PNI), rec(2, ADDR_A.ACI_NUM)), ADDR_A.ACI_PNI, Set.of(rec(2, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.PNI_S), rec(2, ADDR_A.ACI_NUM)), ADDR_A.ACI_PNI, Set.of(rec(2, ADDR_A.FULL))),
+ };
+
+ static T[] testInstancesThree = new T[]{
+ // 1
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI), rec(3, ADDR_A.NUM)),
+ ADDR_A.FULL,
+ Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI.withIdentifiersFrom(ADDR_B.PNI)), rec(2, ADDR_A.PNI), rec(3, ADDR_A.NUM)),
+ ADDR_A.FULL,
+ Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI.withIdentifiersFrom(ADDR_B.NUM)), rec(2, ADDR_A.PNI), rec(3, ADDR_A.NUM)),
+ ADDR_A.FULL,
+ Set.of(rec(1, ADDR_A.FULL))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI), rec(3, ADDR_A.NUM.withIdentifiersFrom(ADDR_B.ACI))),
+ ADDR_A.FULL,
+ Set.of(rec(1, ADDR_A.FULL), rec(3, ADDR_B.ACI))),
+ new T(Set.of(rec(1, ADDR_A.ACI), rec(2, ADDR_A.PNI.withIdentifiersFrom(ADDR_B.ACI)), rec(3, ADDR_A.NUM)),
+ ADDR_A.FULL,
+ Set.of(rec(1, ADDR_A.FULL), rec(2, ADDR_B.ACI))),
+ };
+
+ @ParameterizedTest
+ @MethodSource
+ void resolveRecipientTrustedLocked_NoneExisting(T test) throws Exception {
+ final var testStore = new TestStore(test.input);
+ MergeRecipientHelper.resolveRecipientTrustedLocked(testStore, test.request);
+ assertEquals(test.output, testStore.getRecipients());
+ }
+
+ private static Stream resolveRecipientTrustedLocked_NoneExisting() {
+ return Arrays.stream(testInstancesNone).map(Arguments::of);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void resolveRecipientTrustedLocked_SingleExisting(T test) throws Exception {
+ final var testStore = new TestStore(test.input);
+ MergeRecipientHelper.resolveRecipientTrustedLocked(testStore, test.request);
+ assertEquals(test.output, testStore.getRecipients());
+ }
+
+ private static Stream resolveRecipientTrustedLocked_SingleExisting() {
+ return Arrays.stream(testInstancesSingle).map(Arguments::of);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void resolveRecipientTrustedLocked_TwoExisting(T test) throws Exception {
+ final var testStore = new TestStore(test.input);
+ MergeRecipientHelper.resolveRecipientTrustedLocked(testStore, test.request);
+ assertEquals(test.output, testStore.getRecipients());
+ }
+
+ private static Stream resolveRecipientTrustedLocked_TwoExisting() {
+ return Arrays.stream(testInstancesTwo).map(Arguments::of);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void resolveRecipientTrustedLocked_ThreeExisting(T test) throws Exception {
+ final var testStore = new TestStore(test.input);
+ MergeRecipientHelper.resolveRecipientTrustedLocked(testStore, test.request);
+ assertEquals(test.output, testStore.getRecipients());
+ }
+
+ private static Stream resolveRecipientTrustedLocked_ThreeExisting() {
+ return Arrays.stream(testInstancesThree).map(Arguments::of);
+ }
+
+ private static RecipientWithAddress rec(long recipientId, RecipientAddress address) {
+ return new RecipientWithAddress(new RecipientId(recipientId, null), address);
+ }
+
+ record T(
+ Set input, RecipientAddress request, Set output
+ ) {
+
+ @Override
+ public String toString() {
+ return "T{#input=%s, request=%s_%s_%s, #output=%s}".formatted(input.size(),
+ request.serviceId().isPresent() ? "SVI" : "",
+ request.pni().isPresent() ? "PNI" : "",
+ request.number().isPresent() ? "NUM" : "",
+ output.size());
+ }
+ }
+
+ static class TestStore implements MergeRecipientHelper.Store {
+
+ final Set recipients;
+ long nextRecipientId = 1000000;
+
+ TestStore(final Set recipients) {
+ this.recipients = new HashSet<>(recipients);
+ }
+
+ public Set getRecipients() {
+ return recipients;
+ }
+
+ @Override
+ public Set findAllByAddress(final RecipientAddress address) {
+ return recipients.stream().filter(r -> r.address().matches(address)).collect(Collectors.toSet());
+ }
+
+ @Override
+ public RecipientId addNewRecipient(final RecipientAddress address) {
+ final var recipientId = new RecipientId(nextRecipientId++, null);
+ recipients.add(new RecipientWithAddress(recipientId, address));
+ return recipientId;
+ }
+
+ @Override
+ public void updateRecipientAddress(
+ final RecipientId recipientId, final RecipientAddress address
+ ) {
+ recipients.removeIf(r -> r.id().equals(recipientId));
+ recipients.add(new RecipientWithAddress(recipientId, address));
+ }
+
+ @Override
+ public void removeRecipientAddress(final RecipientId recipientId) {
+ recipients.removeIf(r -> r.id().equals(recipientId));
+ }
+ }
+
+ private record PartialAddresses(
+ RecipientAddress FULL,
+ RecipientAddress ACI,
+ RecipientAddress PNI,
+ RecipientAddress PNI_S,
+ RecipientAddress NUM,
+ RecipientAddress ACI_NUM,
+ RecipientAddress PNI_NUM,
+ RecipientAddress PNI_S_NUM,
+ RecipientAddress ACI_PNI
+ ) {
+
+ PartialAddresses(ServiceId serviceId, PNI pni, String number) {
+ this(new RecipientAddress(serviceId, pni, number),
+ new RecipientAddress(serviceId, null, null),
+ new RecipientAddress(null, pni, null),
+ new RecipientAddress(ServiceId.from(pni.uuid()), null, null),
+ new RecipientAddress(null, null, number),
+ new RecipientAddress(serviceId, null, number),
+ new RecipientAddress(null, pni, number),
+ new RecipientAddress(ServiceId.from(pni.uuid()), null, number),
+ new RecipientAddress(serviceId, pni, null));
+ }
+ }
+}