// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Contacts import Foundation import LibSignalClient import SignalCoreKit extension Notification.Name { public static let OWSContactsManagerSignalAccountsDidChange = Notification.Name("OWSContactsManagerSignalAccountsDidChangeNotification") public static let OWSContactsManagerContactsDidChange = Notification.Name("OWSContactsManagerContactsDidChangeNotification") } @objc public enum RawContactAuthorizationStatus: UInt { case notDetermined, denied, restricted, authorized } public enum ContactAuthorizationForEditing { case notAllowed, denied, restricted, authorized } public enum ContactAuthorizationForSharing { case notDetermined, denied, authorized } @objc public class OWSContactsManager: NSObject, ContactsManagerProtocol { let swiftValues: OWSContactsManagerSwiftValues let systemContactsFetcher: SystemContactsFetcher let keyValueStore: SDSKeyValueStore public var isEditingAllowed: Bool { TSAccountManagerObjcBridge.isPrimaryDeviceWithMaybeTransaction } /// Must call `requestSystemContactsOnce` before accessing this method public var editingAuthorization: ContactAuthorizationForEditing { guard isEditingAllowed else { return .notAllowed } switch systemContactsFetcher.rawAuthorizationStatus { case .notDetermined: owsFailDebug("should have called `requestOnce` before checking authorization status.") fallthrough case .denied: return .denied case .restricted: return .restricted case .authorized: return .authorized } } public var sharingAuthorization: ContactAuthorizationForSharing { switch self.systemContactsFetcher.rawAuthorizationStatus { case .notDetermined: return .notDetermined case .denied, .restricted: return .denied case .authorized: return .authorized } } /// Whether or not we've fetched system contacts on this launch. /// /// This property is set to true even if the user doesn't have any system /// contacts. /// /// This property is only valid if the user has granted contacts access. /// Otherwise, it's value is undefined. public private(set) var hasLoadedSystemContacts: Bool = false public init(swiftValues: OWSContactsManagerSwiftValues) { keyValueStore = SDSKeyValueStore(collection: "OWSContactsManagerCollection") systemContactsFetcher = SystemContactsFetcher() self.swiftValues = swiftValues super.init() systemContactsFetcher.delegate = self SwiftSingletons.register(self) } // Request systems contacts and start syncing changes. The user will see an alert // if they haven't previously. public func requestSystemContactsOnce(completion: (((any Error)?) -> Void)? = nil) { AssertIsOnMainThread() guard isEditingAllowed else { if let completion = completion { Logger.warn("Editing contacts isn't available on linked devices.") completion(OWSError(error: .genericFailure, description: OWSLocalizedString("ERROR_DESCRIPTION_UNKNOWN_ERROR", comment: "Worst case generic error message"), isRetryable: false)) } return } systemContactsFetcher.requestOnce(completion: completion) } /// Ensure's the app has the latest contacts, but won't prompt the user for contact /// access if they haven't granted it. public func fetchSystemContactsOnceIfAlreadyAuthorized() { guard isEditingAllowed else { return } systemContactsFetcher.fetchOnceIfAlreadyAuthorized() } /// This variant will fetch system contacts if contact access has already been granted, /// but not prompt for contact access. Also, it will always notify delegates, even if /// contacts haven't changed, and will clear out any stale cached SignalAccounts public func userRequestedSystemContactsRefresh() -> AnyPromise { guard isEditingAllowed else { owsFailDebug("Editing contacts isn't available on linked devices.") let promise = AnyPromise() promise.reject(OWSError(error: .assertionFailure, description: OWSLocalizedString("ERROR_DESCRIPTION_UNKNOWN_ERROR", comment: "Worst case generic error message"), isRetryable: false)) return promise } return AnyPromise(future: { (future: AnyFuture) in self.systemContactsFetcher.userRequestedRefresh { (error: (any Error)?) in if let error = error { Logger.error("refreshing contacts failed with error: \(error)") future.reject(error: error) } else { future.resolve(value: NSNumber(value: 1)) } } }) } } // MARK: - SystemContactsFetcherDelegate extension OWSContactsManager: SystemContactsFetcherDelegate { func systemContactsFetcher(_ systemContactsFetcher: SystemContactsFetcher, hasAuthorizationStatus authorizationStatus: RawContactAuthorizationStatus) { guard isEditingAllowed else { owsFailDebug("Syncing contacts isn't available on linked devices.") return } switch authorizationStatus { case .restricted, .denied: self.updateContacts(nil, isUserRequested: false) case .notDetermined, .authorized: break } } func systemContactsFetcher(_ systemContactsFetcher: SystemContactsFetcher, updatedContacts contacts: [SystemContact], isUserRequested: Bool) { guard isEditingAllowed else { owsFailDebug("Syncing contacts isn't available on linked devices.") return } updateContacts(contacts, isUserRequested: isUserRequested) } public func displayNameString(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String { displayName(for: address, tx: transaction).resolvedValue() } public func shortDisplayNameString(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String { displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true) } } // MARK: - OWSContactsMangerSwiftValues public class OWSContactsManagerSwiftValues { fileprivate let avatarBlurringCache = LowTrustCache() fileprivate let cnContactCache = LRUCache(maxSize: 50, shouldEvacuateInBackground: true) fileprivate let isInWhitelistedGroupWithLocalUserCache = AtomicDictionary([:], lock: .init()) fileprivate let hasWhitelistedGroupMemberCache = AtomicDictionary([:], lock: .init()) fileprivate let systemContactsCache = SystemContactsCache() fileprivate let unknownThreadWarningCache = LowTrustCache() fileprivate let intersectionQueue = DispatchQueue(label: "org.signal.contacts.intersection") fileprivate let skipContactAvatarBlurByServiceIdStore = SDSKeyValueStore(collection: "OWSContactsManager.skipContactAvatarBlurByUuidStore") fileprivate let skipGroupAvatarBlurByGroupIdStore = SDSKeyValueStore(collection: "OWSContactsManager.skipGroupAvatarBlurByGroupIdStore") fileprivate let usernameLookupManager: UsernameLookupManager fileprivate let recipientDatabaseTable: any RecipientDatabaseTable fileprivate let nicknameManager: any NicknameManager public init( usernameLookupManager: UsernameLookupManager, recipientDatabaseTable: any RecipientDatabaseTable, nicknameManager: any NicknameManager ) { self.usernameLookupManager = usernameLookupManager self.recipientDatabaseTable = recipientDatabaseTable self.nicknameManager = nicknameManager } } // MARK: - private class SystemContactsCache { let fetchedSystemContacts = AtomicOptional(nil, lock: .init()) } // MARK: - private class LowTrustCache { let contactCache = AtomicSet(lock: .sharedGlobal) let groupCache = AtomicSet(lock: .sharedGlobal) func contains(groupThread: TSGroupThread) -> Bool { groupCache.contains(groupThread.groupId) } func contains(contactThread: TSContactThread) -> Bool { contains(address: contactThread.contactAddress) } func contains(address: SignalServiceAddress) -> Bool { guard let serviceId = address.serviceId else { return false } return contactCache.contains(serviceId) } func add(groupThread: TSGroupThread) { groupCache.insert(groupThread.groupId) } func add(contactThread: TSContactThread) { add(address: contactThread.contactAddress) } func add(address: SignalServiceAddress) { guard let serviceId = address.serviceId else { return } contactCache.insert(serviceId) } } // MARK: - extension OWSContactsManager: ContactManager { // MARK: Low Trust private func isLowTrustThread(_ thread: TSThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool { if let contactThread = thread as? TSContactThread { return isLowTrustContact(contactThread: contactThread, lowTrustCache: lowTrustCache, tx: tx) } else if let groupThread = thread as? TSGroupThread { return isLowTrustGroup(groupThread: groupThread, lowTrustCache: lowTrustCache, tx: tx) } else { owsFailDebug("Invalid thread.") return false } } private func isLowTrustContact(address: SignalServiceAddress, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool { if lowTrustCache.contains(address: address) { return false } guard let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx) else { lowTrustCache.add(address: address) return false } return isLowTrustContact(contactThread: contactThread, lowTrustCache: lowTrustCache, tx: tx) } private func isLowTrustContact(contactThread: TSContactThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool { let address = contactThread.contactAddress if lowTrustCache.contains(address: address) { return false } if !contactThread.hasPendingMessageRequest(transaction: tx) { lowTrustCache.add(address: address) return false } // ...and not in a whitelisted group with the locar user. if isInWhitelistedGroupWithLocalUser(otherAddress: address, tx: tx) { lowTrustCache.add(address: address) return false } // We can skip avatar blurring if the user has explicitly waived the blurring. if lowTrustCache === swiftValues.avatarBlurringCache, let storeKey = address.serviceId?.serviceIdUppercaseString, swiftValues.skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx) { lowTrustCache.add(address: address) return false } return true } private func isLowTrustGroup(groupThread: TSGroupThread, lowTrustCache: LowTrustCache, tx: SDSAnyReadTransaction) -> Bool { if lowTrustCache.contains(groupThread: groupThread) { return false } if !groupThread.hasPendingMessageRequest(transaction: tx) { lowTrustCache.add(groupThread: groupThread) return false } // We can skip "unknown thread warnings" if a group has members which are trusted. if lowTrustCache === swiftValues.unknownThreadWarningCache, hasWhitelistedGroupMember(groupThread: groupThread, tx: tx) { lowTrustCache.add(groupThread: groupThread) return false } return true } private func isInWhitelistedGroupWithLocalUser(otherAddress: SignalServiceAddress, tx: SDSAnyReadTransaction) -> Bool { let cache = swiftValues.isInWhitelistedGroupWithLocalUserCache if let cacheKey = otherAddress.serviceId, let cachedValue = cache[cacheKey] { return cachedValue } let result: Bool = { guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else { owsFailDebug("Missing localAddress.") return false } let otherGroupThreadIds = TSGroupThread.groupThreadIds(with: otherAddress, transaction: tx) guard !otherGroupThreadIds.isEmpty else { return false } let localGroupThreadIds = TSGroupThread.groupThreadIds(with: localAddress, transaction: tx) let groupThreadIds = Set(otherGroupThreadIds).intersection(localGroupThreadIds) for groupThreadId in groupThreadIds { guard let groupThread = TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId, transaction: tx) else { owsFailDebug("Missing group thread") continue } if profileManager.isGroupId(inProfileWhitelist: groupThread.groupId, transaction: tx) { return true } } return false }() if let cacheKey = otherAddress.serviceId { cache[cacheKey] = result } return result } private func hasWhitelistedGroupMember(groupThread: TSGroupThread, tx: SDSAnyReadTransaction) -> Bool { let cache = swiftValues.hasWhitelistedGroupMemberCache let cacheKey = groupThread.groupId if let cachedValue = cache[cacheKey] { return cachedValue } let result: Bool = { for groupMember in groupThread.groupMembership.fullMembers { if profileManager.isUser(inProfileWhitelist: groupMember, transaction: tx) { return true } } return false }() cache[cacheKey] = result return result } public func shouldShowUnknownThreadWarning(thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool { isLowTrustThread(thread, lowTrustCache: swiftValues.unknownThreadWarningCache, tx: transaction) } // MARK: - Avatar Blurring public func shouldBlurContactAvatar(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool { isLowTrustContact(address: address, lowTrustCache: swiftValues.avatarBlurringCache, tx: transaction) } public func shouldBlurContactAvatar(contactThread: TSContactThread, transaction: SDSAnyReadTransaction) -> Bool { isLowTrustContact(contactThread: contactThread, lowTrustCache: swiftValues.avatarBlurringCache, tx: transaction) } public func shouldBlurGroupAvatar(groupThread: TSGroupThread, transaction: SDSAnyReadTransaction) -> Bool { if nil == groupThread.groupModel.avatarHash { // DO NOT add to the cache. return false } if !isLowTrustGroup( groupThread: groupThread, lowTrustCache: swiftValues.avatarBlurringCache, tx: transaction ) { return false } // We can skip avatar blurring if the user has explicitly waived the blurring. if swiftValues.skipGroupAvatarBlurByGroupIdStore.getBool( groupThread.groupId.hexadecimalString, defaultValue: false, transaction: transaction) { swiftValues.avatarBlurringCache.add(groupThread: groupThread) return false } return true } public static let skipContactAvatarBlurDidChange = NSNotification.Name("skipContactAvatarBlurDidChange") public static let skipContactAvatarBlurAddressKey = "skipContactAvatarBlurAddressKey" public static let skipGroupAvatarBlurDidChange = NSNotification.Name("skipGroupAvatarBlurDidChange") public static let skipGroupAvatarBlurGroupUniqueIdKey = "skipGroupAvatarBlurGroupUniqueIdKey" public func doNotBlurContactAvatar(address: SignalServiceAddress, transaction tx: SDSAnyWriteTransaction) { guard let serviceId = address.serviceId else { owsFailDebug("Missing ServiceId for user.") return } let storeKey = serviceId.serviceIdUppercaseString let shouldSkipBlur = swiftValues.skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx) guard !shouldSkipBlur else { owsFailDebug("Value did not change.") return } swiftValues.skipContactAvatarBlurByServiceIdStore.setBool(true, key: storeKey, transaction: tx) if let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx) { databaseStorage.touch(thread: contactThread, shouldReindex: false, transaction: tx) } tx.addAsyncCompletionOffMain { NotificationCenter.default.postNotificationNameAsync( Self.skipContactAvatarBlurDidChange, object: nil, userInfo: [ Self.skipContactAvatarBlurAddressKey: address ] ) } } public func doNotBlurGroupAvatar(groupThread: TSGroupThread, transaction: SDSAnyWriteTransaction) { let groupId = groupThread.groupId let groupUniqueId = groupThread.uniqueId guard !swiftValues.skipGroupAvatarBlurByGroupIdStore.getBool( groupId.hexadecimalString, defaultValue: false, transaction: transaction ) else { owsFailDebug("Value did not change.") return } swiftValues.skipGroupAvatarBlurByGroupIdStore.setBool( true, key: groupId.hexadecimalString, transaction: transaction ) databaseStorage.touch(thread: groupThread, shouldReindex: false, transaction: transaction) transaction.addAsyncCompletionOffMain { NotificationCenter.default.postNotificationNameAsync(Self.skipGroupAvatarBlurDidChange, object: nil, userInfo: [ Self.skipGroupAvatarBlurGroupUniqueIdKey: groupUniqueId ]) } } public func blurAvatar(_ image: UIImage) -> UIImage? { do { return try image.withGaussianBlur(radius: 16, resizeToMaxPixelDimension: 100) } catch { owsFailDebug("Error: \(error)") return nil } } // MARK: - Avatars public func avatarImage(forAddress address: SignalServiceAddress?, shouldValidate: Bool, transaction: SDSAnyReadTransaction) -> UIImage? { guard let imageData = avatarImageData(forAddress: address, shouldValidate: shouldValidate, transaction: transaction) else { return nil } guard let image = UIImage(data: imageData) else { owsFailDebug("Invalid image.") return nil } return image } public func avatarImageData(forAddress address: SignalServiceAddress?, shouldValidate: Bool, transaction: SDSAnyReadTransaction) -> Data? { guard let address = address, address.isValid else { owsFailDebug("Missing or invalid address.") return nil } if SSKPreferences.preferContactAvatars(transaction: transaction) { return (systemContactOrSyncedImageData(forAddress: address, shouldValidate: shouldValidate, transaction: transaction) ?? profileAvatarImageData(forAddress: address, shouldValidate: shouldValidate, transaction: transaction)) } else { return (profileAvatarImageData(forAddress: address, shouldValidate: shouldValidate, transaction: transaction) ?? systemContactOrSyncedImageData(forAddress: address, shouldValidate: shouldValidate, transaction: transaction)) } } private func profileAvatarImageData( forAddress address: SignalServiceAddress?, shouldValidate: Bool, transaction: SDSAnyReadTransaction ) -> Data? { func validateIfNecessary(_ imageData: Data) -> Data? { guard shouldValidate else { return imageData } guard imageData.ows_isValidImage else { owsFailDebug("Invalid image data.") return nil } return imageData } guard let address = address, address.isValid else { owsFailDebug("Missing or invalid address.") return nil } if let avatarData = profileManagerImpl.profileAvatarData(for: address, transaction: transaction), let validData = validateIfNecessary(avatarData) { return validData } return nil } private func systemContactOrSyncedImageData( forAddress address: SignalServiceAddress?, shouldValidate: Bool, transaction: SDSAnyReadTransaction ) -> Data? { func validateIfNecessary(_ imageData: Data) -> Data? { guard shouldValidate else { return imageData } guard imageData.ows_isValidImage else { owsFailDebug("Invalid image data.") return nil } return imageData } guard let address, address.isValid else { owsFailDebug("Missing or invalid address.") return nil } guard !address.isLocalAddress else { // Never use system contact or synced image data for the local user return nil } if let phoneNumber = address.phoneNumber, let signalAccount = self.fetchSignalAccount(forPhoneNumber: phoneNumber, transaction: transaction), let cnContactId = signalAccount.cnContactId, let avatarData = self.avatarData(for: cnContactId), let validData = validateIfNecessary(avatarData) { return validData } return nil } // MARK: - Intersection private func buildContactAvatarHash(for systemContact: SystemContact) -> Data? { return autoreleasepool { let cnContactId = systemContact.cnContactId guard let contactAvatarData = avatarData(for: cnContactId) else { return nil } guard let contactAvatarHash = Cryptography.computeSHA256Digest(contactAvatarData) else { owsFailDebug("Could not digest contactAvatarData.") return nil } return contactAvatarHash } } private func discoverableRecipient(for canonicalPhoneNumber: CanonicalPhoneNumber, tx: SDSAnyReadTransaction) -> SignalRecipient? { let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable for phoneNumber in [canonicalPhoneNumber.rawValue] + canonicalPhoneNumber.alternatePhoneNumbers() { let recipient = recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber.stringValue, transaction: tx.asV2Read) guard let recipient, recipient.isPhoneNumberDiscoverable else { continue } return recipient } return nil } private func buildSignalAccounts( for fetchedSystemContacts: FetchedSystemContacts, transaction: SDSAnyReadTransaction ) -> [SignalAccount] { var discoverableRecipients = [CanonicalPhoneNumber: (SignalRecipient, ServiceId)]() var discoverablePhoneNumberCounts = [String: Int]() for (phoneNumber, contactRef) in fetchedSystemContacts.phoneNumberToContactRef { guard let signalRecipient = discoverableRecipient(for: phoneNumber, tx: transaction) else { // Not discoverable. continue } guard let serviceId = signalRecipient.aci ?? signalRecipient.pni else { owsFailDebug("Can't be discoverable without an ACI or PNI.") continue } discoverableRecipients[phoneNumber] = (signalRecipient, serviceId) discoverablePhoneNumberCounts[contactRef.cnContactId, default: 0] += 1 } var signalAccounts = [SignalAccount]() for (phoneNumber, contactRef) in fetchedSystemContacts.phoneNumberToContactRef { guard let (signalRecipient, serviceId) = discoverableRecipients[phoneNumber] else { continue } guard let discoverablePhoneNumberCount = discoverablePhoneNumberCounts[contactRef.cnContactId] else { owsFailDebug("Couldn't find relatedPhoneNumbers") continue } guard let systemContact = fetchedSystemContacts.cnContactIdToContact[contactRef.cnContactId] else { owsFailDebug("Couldn't find systemContact") continue } let multipleAccountLabelText = Contact.uniquePhoneNumberLabel( userProvidedLabel: contactRef.userProvidedLabel, discoverablePhoneNumberCount: discoverablePhoneNumberCount ) let contactAvatarHash = buildContactAvatarHash(for: systemContact) let signalAccount = SignalAccount( recipientPhoneNumber: signalRecipient.phoneNumber?.stringValue, recipientServiceId: serviceId, multipleAccountLabelText: multipleAccountLabelText, cnContactId: systemContact.cnContactId, givenName: systemContact.firstName, familyName: systemContact.lastName, nickname: systemContact.nickname, fullName: systemContact.fullName, contactAvatarHash: contactAvatarHash ) signalAccounts.append(signalAccount) } return signalAccounts } private func buildSignalAccountsAndUpdatePersistedState(for fetchedSystemContacts: FetchedSystemContacts) { assertOnQueue(swiftValues.intersectionQueue) let (oldSignalAccounts, newSignalAccounts) = databaseStorage.read { transaction in let oldSignalAccounts = SignalAccount.anyFetchAll(transaction: transaction) let newSignalAccounts = buildSignalAccounts(for: fetchedSystemContacts, transaction: transaction) return (oldSignalAccounts, newSignalAccounts) } let oldSignalAccountsMap: [String?: SignalAccount] = Dictionary( oldSignalAccounts.lazy.map { ($0.recipientPhoneNumber, $0) }, uniquingKeysWith: { _, new in new } ) var newSignalAccountsMap = [String: SignalAccount]() var signalAccountChanges: [(remove: SignalAccount?, insert: SignalAccount?)] = [] for newSignalAccount in newSignalAccounts { guard let phoneNumber = newSignalAccount.recipientPhoneNumber else { owsFailDebug("Can't have a system contact without a phone number.") continue } // The user might have multiple entries in their address book with the same phone number. if newSignalAccountsMap[phoneNumber] != nil { Logger.warn("Ignoring redundant signal account") continue } let oldSignalAccountToKeep: SignalAccount? let oldSignalAccount = oldSignalAccountsMap[phoneNumber] switch oldSignalAccount { case .none: oldSignalAccountToKeep = nil case .some(let oldSignalAccount) where oldSignalAccount.hasSameContent(newSignalAccount) && !oldSignalAccount.hasDeprecatedRepresentation: // Same content, no need to update. oldSignalAccountToKeep = oldSignalAccount case .some: oldSignalAccountToKeep = nil } if let oldSignalAccount = oldSignalAccountToKeep { newSignalAccountsMap[phoneNumber] = oldSignalAccount } else { newSignalAccountsMap[phoneNumber] = newSignalAccount signalAccountChanges.append((oldSignalAccount, newSignalAccount)) } } // Clean up orphans. for signalAccount in oldSignalAccounts { if let phoneNumber = signalAccount.recipientPhoneNumber, newSignalAccountsMap[phoneNumber]?.uniqueId == signalAccount.uniqueId { // Don't clean up SignalAccounts that aren't changing. continue } // Clean up instances that have been replaced by another instance or are no // longer in the system contacts. signalAccountChanges.append((signalAccount, nil)) } // Update cached SignalAccounts on disk databaseStorage.write { tx in for (signalAccountToRemove, signalAccountToInsert) in signalAccountChanges { let oldSignalAccount = signalAccountToRemove.flatMap { SignalAccount.anyFetch(uniqueId: $0.uniqueId, transaction: tx) } let newSignalAccount = signalAccountToInsert oldSignalAccount?.anyRemove(transaction: tx) newSignalAccount?.anyInsert(transaction: tx) updatePhoneNumberVisibilityIfNeeded( oldSignalAccount: oldSignalAccount, newSignalAccount: newSignalAccount, tx: tx.asV2Write ) } if !signalAccountChanges.isEmpty { Logger.info("Updated \(signalAccountChanges.count) SignalAccounts; now have \(newSignalAccountsMap.count) total") } // Add system contacts to the profile whitelist immediately so that they do // not see the "message request" UI. profileManager.addUsers( toProfileWhitelist: newSignalAccountsMap.values.map { $0.recipientAddress }, userProfileWriter: .systemContactsFetch, transaction: tx ) } // Once we've persisted new SignalAccount state, we should let // StorageService know. updateStorageServiceForSystemContactsFetch( allSignalAccountsBeforeFetch: oldSignalAccountsMap, allSignalAccountsAfterFetch: newSignalAccountsMap ) let didChangeAnySignalAccount = !signalAccountChanges.isEmpty DispatchQueue.main.async { // Post a notification if something changed or this is the first load since launch. let shouldNotify = didChangeAnySignalAccount || !self.hasLoadedSystemContacts self.hasLoadedSystemContacts = true self.didUpdateSignalAccounts(shouldNotify: shouldNotify) } } /// Updates StorageService records for any Signal contacts associated with /// a system contact that has been added, removed, or modified in a /// relevant way. Has no effect when we are a linked device. private func updateStorageServiceForSystemContactsFetch( allSignalAccountsBeforeFetch: [String?: SignalAccount], allSignalAccountsAfterFetch: [String: SignalAccount] ) { let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false else { return } var phoneNumbersToUpdateInStorageService = [String]() var allSignalAccountsBeforeFetch = allSignalAccountsBeforeFetch for (phoneNumber, newSignalAccount) in allSignalAccountsAfterFetch { let oldSignalAccount = allSignalAccountsBeforeFetch.removeValue(forKey: phoneNumber) if let oldSignalAccount, newSignalAccount.hasSameName(oldSignalAccount) { // No Storage Service-relevant changes were made. continue } phoneNumbersToUpdateInStorageService.append(phoneNumber) } // Anything left in ...BeforeFetch was removed. phoneNumbersToUpdateInStorageService.append( contentsOf: allSignalAccountsBeforeFetch.keys.lazy.compactMap { $0 } ) let updatedAccountIds = databaseStorage.read { tx in return phoneNumbersToUpdateInStorageService.compactMap { let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable return recipientDatabaseTable.fetchRecipient(phoneNumber: $0, transaction: tx.asV2Read)?.uniqueId } } storageServiceManager.recordPendingUpdates(updatedAccountIds: updatedAccountIds) } private func updatePhoneNumberVisibilityIfNeeded( oldSignalAccount: SignalAccount?, newSignalAccount: SignalAccount?, tx: DBWriteTransaction ) { let aciToUpdate = SignalAccount.aciForPhoneNumberVisibilityUpdate( oldAccount: oldSignalAccount, newAccount: newSignalAccount ) guard let aciToUpdate else { return } let recipient = swiftValues.recipientDatabaseTable.fetchRecipient(serviceId: aciToUpdate, transaction: tx) guard let recipient else { return } // Tell the cache to refresh its state for this recipient. It will check // whether or not the number should be visible based on this state and the // state of system contacts. signalServiceAddressCache.updateRecipient(recipient, tx: tx) } public func didUpdateSignalAccounts(transaction: SDSAnyWriteTransaction) { transaction.addTransactionFinalizationBlock(forKey: "OWSContactsManager.didUpdateSignalAccounts") { _ in self.didUpdateSignalAccounts(shouldNotify: true) } } private func didUpdateSignalAccounts(shouldNotify: Bool) { if shouldNotify { NotificationCenter.default.postNotificationNameAsync(.OWSContactsManagerSignalAccountsDidChange, object: nil) } } private enum Constants { static let nextFullIntersectionDate = "OWSContactsManagerKeyNextFullIntersectionDate2" static let lastKnownContactPhoneNumbers = "OWSContactsManagerKeyLastKnownContactPhoneNumbers" static let didIntersectAddressBook = "didIntersectAddressBook" } func updateContacts(_ addressBookContacts: [SystemContact]?, isUserRequested: Bool) { swiftValues.intersectionQueue.async { self._updateContacts(addressBookContacts, isUserRequested: isUserRequested) } } private func fetchPriorIntersectionPhoneNumbers(tx: SDSAnyReadTransaction) -> Set? { keyValueStore.getObject(forKey: Constants.lastKnownContactPhoneNumbers, transaction: tx) as? Set } private func setPriorIntersectionPhoneNumbers(_ phoneNumbers: Set, tx: SDSAnyWriteTransaction) { keyValueStore.setObject(phoneNumbers, key: Constants.lastKnownContactPhoneNumbers, transaction: tx) } private enum IntersectionMode { /// It's time for the regularly-scheduled full intersection. case fullIntersection /// It's not time for the regularly-scheduled full intersection. Only check /// new phone numbers. case deltaIntersection(priorPhoneNumbers: Set) } private func fetchIntersectionMode(isUserRequested: Bool, tx: SDSAnyReadTransaction) -> IntersectionMode { if isUserRequested { return .fullIntersection } let nextFullIntersectionDate = keyValueStore.getDate(Constants.nextFullIntersectionDate, transaction: tx) guard let nextFullIntersectionDate, nextFullIntersectionDate.isAfterNow else { return .fullIntersection } guard let priorPhoneNumbers = fetchPriorIntersectionPhoneNumbers(tx: tx) else { // We don't know the prior phone numbers, so do a `.fullIntersection`. return .fullIntersection } return .deltaIntersection(priorPhoneNumbers: priorPhoneNumbers) } private func _updateContacts(_ addressBookContacts: [SystemContact]?, isUserRequested: Bool) { let tsAccountManager = DependenciesBridge.shared.tsAccountManager let localNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber let fetchedSystemContacts = FetchedSystemContacts.parseContacts( addressBookContacts ?? [], phoneNumberUtil: NSObject.phoneNumberUtil, localPhoneNumber: localNumber ) setFetchedSystemContacts(fetchedSystemContacts) intersectContacts( fetchedSystemContacts: fetchedSystemContacts, localNumber: localNumber, isUserRequested: isUserRequested ) } private func intersectContacts( fetchedSystemContacts: FetchedSystemContacts, localNumber: String?, isUserRequested: Bool ) { let systemContactPhoneNumbers = fetchedSystemContacts.phoneNumberToContactRef.keys let (intersectionMode, signalRecipientPhoneNumbers) = databaseStorage.read { tx in let intersectionMode = fetchIntersectionMode(isUserRequested: isUserRequested, tx: tx) let signalRecipientPhoneNumbers = SignalRecipient.fetchAllPhoneNumbers(tx: tx) return (intersectionMode, signalRecipientPhoneNumbers) } var phoneNumbersToIntersect = Set(signalRecipientPhoneNumbers.keys) phoneNumbersToIntersect.formUnion(systemContactPhoneNumbers.lazy.map { $0.rawValue.stringValue }) phoneNumbersToIntersect.formUnion(systemContactPhoneNumbers.lazy.flatMap { $0.alternatePhoneNumbers().map { $0.stringValue } }) if case .deltaIntersection(let priorPhoneNumbers) = intersectionMode { phoneNumbersToIntersect.subtract(priorPhoneNumbers) } if let localNumber { phoneNumbersToIntersect.remove(localNumber) } switch intersectionMode { case .fullIntersection: Logger.info("Performing full intersection for \(phoneNumbersToIntersect.count) phone numbers.") case .deltaIntersection: Logger.info("Performing delta intersection for \(phoneNumbersToIntersect.count) phone numbers.") } let intersectionPromise = intersectContacts(phoneNumbersToIntersect, retryDelaySeconds: 1) intersectionPromise.done(on: swiftValues.intersectionQueue) { intersectedRecipients in // Mark it as complete. If the app crashes after this transaction, we'll // avoid a redundant (expensive) intersection when we retry. self.databaseStorage.write { tx in self.didFinishIntersection( mode: intersectionMode, phoneNumbers: phoneNumbersToIntersect, tx: tx ) } // Save names to the database before generating notifications. self.buildSignalAccountsAndUpdatePersistedState(for: fetchedSystemContacts) self.databaseStorage.write { tx in self.postJoinNotificationsIfNeeded( addressBookPhoneNumbers: systemContactPhoneNumbers, phoneNumberRegistrationStatus: signalRecipientPhoneNumbers, intersectedRecipients: intersectedRecipients, tx: tx ) self.unhideRecipientsIfNeeded( addressBookPhoneNumbers: systemContactPhoneNumbers, tx: tx.asV2Write ) } }.catch(on: swiftValues.intersectionQueue) { error in owsFailDebug("Couldn't intersect contacts: \(error)") } } private func postJoinNotificationsIfNeeded( addressBookPhoneNumbers: some Sequence, phoneNumberRegistrationStatus: [String: Bool], intersectedRecipients: some Sequence, tx: SDSAnyWriteTransaction ) { let didIntersectAtLeastOnce = keyValueStore.getBool(Constants.didIntersectAddressBook, defaultValue: false, transaction: tx) guard didIntersectAtLeastOnce else { // This is the first address book intersection. Don't post notifications, // but mark the flag so that we post notifications next time. keyValueStore.setBool(true, key: Constants.didIntersectAddressBook, transaction: tx) return } guard Self.preferences.shouldNotifyOfNewAccounts(transaction: tx) else { return } let phoneNumbers = Set(addressBookPhoneNumbers.lazy.map { $0.rawValue.stringValue }) for signalRecipient in intersectedRecipients { guard let phoneNumber = signalRecipient.phoneNumber, phoneNumber.isDiscoverable else { continue // Can't happen. } guard phoneNumbers.contains(phoneNumber.stringValue) else { continue // Not in the address book -- no notification. } guard phoneNumberRegistrationStatus[phoneNumber.stringValue] != true else { continue // They were already registered -- no notification. } NewAccountDiscovery.postNotification(for: signalRecipient, tx: tx) } } /// We cannot hide a contact that is in our address book. /// As a result, when a contact that was hidden is added to the address book, /// we must unhide them. private func unhideRecipientsIfNeeded( addressBookPhoneNumbers: some Sequence, tx: DBWriteTransaction ) { let recipientHidingManager = DependenciesBridge.shared.recipientHidingManager let phoneNumbers = Set(addressBookPhoneNumbers.lazy.map { $0.rawValue.stringValue }) for hiddenRecipient in recipientHidingManager.hiddenRecipients(tx: tx) { guard let phoneNumber = hiddenRecipient.phoneNumber else { continue // We can't unhide because of the address book w/o a phone number. } guard phoneNumber.isDiscoverable else { continue // Not discoverable -- no unhiding. } guard phoneNumbers.contains(phoneNumber.stringValue) else { continue // Not in the address book -- no unhiding. } DependenciesBridge.shared.recipientHidingManager.removeHiddenRecipient( hiddenRecipient, wasLocallyInitiated: true, tx: tx ) } } private func didFinishIntersection( mode intersectionMode: IntersectionMode, phoneNumbers: Set, tx: SDSAnyWriteTransaction ) { switch intersectionMode { case .fullIntersection: setPriorIntersectionPhoneNumbers(phoneNumbers, tx: tx) let nextFullIntersectionDate = Date(timeIntervalSinceNow: RemoteConfig.cdsSyncInterval) keyValueStore.setDate(nextFullIntersectionDate, key: Constants.nextFullIntersectionDate, transaction: tx) case .deltaIntersection: // If a user has a "flaky" address book (perhaps it's a network-linked // directory that goes in and out of existence), we could get thrashing // between what the last known set is, causing us to re-intersect contacts // many times within the debounce interval. So while we're doing // incremental intersections, we *accumulate*, rather than replace, the set // of recently intersected contacts. let priorPhoneNumbers = fetchPriorIntersectionPhoneNumbers(tx: tx) ?? [] setPriorIntersectionPhoneNumbers(priorPhoneNumbers.union(phoneNumbers), tx: tx) } } private func intersectContacts( _ phoneNumbers: Set, retryDelaySeconds: TimeInterval ) -> Promise> { owsAssertDebug(retryDelaySeconds > 0) if phoneNumbers.isEmpty { return .value([]) } return contactDiscoveryManager.lookUp( phoneNumbers: phoneNumbers, mode: .contactIntersection ).recover(on: DispatchQueue.global()) { (error) -> Promise> in var retryAfter: TimeInterval = retryDelaySeconds if let cdsError = error as? ContactDiscoveryError { guard cdsError.code != ContactDiscoveryError.Kind.rateLimit.rawValue else { Logger.error("Contact intersection hit rate limit with error: \(error)") return Promise(error: error) } guard cdsError.retrySuggested else { Logger.error("Contact intersection error suggests not to retry. Aborting without rescheduling.") return Promise(error: error) } if let retryAfterDate = cdsError.retryAfterDate { retryAfter = max(retryAfter, retryAfterDate.timeIntervalSinceNow) } } // TODO: Abort if another contact intersection succeeds in the meantime. Logger.warn("Contact intersection failed with error: \(error). Rescheduling.") return Guarantee.after(seconds: retryAfter).then(on: DispatchQueue.global()) { self.intersectContacts(phoneNumbers, retryDelaySeconds: retryDelaySeconds * 2) } } } private static let unknownAddressFetchDateMap = AtomicDictionary(lock: .sharedGlobal) @objc(fetchProfileForUnknownAddress:) func fetchProfile(forUnknownAddress address: SignalServiceAddress) { // We only consider ACIs b/c PNIs will never give us a name other than "Unknown". guard let aci = address.serviceId as? Aci else { return } let minFetchInterval = kMinuteInterval * 30 if let lastFetchDate = Self.unknownAddressFetchDateMap[aci], abs(lastFetchDate.timeIntervalSinceNow) < minFetchInterval { return } Self.unknownAddressFetchDateMap[aci] = Date() let profileFetcher = SSKEnvironment.shared.profileFetcherRef _ = profileFetcher.fetchProfileSync(for: aci, options: [.opportunistic]) } // MARK: - System Contacts private func setFetchedSystemContacts(_ fetchedSystemContacts: FetchedSystemContacts) { swiftValues.systemContactsCache.fetchedSystemContacts.set(fetchedSystemContacts) swiftValues.cnContactCache.removeAllObjects() NotificationCenter.default.postNotificationNameAsync(.OWSContactsManagerContactsDidChange, object: nil) } public func cnContact(withId cnContactId: String?) -> CNContact? { guard let cnContactId else { return nil } if let cnContact = swiftValues.cnContactCache[cnContactId] { return cnContact } let cnContact = systemContactsFetcher.fetchCNContact(contactId: cnContactId) if let cnContact { swiftValues.cnContactCache[cnContactId] = cnContact } return cnContact } public func cnContactId(for phoneNumber: String) -> String? { guard let phoneNumber = E164(phoneNumber) else { return nil } let fetchedSystemContacts = swiftValues.systemContactsCache.fetchedSystemContacts.get() let canonicalPhoneNumber = CanonicalPhoneNumber(nonCanonicalPhoneNumber: phoneNumber) return fetchedSystemContacts?.phoneNumberToContactRef[canonicalPhoneNumber]?.cnContactId } // MARK: - Display Names private func displayNamesRefinery( for addresses: [SignalServiceAddress], transaction: SDSAnyReadTransaction ) -> Refinery { let tx = transaction.asV2Read return .init(addresses).refine { addresses -> [DisplayName?] in return addresses.map { address -> DisplayName? in swiftValues.recipientDatabaseTable.fetchRecipient(address: address, tx: tx) .flatMap { swiftValues.nicknameManager.fetchNickname(for: $0, tx: tx) } .flatMap(ProfileName.init(nicknameRecord:)) .map(DisplayName.nickname(_:)) } }.refine { addresses -> [DisplayName?] in // Prefer a saved name from system contacts, if available. return systemContactNames(for: addresses, tx: transaction) .map { $0.map { .systemContactName($0) } } }.refine { addresses -> [DisplayName?] in return profileManager.fetchUserProfiles(for: Array(addresses), tx: transaction) .map { $0?.nameComponents.map { .profileName($0) } } }.refine { addresses -> [DisplayName?] in return addresses.map { $0.e164.map { .phoneNumber($0) } } }.refine { addresses -> [DisplayName?] in return swiftValues.usernameLookupManager.fetchUsernames( forAddresses: addresses, transaction: transaction.asV2Read ).map { $0.map { .username($0) } } }.refine { addresses in let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable return addresses.lazy.map { address -> DisplayName in let signalRecipient = recipientDatabaseTable.fetchRecipient( address: address, tx: transaction.asV2Read ) if let signalRecipient, !signalRecipient.isRegistered { return .deletedAccount } else { self.fetchProfile(forUnknownAddress: address) return .unknown } } as [DisplayName?] } } public func displayNamesByAddress( for addresses: [SignalServiceAddress], transaction: SDSAnyReadTransaction ) -> [SignalServiceAddress: DisplayName] { Dictionary(displayNamesRefinery(for: addresses, transaction: transaction)) } public func displayNames(for addresses: [SignalServiceAddress], tx: SDSAnyReadTransaction) -> [DisplayName] { displayNamesRefinery(for: addresses, transaction: tx).values.map { $0! } } private func systemContactNames(for addresses: some Sequence, tx: SDSAnyReadTransaction) -> [DisplayName.SystemContactName?] { let phoneNumbers = addresses.map { $0.phoneNumber } var compactedResult = systemContactNames(for: phoneNumbers.compacted(), tx: tx).makeIterator() return phoneNumbers.map { $0 != nil ? compactedResult.next()! : nil } } public func leaseCacheSize(_ cacheSize: Int) -> ModelReadCacheSizeLease { return modelReadCaches.signalAccountReadCache.leaseCacheSize(cacheSize) } public func fetchSignalAccounts( for phoneNumbers: [String], transaction: SDSAnyReadTransaction ) -> [SignalAccount?] { return modelReadCaches.signalAccountReadCache.getSignalAccounts( phoneNumbers: phoneNumbers, transaction: transaction ) } public func shortestDisplayName( forGroupMember groupMember: SignalServiceAddress, inGroup groupModel: TSGroupModel, transaction: SDSAnyReadTransaction ) -> String { let displayName = self.displayName(for: groupMember, tx: transaction) let fullName = displayName.resolvedValue() let shortName = displayName.resolvedValue(useShortNameIfAvailable: true) guard fullName != shortName else { return fullName } // Try to return just the short name unless the group contains another // member with the same short name. for otherMember in groupModel.groupMembership.fullMembers { guard otherMember != groupMember else { continue } // Use the full name if the member's short name matches // another member's full or short name. let otherDisplayName = self.displayName(for: otherMember, tx: transaction) guard otherDisplayName.resolvedValue() != shortName else { return fullName } guard otherDisplayName.resolvedValue(useShortNameIfAvailable: true) != shortName else { return fullName } } return shortName } } // MARK: - ContactManager extension ContactManager { public func nameForAddress( _ address: SignalServiceAddress, localUserDisplayMode: LocalUserDisplayMode, short: Bool, transaction: SDSAnyReadTransaction ) -> NSAttributedString { return { () -> String in if address.isLocalAddress { switch localUserDisplayMode { case .noteToSelf: return MessageStrings.noteToSelf case .asLocalUser: return CommonStrings.you case .asUser: break } } let displayName = self.displayName(for: address, tx: transaction) return displayName.resolvedValue(useShortNameIfAvailable: short) }().asAttributedString } public func displayName(for thread: TSThread, transaction: SDSAnyReadTransaction) -> String { return displayName(for: thread, tx: transaction)?.resolvedValue() ?? "" } public func displayName(for thread: TSThread, tx: SDSAnyReadTransaction) -> ThreadDisplayName? { if thread.isNoteToSelf { return .noteToSelf } switch thread { case let thread as TSContactThread: return .contactThread(displayName(for: thread.contactAddress, tx: tx)) case let thread as TSGroupThread: return .groupThread(thread.groupNameOrDefault) default: owsFailDebug("Unexpected thread type: \(type(of: thread))") return nil } } public func sortSignalServiceAddresses( _ addresses: some Sequence, transaction: SDSAnyReadTransaction ) -> [SignalServiceAddress] { return sortedComparableNames(for: addresses, tx: transaction).map { $0.address } } public func sortedComparableNames( for addresses: some Sequence, tx: SDSAnyReadTransaction ) -> [ComparableDisplayName] { let addresses = Array(addresses) let displayNames = self.displayNames(for: addresses, tx: tx) let config = DisplayName.ComparableValue.Config.current() return zip(addresses, displayNames).map { (address, displayName) in return ComparableDisplayName( address: address, displayName: displayName, config: config ) }.sorted(by: <) } } // MARK: - ThreadDisplayName public enum ThreadDisplayName { case noteToSelf case contactThread(DisplayName) case groupThread(String) public func resolvedValue() -> String { switch self { case .noteToSelf: return MessageStrings.noteToSelf case .contactThread(let displayName): return displayName.resolvedValue() case .groupThread(let groupName): return groupName } } }