971 lines
40 KiB
Swift
971 lines
40 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
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: 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 aciSessionStore: SignalSessionStore
|
|
private let blockedRecipientStore: any BlockedRecipientStore
|
|
private let identityManager: OWSIdentityManager
|
|
private let observers: Observers
|
|
private let recipientDatabaseTable: RecipientDatabaseTable
|
|
private let recipientFetcher: RecipientFetcher
|
|
private let storageServiceManager: StorageServiceManager
|
|
|
|
/// 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(
|
|
aciSessionStore: SignalSessionStore,
|
|
blockedRecipientStore: any BlockedRecipientStore,
|
|
identityManager: OWSIdentityManager,
|
|
observers: Observers,
|
|
recipientDatabaseTable: RecipientDatabaseTable,
|
|
recipientFetcher: RecipientFetcher,
|
|
storageServiceManager: StorageServiceManager
|
|
) {
|
|
self.aciSessionStore = aciSessionStore
|
|
self.blockedRecipientStore = blockedRecipientStore
|
|
self.identityManager = identityManager
|
|
self.observers = observers
|
|
self.recipientDatabaseTable = recipientDatabaseTable
|
|
self.recipientFetcher = recipientFetcher
|
|
self.storageServiceManager = storageServiceManager
|
|
}
|
|
|
|
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(),
|
|
ProfileWhitelistMerger(profileManager: profileManager),
|
|
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
|
|
let aciRecipient = recipientDatabaseTable.fetchRecipient(serviceId: aci, transaction: tx),
|
|
let 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
|
|
) {
|
|
aciRecipient.phoneNumber = pniRecipient.phoneNumber
|
|
aciRecipient.pni = pniRecipient.pni
|
|
pniRecipient.phoneNumber = nil
|
|
pniRecipient.pni = nil
|
|
return aciRecipient
|
|
}
|
|
}
|
|
|
|
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: 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
|
|
) {
|
|
let splitRecipient = SignalRecipient.buildEmptyRecipient(unregisteredAt: NSDate.ows_millisecondTimeStamp())
|
|
splitRecipient.phoneNumber = unregisteredRecipient.phoneNumber
|
|
splitRecipient.pni = unregisteredRecipient.pni
|
|
unregisteredRecipient.phoneNumber = nil
|
|
unregisteredRecipient.pni = nil
|
|
return splitRecipient
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
) {
|
|
let existingRecipient = _mergeHighTrust(
|
|
aci: aci,
|
|
phoneNumber: phoneNumber,
|
|
aciRecipient: aciRecipient,
|
|
phoneNumberRecipient: phoneNumberRecipient,
|
|
tx: tx
|
|
)
|
|
return existingRecipient ?? SignalRecipient(aci: aci, pni: alreadyKnownPni, phoneNumber: phoneNumber)
|
|
}
|
|
}
|
|
|
|
private func _mergeHighTrust(
|
|
aci: Aci,
|
|
phoneNumber: E164,
|
|
aciRecipient: SignalRecipient?,
|
|
phoneNumberRecipient: SignalRecipient?,
|
|
tx: DBWriteTransaction
|
|
) -> SignalRecipient? {
|
|
if let aciRecipient {
|
|
guard let 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
|
|
}
|
|
|
|
if let 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.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
|
|
) {
|
|
let existingRecipient = _mergeAlways(
|
|
phoneNumber: phoneNumber,
|
|
pni: pni,
|
|
phoneNumberRecipient: phoneNumberRecipient,
|
|
pniRecipient: pniRecipient,
|
|
tx: tx
|
|
)
|
|
return existingRecipient ?? SignalRecipient(aci: nil, pni: pni, phoneNumber: phoneNumber)
|
|
}
|
|
}
|
|
|
|
private func _mergeAlways(
|
|
phoneNumber: E164,
|
|
pni: Pni,
|
|
phoneNumberRecipient: SignalRecipient?,
|
|
pniRecipient: SignalRecipient?,
|
|
tx: DBWriteTransaction
|
|
) -> 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 let phoneNumberRecipient {
|
|
guard let 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
|
|
}
|
|
|
|
// 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 let pniRecipient {
|
|
if pniRecipient.phoneNumber != nil {
|
|
pniRecipient.pni = nil
|
|
return nil
|
|
}
|
|
|
|
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
|
|
) {
|
|
let existingRecipient = _mergeAlwaysFromStorageService(
|
|
aci: aci,
|
|
pni: pni,
|
|
isPrimaryDevice: isPrimaryDevice,
|
|
aciRecipient: aciRecipient,
|
|
pniRecipient: pniRecipient,
|
|
tx: tx
|
|
)
|
|
return existingRecipient ?? SignalRecipient(aci: aci, pni: pni, phoneNumber: nil)
|
|
}
|
|
}
|
|
|
|
private func _mergeAlwaysFromStorageService(
|
|
aci: Aci,
|
|
pni: Pni,
|
|
isPrimaryDevice: Bool,
|
|
aciRecipient: SignalRecipient?,
|
|
pniRecipient: SignalRecipient?,
|
|
tx: DBWriteTransaction
|
|
) -> SignalRecipient? {
|
|
if let 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
|
|
}()
|
|
if canAssignPni {
|
|
if let pniRecipient {
|
|
pniRecipient.phoneNumber = nil
|
|
pniRecipient.pni = nil
|
|
}
|
|
aciRecipient.pni = pni
|
|
}
|
|
return aciRecipient
|
|
}
|
|
|
|
if let pniRecipient {
|
|
if pniRecipient.aciString != nil {
|
|
pniRecipient.phoneNumber = nil
|
|
pniRecipient.pni = nil
|
|
return nil
|
|
}
|
|
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: () -> SignalRecipient
|
|
) -> SignalRecipient {
|
|
let oldRecipients = existingRecipients.map { $0.copyRecipient() }
|
|
|
|
// 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.
|
|
existingRecipients.forEach { recipient in
|
|
observers.willBreakAssociation(for: recipient, mightReplaceNonnilPhoneNumber: mightReplaceNonnilPhoneNumber, tx: tx)
|
|
}
|
|
|
|
let mergedRecipient = applyMerge()
|
|
|
|
let sessionEvents = prepareSessionEventsToInsert(
|
|
oldRecipients: oldRecipients,
|
|
newRecipients: existingRecipients,
|
|
mergedRecipient: mergedRecipient,
|
|
tx: 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`.
|
|
var affectedRecipients = existingRecipients
|
|
affectedRecipients.removeAll(where: { $0.uniqueId == mergedRecipient.uniqueId })
|
|
affectedRecipients.append(mergedRecipient)
|
|
|
|
for affectedRecipient in affectedRecipients {
|
|
if affectedRecipient.isEmpty {
|
|
// TODO: Should we clean up any more state related to the discarded recipient?
|
|
aciSessionStore.mergeRecipient(affectedRecipient, into: mergedRecipient, tx: tx)
|
|
identityManager.mergeRecipient(affectedRecipient, into: mergedRecipient, tx: tx)
|
|
blockedRecipientStore.mergeRecipientId(affectedRecipient.id!, into: mergedRecipient.id!, tx: tx)
|
|
recipientDatabaseTable.removeRecipient(affectedRecipient, transaction: tx)
|
|
} else if existingRecipients.contains(where: { $0.uniqueId == affectedRecipient.uniqueId }) {
|
|
recipientDatabaseTable.updateRecipient(affectedRecipient, transaction: tx)
|
|
} else {
|
|
recipientDatabaseTable.insertRecipient(affectedRecipient, transaction: 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],
|
|
newRecipients: [SignalRecipient],
|
|
mergedRecipient: SignalRecipient,
|
|
tx: DBWriteTransaction
|
|
) -> [SessionEvent] {
|
|
var result = [SessionEvent]()
|
|
for (oldRecipient, newRecipient) in zip(oldRecipients, newRecipients) {
|
|
let recipientPair = MergePair(
|
|
fromValue: oldRecipient,
|
|
intoValue: newRecipient.isEmpty ? mergedRecipient : newRecipient
|
|
)
|
|
|
|
guard aciSessionStore.mightContainSession(for: recipientPair.fromValue, 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)
|
|
aciSessionStore.deleteAllSessions(for: recipientPair.fromValue.uniqueId, 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 {
|
|
private let scheduler: Scheduler
|
|
|
|
public init(scheduler: Scheduler) {
|
|
self.scheduler = scheduler
|
|
}
|
|
|
|
func willBreakAssociation(for recipient: SignalRecipient, mightReplaceNonnilPhoneNumber: Bool, tx: DBWriteTransaction) {}
|
|
|
|
func didLearnAssociation(mergedRecipient: MergedRecipient, tx: DBWriteTransaction) {
|
|
tx.addAsyncCompletion(on: scheduler) {
|
|
NotificationCenter.default.post(name: .didLearnRecipientAssociation, object: self)
|
|
}
|
|
}
|
|
}
|