Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Contacts/RecipientMerger.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import GRDB
public import LibSignalClient

public protocol RecipientMerger {
    /// We're registering, linking, changing our number, etc. This is the only
    /// time we're allowed to "merge" the identifiers for our own account.
    func applyMergeForLocalAccount(
        aci: Aci,
        phoneNumber: E164,
        pni: Pni?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient

    /// We've learned about an association from Storage Service.
    func applyMergeFromStorageService(
        localIdentifiers: LocalIdentifiers,
        isPrimaryDevice: Bool,
        serviceIds: AtLeastOneServiceId,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient

    func applyMergeFromContactSync(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient

    /// We've learned about an association from CDS.
    func applyMergeFromContactDiscovery(
        localIdentifiers: LocalIdentifiers,
        phoneNumber: E164,
        pni: Pni,
        aci: Aci?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient?

    /// We've learned about an association from a Sealed Sender message. These
    /// always come from an ACI, but they might not have a phone number if phone
    /// number sharing is disabled.
    func applyMergeFromSealedSender(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient

    func applyMergeFromPniSignature(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        pni: Pni,
        tx: DBWriteTransaction,
    )

    /// We learned an ACI is unregistered, so we might need to split the E164/PNI.
    func splitUnregisteredRecipientIfNeeded(
        localIdentifiers: LocalIdentifiers,
        unregisteredRecipient: inout SignalRecipient,
        tx: DBWriteTransaction,
    )
}

protocol RecipientMergeObserver {
    /// We are about to learn a new association between identifiers.
    ///
    /// - parameter recipient: The recipient whose identifiers are about to be
    /// removed or replaced.
    ///
    /// - parameter mightReplaceNonnilPhoneNumber: If true, we might be about to
    /// update an ACI/phone number association. This property exists mostly as a
    /// performance optimization for ``AuthorMergeObserver``.
    func willBreakAssociation(for recipient: SignalRecipient, mightReplaceNonnilPhoneNumber: Bool, tx: DBWriteTransaction)

    /// We just learned a new association between identifiers.
    ///
    /// If you provide only a single identifier to a merge, then it's not
    /// possible for us to learn about an association. However, if you provide
    /// two or more identifiers, and if it's the first time we've learned that
    /// they're linked, this callback will be invoked.
    func didLearnAssociation(mergedRecipient: MergedRecipient, tx: DBWriteTransaction)
}

struct MergedRecipient {
    let isLocalRecipient: Bool
    let oldRecipient: SignalRecipient?
    let newRecipient: SignalRecipient
}

class RecipientMergerImpl: RecipientMerger {
    private let blockedRecipientStore: BlockedRecipientStore
    private let identityManager: OWSIdentityManager
    private let observers: Observers
    private let recipientDatabaseTable: RecipientDatabaseTable
    private let recipientFetcher: RecipientFetcher
    private let searchableNameIndexer: any SearchableNameIndexer
    private let sessionStore: SessionStore
    private let storageServiceManager: StorageServiceManager
    private let storyRecipientStore: StoryRecipientStore

    /// Initializes a RecipientMerger.
    ///
    /// - Parameter observers: Observers that are notified after a new
    /// association is learned. They are notified in the same transaction in
    /// which we learned about the new association, and they are notified in the
    /// order in which they are provided.
    init(
        blockedRecipientStore: BlockedRecipientStore,
        identityManager: OWSIdentityManager,
        observers: Observers,
        recipientDatabaseTable: RecipientDatabaseTable,
        recipientFetcher: RecipientFetcher,
        searchableNameIndexer: any SearchableNameIndexer,
        sessionStore: SessionStore,
        storageServiceManager: StorageServiceManager,
        storyRecipientStore: StoryRecipientStore,
    ) {
        self.blockedRecipientStore = blockedRecipientStore
        self.identityManager = identityManager
        self.observers = observers
        self.recipientDatabaseTable = recipientDatabaseTable
        self.recipientFetcher = recipientFetcher
        self.searchableNameIndexer = searchableNameIndexer
        self.sessionStore = sessionStore
        self.storageServiceManager = storageServiceManager
        self.storyRecipientStore = storyRecipientStore
    }

    struct Observers {
        let preThreadMerger: [RecipientMergeObserver]
        let threadMerger: ThreadMerger
        let postThreadMerger: [RecipientMergeObserver]
    }

    static func buildObservers(
        authorMergeHelper: AuthorMergeHelper,
        callRecordStore: CallRecordStore,
        chatColorSettingStore: ChatColorSettingStore,
        deletedCallRecordStore: DeletedCallRecordStore,
        disappearingMessagesConfigurationStore: DisappearingMessagesConfigurationStore,
        groupMemberUpdater: GroupMemberUpdater,
        groupMemberStore: GroupMemberStore,
        interactionStore: InteractionStore,
        pinnedThreadManager: PinnedThreadManager,
        profileManager: ProfileManager,
        recipientMergeNotifier: RecipientMergeNotifier,
        signalServiceAddressCache: SignalServiceAddressCache,
        threadAssociatedDataStore: ThreadAssociatedDataStore,
        threadRemover: ThreadRemover,
        threadReplyInfoStore: ThreadReplyInfoStore,
        threadStore: ThreadStore,
        userProfileStore: UserProfileStore,
        wallpaperImageStore: WallpaperImageStore,
        wallpaperStore: WallpaperStore,
    ) -> Observers {
        // PNI TODO: Merge ReceiptForLinkedDevice if needed.
        return Observers(
            preThreadMerger: [
                signalServiceAddressCache,
                AuthorMergeObserver(authorMergeHelper: authorMergeHelper),
                SignalAccountMergeObserver(),
                UserProfileMerger(userProfileStore: userProfileStore),
            ],
            threadMerger: ThreadMerger(
                callRecordStore: callRecordStore,
                chatColorSettingStore: chatColorSettingStore,
                deletedCallRecordStore: deletedCallRecordStore,
                disappearingMessagesConfigurationManager: ThreadMerger.Wrappers.DisappearingMessagesConfigurationManager(),
                disappearingMessagesConfigurationStore: disappearingMessagesConfigurationStore,
                interactionStore: interactionStore,
                pinnedThreadManager: pinnedThreadManager,
                sdsThreadMerger: ThreadMerger.Wrappers.SDSThreadMerger(),
                threadAssociatedDataManager: ThreadMerger.Wrappers.ThreadAssociatedDataManager(),
                threadAssociatedDataStore: threadAssociatedDataStore,
                threadRemover: threadRemover,
                threadReplyInfoStore: threadReplyInfoStore,
                threadStore: threadStore,
                wallpaperImageStore: wallpaperImageStore,
                wallpaperStore: wallpaperStore,
            ),
            postThreadMerger: [
                // The group member MergeObserver depends on `SignalServiceAddressCache`,
                // so ensure that one's listed first.
                GroupMemberMergeObserverImpl(
                    threadStore: threadStore,
                    groupMemberUpdater: groupMemberUpdater,
                    groupMemberStore: groupMemberStore,
                ),
                PhoneNumberChangedMessageInserter(
                    groupMemberStore: groupMemberStore,
                    interactionStore: interactionStore,
                    threadAssociatedDataStore: threadAssociatedDataStore,
                    threadStore: threadStore,
                ),
                recipientMergeNotifier,
            ],
        )
    }

    func applyMergeForLocalAccount(
        aci: Aci,
        phoneNumber: E164,
        pni: Pni?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        let aciResult = mergeAlways(aci: aci, phoneNumber: phoneNumber, isLocalRecipient: true, tx: tx)
        if let pni {
            return mergeAlways(phoneNumber: phoneNumber, pni: pni, isLocalRecipient: true, tx: tx)
        }
        return aciResult
    }

    func applyMergeFromStorageService(
        localIdentifiers: LocalIdentifiers,
        isPrimaryDevice: Bool,
        serviceIds: AtLeastOneServiceId,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        // The caller checks this, but we assert here to maintain consistency with
        // all the other merging methods that check this themselves.
        owsPrecondition(!localIdentifiers.containsAnyOf(aci: serviceIds.aci, phoneNumber: phoneNumber, pni: serviceIds.pni))

        let updatedValues = { () -> (phoneNumber: E164, pni: Pni)? in
            let pni = serviceIds.pni
            // A primary device must not change an E164/PNI association based on a
            // merge from Storage Service. Instead, it will ignore the change and trust
            // CDS to tell it the correct value.
            if isPrimaryDevice {
                // If we already have a PNI for this phone number, use that.
                if let phoneNumber, let alreadyKnownPni = fetchPni(for: phoneNumber, tx: tx) {
                    return (phoneNumber, alreadyKnownPni)
                }
                // If we already have a phone number for this PNI, use that.
                if let pni, let alreadyKnownPhoneNumber = fetchPhoneNumber(for: pni, tx: tx) {
                    return (alreadyKnownPhoneNumber, pni)
                }
            } else {
                // If no phone number is specified and we know it, use that.
                if phoneNumber == nil, let pni, let alreadyKnownPhoneNumber = fetchPhoneNumber(for: pni, tx: tx) {
                    return (alreadyKnownPhoneNumber, pni)
                }
            }
            return nil
        }()

        return _applyValidatedMergeFromStorageService(
            isPrimaryDevice: isPrimaryDevice,
            serviceIds: AtLeastOneServiceId(aci: serviceIds.aci, pni: updatedValues?.pni ?? serviceIds.pni)!,
            phoneNumber: updatedValues?.phoneNumber ?? phoneNumber,
            tx: tx,
        )
    }

    private func _applyValidatedMergeFromStorageService(
        isPrimaryDevice: Bool,
        serviceIds: AtLeastOneServiceId,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        // If there's a phone number, things are straightforward.
        let aciPhoneNumberRecipient: SignalRecipient? = {
            guard let aci = serviceIds.aci, let phoneNumber else {
                return nil
            }
            // Explicit cast to guarantee this method doesn't return an Optional.
            return mergeAlways(aci: aci, phoneNumber: phoneNumber, isLocalRecipient: false, tx: tx) as SignalRecipient
        }()
        let phoneNumberPniRecipient: SignalRecipient? = {
            guard let phoneNumber, let pni = serviceIds.pni else {
                return nil
            }
            // Explicit cast to guarantee this method doesn't return an Optional.
            return mergeAlways(phoneNumber: phoneNumber, pni: pni, isLocalRecipient: false, tx: tx) as SignalRecipient
        }()
        if let phoneNumberResult = phoneNumberPniRecipient ?? aciPhoneNumberRecipient {
            return phoneNumberResult
        }

        // If we have an E164, then at least one of the two `mergeAlways` calls
        // above will be triggered. This happens because we have
        // `AtLeastOneServiceId`. If we reach this point, it means we don't have a
        // phone number, so we try a special ACI/PNI fill-in-the-blanks merge.
        if let aci = serviceIds.aci, let pni = serviceIds.pni {
            return mergeAlwaysFromStorageService(isPrimaryDevice: isPrimaryDevice, aci: aci, pni: pni, tx: tx)
        }

        // Finally, we just fall back to the single present identifier.
        return recipientFetcher.fetchOrCreate(serviceId: serviceIds.aciOrElsePni, tx: tx)
    }

    func applyMergeFromContactSync(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        guard let phoneNumber else {
            return recipientFetcher.fetchOrCreate(serviceId: aci, tx: tx)
        }
        return mergeIfNotLocalIdentifier(localIdentifiers: localIdentifiers, aci: aci, phoneNumber: phoneNumber, tx: tx)
    }

    func applyMergeFromSealedSender(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        phoneNumber: E164?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        guard let phoneNumber else {
            return recipientFetcher.fetchOrCreate(serviceId: aci, tx: tx)
        }
        return mergeIfNotLocalIdentifier(localIdentifiers: localIdentifiers, aci: aci, phoneNumber: phoneNumber, tx: tx)
    }

    func applyMergeFromPniSignature(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        pni: Pni,
        tx: DBWriteTransaction,
    ) {
        guard
            var aciRecipient = recipientDatabaseTable.fetchRecipient(serviceId: aci, transaction: tx),
            var pniRecipient = recipientDatabaseTable.fetchRecipient(serviceId: pni, transaction: tx),
            pniRecipient.aciString == nil
        else {
            owsFail("Can't apply PNI signature merge with precondition violations")
        }

        if localIdentifiers.containsAnyOf(aci: aci, phoneNumber: nil, pni: pni) {
            Logger.warn("Can't apply PNI signature merge with our own identifiers")
            return
        }

        Logger.info("Associating \(aci) with \(pni) from a signature")

        mergeAndNotify(
            existingRecipients: [pniRecipient, aciRecipient],
            mightReplaceNonnilPhoneNumber: true,
            insertSessionSwitchoverIfNeeded: false,
            isLocalMerge: false,
            tx: tx,
        ) { _ in
            aciRecipient.phoneNumber = pniRecipient.phoneNumber
            aciRecipient.pni = pniRecipient.pni
            pniRecipient.phoneNumber = nil
            pniRecipient.pni = nil
            return (
                mergedRecipient: aciRecipient,
                otherUpdatedRecipients: [pniRecipient],
            )
        }
    }

    func applyMergeFromContactDiscovery(
        localIdentifiers: LocalIdentifiers,
        phoneNumber: E164,
        pni: Pni,
        aci: Aci?,
        tx: DBWriteTransaction,
    ) -> SignalRecipient? {
        // If you type in your own phone number, ignore the result and return your
        // own recipient.
        if localIdentifiers.contains(phoneNumber: phoneNumber) {
            return recipientFetcher.fetchOrCreate(phoneNumber: phoneNumber, tx: tx)
        }
        // Otherwise, if CDS tells us that our PNI belongs to some other account,
        // we can't fulfill the request. If we did fulfill the request, we'd either
        // return a result without a PNI or a result with a stale PNI. Both of
        // those are unacceptable.
        if localIdentifiers.pni == pni {
            return nil
        }
        // Finally, if CDS tells us our ACI is associated with another phone
        // number, ignore the ACI and process the phone number/PNI pair.
        var aci = aci
        if localIdentifiers.aci == aci {
            aci = nil
        }
        if let aci {
            _ = mergeAlways(aci: aci, phoneNumber: phoneNumber, isLocalRecipient: false, tx: tx)
        }
        return mergeAlways(phoneNumber: phoneNumber, pni: pni, isLocalRecipient: false, tx: tx)
    }

    func splitUnregisteredRecipientIfNeeded(
        localIdentifiers: LocalIdentifiers,
        unregisteredRecipient: inout SignalRecipient,
        tx: DBWriteTransaction,
    ) {
        // We can't split if they're registered or lacking an ACI.
        guard !unregisteredRecipient.isRegistered, let aci = unregisteredRecipient.aci else {
            return
        }
        // We never touch our own recipient -- that's handled by registration.
        if localIdentifiers.contains(serviceId: aci) {
            return
        }
        // We can't split if there's no other identifiers.
        guard unregisteredRecipient.phoneNumber != nil || unregisteredRecipient.pni != nil else {
            return
        }
        mergeAndNotify(
            existingRecipients: [unregisteredRecipient],
            mightReplaceNonnilPhoneNumber: true,
            insertSessionSwitchoverIfNeeded: true,
            isLocalMerge: false,
            tx: tx,
        ) { tx in
            var splitRecipient = failIfThrowsDatabaseError { () throws(GRDB.DatabaseError) in
                return try SignalRecipient.insertRecord(unregisteredAtTimestamp: Date.ows_millisecondTimestamp(), tx: tx)
            }
            splitRecipient.phoneNumber = unregisteredRecipient.phoneNumber
            splitRecipient.pni = unregisteredRecipient.pni
            unregisteredRecipient.phoneNumber = nil
            unregisteredRecipient.pni = nil
            return (mergedRecipient: splitRecipient, otherUpdatedRecipients: [unregisteredRecipient])
        }
    }

    /// Performs a merge unless a provided identifier refers to the local user.
    ///
    /// With the exception of registration, change number, etc., we're never
    /// allowed to initiate a merge with our own identifiers. Instead, we simply
    /// return whichever recipient exists for the provided `aci`.
    private func mergeIfNotLocalIdentifier(
        localIdentifiers: LocalIdentifiers,
        aci: Aci,
        phoneNumber: E164,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        if localIdentifiers.containsAnyOf(aci: aci, phoneNumber: phoneNumber, pni: nil) {
            return recipientFetcher.fetchOrCreate(serviceId: aci, tx: tx)
        }
        return mergeAlways(aci: aci, phoneNumber: phoneNumber, isLocalRecipient: false, tx: tx)
    }

    // MARK: - Merge Logic

    /// Performs a merge for the provided identifiers.
    ///
    /// There may be a ``SignalRecipient`` for one or more of the provided
    /// identifiers. If there is, we'll update and return that value (see the
    /// rules below). Otherwise, we'll create a new instance.
    ///
    /// A merge indicates that `aci` & `phoneNumber` refer to the same account.
    /// As part of this operation, the database will be updated to reflect that
    /// relationship.
    ///
    /// In general, the rules we follow when applying changes are:
    ///
    /// * ACIs are immutable and representative of an account. We never change
    /// the ACI of a ``SignalRecipient`` from one ACI to another; instead we
    /// create a new ``SignalRecipient``. (However, the ACI *may* change from a
    /// nil value to a nonnil value.)
    ///
    /// * Phone numbers are transient and can move freely between ACIs. When
    /// they do, we must backfill the database to reflect the change.
    private func mergeAlways(
        aci: Aci,
        phoneNumber: E164,
        isLocalRecipient: Bool,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        let aciRecipient = recipientDatabaseTable.fetchRecipient(serviceId: aci, transaction: tx)

        // If these values have already been merged, we can return the result
        // without any modifications. This will be the path taken in 99% of cases
        // (ie, we'll hit this path every time a recipient sends you a message,
        // assuming they haven't changed their phone number).
        if let aciRecipient, aciRecipient.phoneNumber?.stringValue == phoneNumber.stringValue {
            return aciRecipient
        }

        Logger.info("Updating \(aci)'s phone number")

        // In every other case, we need to change *something*. The goal of the
        // remainder of this method is to ensure there's a `SignalRecipient` such
        // that calling this method again, immediately, with the same parameters
        // would match the the prior `if` check and return early without making any
        // modifications.

        let phoneNumberRecipient = recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber.stringValue, transaction: tx)
        let alreadyKnownPni = phoneNumberRecipient?.pni

        return mergeAndNotify(
            existingRecipients: [phoneNumberRecipient, aciRecipient].compacted(),
            mightReplaceNonnilPhoneNumber: true,
            insertSessionSwitchoverIfNeeded: true,
            isLocalMerge: isLocalRecipient,
            tx: tx,
        ) { tx in
            let mergeResult = _mergeHighTrust(
                aci: aci,
                phoneNumber: phoneNumber,
                aciRecipient: aciRecipient,
                phoneNumberRecipient: phoneNumberRecipient,
                tx: tx,
            )
            return (
                mergedRecipient: mergeResult.mergedRecipient ?? {
                    var mergedRecipient = failIfThrowsDatabaseError { () throws(GRDB.DatabaseError) in
                        return try SignalRecipient.insertRecord(tx: tx)
                    }
                    mergedRecipient.aci = aci
                    mergedRecipient.phoneNumber = SignalRecipient.PhoneNumber(
                        stringValue: phoneNumber.stringValue,
                        isDiscoverable: false,
                    )
                    mergedRecipient.pni = alreadyKnownPni
                    return mergedRecipient
                }(),
                otherUpdatedRecipients: mergeResult.otherUpdatedRecipients,
            )
        }
    }

    private func _mergeHighTrust(
        aci: Aci,
        phoneNumber: E164,
        aciRecipient: SignalRecipient?,
        phoneNumberRecipient: SignalRecipient?,
        tx: DBWriteTransaction,
    ) -> (mergedRecipient: SignalRecipient?, otherUpdatedRecipients: [SignalRecipient]) {
        if var aciRecipient {
            guard var phoneNumberRecipient else {
                aciRecipient.phoneNumber = .init(stringValue: phoneNumber.stringValue, isDiscoverable: false)
                aciRecipient.pni = nil
                return (aciRecipient, [])
            }

            aciRecipient.phoneNumber = phoneNumberRecipient.phoneNumber
            aciRecipient.pni = phoneNumberRecipient.pni
            phoneNumberRecipient.phoneNumber = nil
            phoneNumberRecipient.pni = nil
            return (aciRecipient, [phoneNumberRecipient])
        }

        if var phoneNumberRecipient {
            if phoneNumberRecipient.aciString != nil {
                // We can't change the ACI because it's non-empty. Instead, we must create
                // a new SignalRecipient. We clear the phone number here since it will
                // belong to the new SignalRecipient.
                phoneNumberRecipient.phoneNumber = nil
                phoneNumberRecipient.pni = nil
                return (nil, [phoneNumberRecipient])
            }

            phoneNumberRecipient.aci = aci
            return (phoneNumberRecipient, [])
        }

        // We couldn't find a recipient, so create a new one.
        return (nil, [])
    }

    @discardableResult
    private func mergeAlways(
        phoneNumber: E164,
        pni: Pni,
        isLocalRecipient: Bool,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        let phoneNumberRecipient = recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber.stringValue, transaction: tx)

        // If the phone number & PNI are already associated, do nothing.
        if let phoneNumberRecipient, phoneNumberRecipient.pni == pni {
            return phoneNumberRecipient
        }

        Logger.info("Associating \(pni) with a phone number")

        let pniRecipient = recipientDatabaseTable.fetchRecipient(serviceId: pni, transaction: tx)

        return mergeAndNotify(
            existingRecipients: [pniRecipient, phoneNumberRecipient].compacted(),
            mightReplaceNonnilPhoneNumber: false,
            insertSessionSwitchoverIfNeeded: true,
            isLocalMerge: isLocalRecipient,
            tx: tx,
        ) { tx in
            let mergeResult = _mergeAlways(
                phoneNumber: phoneNumber,
                pni: pni,
                phoneNumberRecipient: phoneNumberRecipient,
                pniRecipient: pniRecipient,
                tx: tx,
            )
            return (
                mergedRecipient: mergeResult.mergedRecipient ?? {
                    var mergedRecipient = failIfThrowsDatabaseError { () throws(GRDB.DatabaseError) in
                        return try SignalRecipient.insertRecord(tx: tx)
                    }
                    mergedRecipient.phoneNumber = SignalRecipient.PhoneNumber(
                        stringValue: phoneNumber.stringValue,
                        isDiscoverable: false,
                    )
                    mergedRecipient.pni = pni
                    return mergedRecipient
                }(),
                otherUpdatedRecipients: mergeResult.otherUpdatedRecipients,
            )
        }
    }

    private func _mergeAlways(
        phoneNumber: E164,
        pni: Pni,
        phoneNumberRecipient: SignalRecipient?,
        pniRecipient: SignalRecipient?,
        tx: DBWriteTransaction,
    ) -> (mergedRecipient: SignalRecipient?, otherUpdatedRecipients: [SignalRecipient]) {
        // If we have a phoneNumberRecipient, we'll always prefer that one because
        // the PNI is property of the phone number (not the other way).
        if var phoneNumberRecipient {
            guard var pniRecipient else {
                // If the PNI isn't on some other row, add it to this one.
                phoneNumberRecipient.pni = pni
                return (phoneNumberRecipient, [])
            }
            // If the PNI is on some other row, steal it for this one.
            phoneNumberRecipient.pni = pni
            pniRecipient.pni = nil
            return (phoneNumberRecipient, [pniRecipient])
        }

        // If we have a pniRecipient, we can use it if it doesn't have a phone
        // number. If it does, that takes precedence, and we need a new recipient.
        if var pniRecipient {
            if pniRecipient.phoneNumber != nil {
                pniRecipient.pni = nil
                return (nil, [pniRecipient])
            }

            pniRecipient.phoneNumber = .init(stringValue: phoneNumber.stringValue, isDiscoverable: false)
            return (pniRecipient, [])
        }

        // We couldn't find a recipient, so create a new one.
        return (nil, [])
    }

    private func mergeAlwaysFromStorageService(
        isPrimaryDevice: Bool,
        aci: Aci,
        pni: Pni,
        tx: DBWriteTransaction,
    ) -> SignalRecipient {
        let aciRecipient = recipientDatabaseTable.fetchRecipient(serviceId: aci, transaction: tx)

        // If the ACI & PNI are already associated, do nothing.
        if let aciRecipient, aciRecipient.pni == pni {
            return aciRecipient
        }

        Logger.info("Associating \(aci) with \(pni)")

        let pniRecipient = recipientDatabaseTable.fetchRecipient(serviceId: pni, transaction: tx)
        owsAssertDebug(pniRecipient?.phoneNumber == nil)

        return mergeAndNotify(
            existingRecipients: [aciRecipient, pniRecipient].compacted(),
            mightReplaceNonnilPhoneNumber: false,
            insertSessionSwitchoverIfNeeded: true,
            isLocalMerge: false,
            tx: tx,
        ) { tx in
            let mergeResult = _mergeAlwaysFromStorageService(
                aci: aci,
                pni: pni,
                isPrimaryDevice: isPrimaryDevice,
                aciRecipient: aciRecipient,
                pniRecipient: pniRecipient,
                tx: tx,
            )
            return (
                mergedRecipient: mergeResult.mergedRecipient ?? {
                    var mergedRecipient = failIfThrowsDatabaseError { () throws(GRDB.DatabaseError) in
                        return try SignalRecipient.insertRecord(tx: tx)
                    }
                    mergedRecipient.aci = aci
                    mergedRecipient.pni = pni
                    return mergedRecipient
                }(),
                otherUpdatedRecipients: mergeResult.otherUpdatedRecipients,
            )
        }
    }

    private func _mergeAlwaysFromStorageService(
        aci: Aci,
        pni: Pni,
        isPrimaryDevice: Bool,
        aciRecipient: SignalRecipient?,
        pniRecipient: SignalRecipient?,
        tx: DBWriteTransaction,
    ) -> (mergedRecipient: SignalRecipient?, otherUpdatedRecipients: [SignalRecipient]) {
        if var aciRecipient {
            let canAssignPni: Bool = {
                if aciRecipient.phoneNumber == nil {
                    // If there's no phone number, we're not changing an E164/PNI association.
                    return true
                }
                if aciRecipient.pni == nil {
                    // If there's no PNI, then we're making the initial association, which is fine.
                    return true
                }
                if !isPrimaryDevice {
                    // If we're a linked device, then we're allowed to change any association.
                    return true
                }
                return false
            }()
            var updatedPniRecipient: SignalRecipient?
            if canAssignPni {
                if let pniRecipient {
                    updatedPniRecipient = pniRecipient
                    updatedPniRecipient?.phoneNumber = nil
                    updatedPniRecipient?.pni = nil
                }
                aciRecipient.pni = pni
            }
            return (aciRecipient, [updatedPniRecipient].compacted())
        }

        if var pniRecipient {
            if pniRecipient.aciString != nil {
                pniRecipient.phoneNumber = nil
                pniRecipient.pni = nil
                return (nil, [pniRecipient])
            }
            pniRecipient.aci = aci
            return (pniRecipient, [])
        }

        return (nil, [])
    }

    // MARK: - Helpers

    private func fetchPni(for phoneNumber: E164, tx: DBReadTransaction) -> Pni? {
        return recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber.stringValue, transaction: tx)?.pni
    }

    private func fetchPhoneNumber(for pni: Pni, tx: DBReadTransaction) -> E164? {
        return E164(recipientDatabaseTable.fetchRecipient(serviceId: pni, transaction: tx)?.phoneNumber?.stringValue)
    }

    // MARK: - Merge Handling

    @discardableResult
    private func mergeAndNotify(
        existingRecipients: [SignalRecipient],
        mightReplaceNonnilPhoneNumber: Bool,
        insertSessionSwitchoverIfNeeded: Bool,
        isLocalMerge: Bool,
        tx: DBWriteTransaction,
        applyMerge: (DBWriteTransaction) -> (mergedRecipient: SignalRecipient, otherUpdatedRecipients: [SignalRecipient]),
    ) -> SignalRecipient {
        let oldRecipients = existingRecipients

        // If PN_1 is associated with ACI_A when this method starts, and if we're
        // trying to associate PN_1 with ACI_B, then we should ensure everything
        // that currently references PN_1 is updated to reference ACI_A. At this
        // point in time, everything we've saved locally with PN_1 is associated
        // with the ACI_A account, so we should mark it as such in the database.
        // After this point, everything new will be associated with ACI_B.
        //
        // Also, if PN_2 is associated with ACI_B when this method starts, and if
        // we're trying to associate PN_1 with ACI_B, then we also should ensure
        // everything that currently references PN_2 is updated to reference ACI_B.
        oldRecipients.forEach { recipient in
            observers.willBreakAssociation(for: recipient, mightReplaceNonnilPhoneNumber: mightReplaceNonnilPhoneNumber, tx: tx)
        }

        // Don't throw errors or return until we've saved every affectedRecipient
        // to the database.

        let (mergedRecipient, newRecipients) = applyMerge(tx)

        // Always put `mergedRecipient` at the end to ensure we don't violate
        // UNIQUE constraints. Note that `mergedRecipient` might be brand new, so
        // we might not find it during the call to `removeAll`.
        owsPrecondition(!newRecipients.contains(where: { $0.uniqueId == mergedRecipient.uniqueId }))
        let affectedRecipients = newRecipients + [mergedRecipient]

        let sessionEvents = prepareSessionEventsToInsert(
            oldRecipients: oldRecipients,
            affectedRecipients: affectedRecipients,
            mergedRecipient: mergedRecipient,
            tx: tx,
        )

        for affectedRecipient in affectedRecipients {
            if affectedRecipient.isEmpty {
                // TODO: Should we clean up any more state related to the discarded recipient?
                sessionStore.mergeRecipientId(affectedRecipient.id, into: mergedRecipient.id, localIdentity: .aci, tx: tx)
                identityManager.mergeRecipient(affectedRecipient, into: mergedRecipient, tx: tx)
                blockedRecipientStore.mergeRecipientId(affectedRecipient.id, into: mergedRecipient.id, tx: tx)
                failIfThrows { try storyRecipientStore.mergeRecipient(affectedRecipient, into: mergedRecipient, tx: tx) }
                recipientDatabaseTable.removeRecipient(affectedRecipient, transaction: tx)
            } else if oldRecipients.contains(where: { $0.uniqueId == affectedRecipient.uniqueId }) {
                recipientDatabaseTable.updateRecipient(affectedRecipient, transaction: tx)
                searchableNameIndexer.update(affectedRecipient, tx: tx)
            } else {
                recipientDatabaseTable.updateRecipient(affectedRecipient, transaction: tx)
                searchableNameIndexer.insert(affectedRecipient, tx: tx)
            }
        }

        storageServiceManager.recordPendingUpdates(updatedRecipientUniqueIds: affectedRecipients.map { $0.uniqueId })

        let threadMergeEventCount = observers.didLearnAssociation(
            mergedRecipient: MergedRecipient(
                isLocalRecipient: isLocalMerge,
                oldRecipient: oldRecipients.first(where: { $0.uniqueId == mergedRecipient.uniqueId }),
                newRecipient: mergedRecipient,
            ),
            tx: tx,
        )

        for sessionEvent in sessionEvents {
            insertSessionEvent(
                sessionEvent,
                insertSessionSwitchoverIfNeeded: insertSessionSwitchoverIfNeeded,
                mergedRecipient: mergedRecipient,
                mergedRecipientHasThreadMergeEvent: threadMergeEventCount > 0,
                tx: tx,
            )
        }

        return mergedRecipient
    }

    // MARK: - Events

    private enum SessionEvent {
        case sessionSwitchover(SignalRecipient, phoneNumber: String?)
        case safetyNumberChange(SignalRecipient, wasIdentityVerified: Bool)
    }

    private func prepareSessionEventsToInsert(
        oldRecipients: [SignalRecipient],
        affectedRecipients: [SignalRecipient],
        mergedRecipient: SignalRecipient,
        tx: DBWriteTransaction,
    ) -> [SessionEvent] {
        var result = [SessionEvent]()
        for oldRecipient in oldRecipients {
            let newRecipient = affectedRecipients.first(where: { $0.uniqueId == oldRecipient.uniqueId }) ?? oldRecipient
            let recipientPair = MergePair(
                fromValue: oldRecipient,
                intoValue: newRecipient.isEmpty ? mergedRecipient : newRecipient,
            )

            guard sessionStore.hasSessionRecords(forRecipientId: recipientPair.fromValue.id, localIdentity: .aci, tx: tx) else {
                continue
            }

            // Check out `sessionIdentifier(for:)` to understand this logic.
            let sessionIdentifier = recipientPair.map { self.sessionIdentifier(for: $0) }
            if sessionIdentifier.fromValue != sessionIdentifier.intoValue {
                result.append(prepareSessionSwitchoverEvent(recipientPair: recipientPair, tx: tx))
                continue
            }

            let recipientIdentity = recipientPair.map { identityManager.recipientIdentity(for: $0.uniqueId, tx: tx) }
            if
                let fromValue = recipientIdentity.fromValue,
                let intoValue = recipientIdentity.intoValue,
                fromValue.identityKey != intoValue.identityKey
            {
                result.append(.safetyNumberChange(recipientPair.intoValue, wasIdentityVerified: fromValue.wasIdentityVerified))
                continue
            }
        }
        return result
    }

    private func prepareSessionSwitchoverEvent(
        recipientPair: MergePair<SignalRecipient>,
        tx: DBWriteTransaction,
    ) -> SessionEvent {
        // If we're UPDATING a recipient, then we need to clear the session. If
        // we're MERGING a recipient, then the merge destination should already
        // have its own session; if it doesn't, then the caller will handle merging
        // the session/identity for these recipients.
        if recipientPair.fromValue.uniqueId == recipientPair.intoValue.uniqueId {
            identityManager.removeRecipientIdentity(for: recipientPair.fromValue.uniqueId, tx: tx)
            sessionStore.deleteSessions(forRecipientId: recipientPair.fromValue.id, localIdentity: .aci, tx: tx)
        }

        // The canonical case is adding an ACI to a recipient that already had a
        // phone number. Other cases shouldn't happen, so we show a generic
        // fallback message in those cases.
        return .sessionSwitchover(recipientPair.intoValue, phoneNumber: {
            if let phoneNumber = recipientPair.fromValue.phoneNumber, recipientPair.intoValue.aciString != nil {
                return phoneNumber.stringValue
            }
            return nil
        }())
    }

    private func insertSessionEvent(
        _ sessionEvent: SessionEvent,
        insertSessionSwitchoverIfNeeded: Bool,
        mergedRecipient: SignalRecipient,
        mergedRecipientHasThreadMergeEvent: Bool,
        tx: DBWriteTransaction,
    ) {
        switch sessionEvent {
        case .sessionSwitchover(let recipient, let phoneNumber):
            guard insertSessionSwitchoverIfNeeded else {
                // We have a valid PNI signature, so we don't need an SSE.
                break
            }
            if recipient.uniqueId == mergedRecipient.uniqueId, mergedRecipientHasThreadMergeEvent {
                // We have a thread merge event, so we don't need an SSE for this
                // recipient. Note that we may have stolen the PNI from some other thread,
                // and *that* thread might need an event.
                break
            }
            identityManager.insertSessionSwitchoverEvent(for: recipient, phoneNumber: phoneNumber, tx: tx)
        case .safetyNumberChange(let recipient, let wasIdentityVerified):
            guard let aci = recipient.aci else {
                owsFailDebug("Can't insert a Safety Number event without an ACI.")
                break
            }
            identityManager.insertIdentityChangeInfoMessage(for: aci, wasIdentityVerified: wasIdentityVerified, tx: tx)
        }
    }

    /// Returns an opaque "session identifier" for the recipient.
    ///
    /// When this identifier changes, we need to insert a session switchover
    /// event. We do so when switching from the PNI session to the ACI session,
    /// when losing the PNI but keeping the phone number, or when switching from
    /// one PNI to another PNI. The latter two shouldn't happen, but they are
    /// technically session switchovers and therefore need to be handled.
    ///
    /// Notable behaviors:
    /// - Once an ACI is assigned, no session switchovers are possible.
    /// - Once an ACI is assigned, it never changes (hence the "aci" constant).
    /// - If the PNI changes, so does the session identifier.
    /// - If the PNI disappears, we add a preemptive session switchover since we
    /// won't add one when learning the new PNI.
    /// - If a phone number-only recipient learns an ACI, that's not a session
    /// switchover. Instead, it's part of a years-old migration from phone
    /// numbers to ACIs.
    private func sessionIdentifier(for recipient: SignalRecipient) -> some Equatable {
        if recipient.aci == nil, let pni = recipient.pni {
            return pni.serviceIdString
        }
        return "aci"
    }
}

// MARK: - Observers

extension RecipientMergerImpl.Observers {
    func willBreakAssociation(for recipient: SignalRecipient, mightReplaceNonnilPhoneNumber: Bool, tx: DBWriteTransaction) {
        return notifyObservers(
            notifyObserver: { $0.willBreakAssociation(for: recipient, mightReplaceNonnilPhoneNumber: mightReplaceNonnilPhoneNumber, tx: tx) },
            notifyThreadMerger: { threadMerger.willBreakAssociation(for: recipient, mightReplaceNonnilPhoneNumber: mightReplaceNonnilPhoneNumber, tx: tx) },
        )
    }

    func didLearnAssociation(mergedRecipient: MergedRecipient, tx: DBWriteTransaction) -> Int {
        return notifyObservers(
            notifyObserver: { $0.didLearnAssociation(mergedRecipient: mergedRecipient, tx: tx) },
            notifyThreadMerger: { threadMerger.didLearnAssociation(mergedRecipient: mergedRecipient, tx: tx) },
        )
    }

    private func notifyObservers<T>(
        notifyObserver: (RecipientMergeObserver) -> Void,
        notifyThreadMerger: () -> T,
    ) -> T {
        preThreadMerger.forEach(notifyObserver)
        let result = notifyThreadMerger()
        postThreadMerger.forEach(notifyObserver)
        return result
    }
}

// MARK: - SignalServiceAddressCache

extension SignalServiceAddressCache: RecipientMergeObserver {
    func willBreakAssociation(for recipient: SignalRecipient, mightReplaceNonnilPhoneNumber: Bool, tx: DBWriteTransaction) {}

    func didLearnAssociation(mergedRecipient: MergedRecipient, tx: DBWriteTransaction) {
        updateRecipient(mergedRecipient.newRecipient, tx: tx)

        // If there are any threads with addresses that have been merged, we should
        // reload them from disk. This allows us to rebuild the addresses with the
        // proper hash values.
        SSKEnvironment.shared.modelReadCachesRef.evacuateAllCaches()
    }
}

// MARK: - RecipientMergeNotifier

extension Notification.Name {
    public static let didLearnRecipientAssociation = Notification.Name("didLearnRecipientAssociation")
}

public class RecipientMergeNotifier: RecipientMergeObserver {
    public init() {}

    func willBreakAssociation(for recipient: SignalRecipient, mightReplaceNonnilPhoneNumber: Bool, tx: DBWriteTransaction) {}

    func didLearnAssociation(mergedRecipient: MergedRecipient, tx: DBWriteTransaction) {
        tx.addSyncCompletion {
            NotificationCenter.default.postOnMainThread(name: .didLearnRecipientAssociation, object: self)
        }
    }
}