// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient enum UniqueRecipientObjectMerger { /// Helps merge database objects that have one instance per recipient. /// /// For example, we expect at most one thread per recipient and one user /// profile per recipient, so they use this class. We expect many messages /// and receipts for each recipient, so they don't use this method. /// /// This method will find and return all objects that belong to `recipient`. /// For example, if you have separate threads for a recipient's ACI and /// phone number, both will be returned. The caller should merge all the /// returned objects into a single object. /// /// This method should be called after adding a new identifier to /// `recipient` because there may be separate objects that should now be /// merged. However, it's safe to call this method (and run the merging /// logic) at any time in the case of bugs or other issues. /// /// Some objects that match one of `recipient`'s identifiers may not belong /// to `recipient`. For example, if an object has some other ACI but /// `recipient`'s phone number, it actually belongs to the ACI's recipient. /// In these cases, the identifier is removed from the object, and it's not /// returned from this method. static func fetchAndExpunge( for recipient: SignalRecipient, serviceIdField: ReferenceWritableKeyPath, phoneNumberField: ReferenceWritableKeyPath, uniqueIdField: KeyPath, fetchObjectsForServiceId: (ServiceId) -> [T], fetchObjectsForPhoneNumber: (E164) -> [T], updateObject: (T) -> Void ) -> [T] { var results = [T]() let aci: Aci? = recipient.aci let phoneNumber: E164? = E164(recipient.phoneNumber?.stringValue) let pni: Pni? = recipient.pni // Find any objects already associated with the ACI. These definitely // belong to this account, and we'll pick an arbitrary one to be the winner // if there's multiple matches. if let aci { results.append(contentsOf: fetchObjectsForServiceId(aci)) } // Find objects associated with the phone number and merge or expunge them. if let phoneNumber { for object in fetchObjectsForPhoneNumber(phoneNumber) { let serviceId = object[keyPath: serviceIdField].flatMap({ try? ServiceId.parseFrom(serviceIdString: $0) }) switch serviceId?.concreteType { case .aci(aci), .pni, .none: // This object already matches the ACI, has *any* PNI, or has no ACI/PNI. // In all of these cases, we can claim it based on the phone number match. results.append(object) case .aci: // This object is associated with some other ACI; expunge its phone number // because we know it's out of date. Logger.info("Expunging out-of-date phone number from \(type(of: object))") object[keyPath: phoneNumberField] = nil updateObject(object) } } } // Find any objects associated with the PNI and merge or expunge them. if let pni { for object in fetchObjectsForServiceId(pni) { switch object[keyPath: phoneNumberField] { case .some(phoneNumber?.stringValue), .none: // This object matches the phone number or doesn't have one. We can claim // it for this account. results.append(object) case .some: // This object is associated with some other phone number; expunge its PNI // because we know it's out of date. Logger.info("Expunging out-of-date PNI from \(type(of: object))") object[keyPath: serviceIdField] = nil updateObject(object) } } } return results.removingDuplicates(uniquingElementsBy: { $0[keyPath: uniqueIdField] }) } }