TM-SGNL-iOS/SignalServiceKit/Contacts/OWSContactsManager.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1340 lines
56 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Contacts
import CryptoKit
import Foundation
import LibSignalClient
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, limited, authorized
}
public enum ContactAuthorizationForEditing {
// Contact edit access not supported by this device (e.g. a linked device)
case notAllowed
// Contact edit access explicitly denied by user
case notAuthorized
// Contact read access explicitly allowed by user to some or all of the system contacts.
// In both of these situations, the user can write to system contacts.
case authorized
}
public enum ContactAuthorizationForSyncing {
// Contact edit access not supported by this device (e.g. a linked device)
case notAllowed
// Contact edit access explicitly denied by user
case denied
// Contact access restricted by actions outside the control of the user (see CNAuthorizationStatus.restricted)
case restricted
// Contact read access explicitly allowed by user to all of the system contacts.
case authorized
// Contact read access explicitly allowed by user to some of the system contacts.
case limited
}
public enum ContactAuthorizationForSharing {
// Authorization hasn't yet been requested
case notDetermined
// Contact read access explicitly denied by user
case denied
// Some type of contact read access explicitly allowed by user
case authorized
}
public class OWSContactsManager: NSObject, ContactsManagerProtocol {
private let avatarBlurringCache = LowTrustCache()
private let cnContactCache = LRUCache<String, CNContact>(maxSize: 50, shouldEvacuateInBackground: true)
private let isInWhitelistedGroupWithLocalUserCache = AtomicDictionary<ServiceId, Bool>([:], lock: .init())
private let hasWhitelistedGroupMemberCache = AtomicDictionary<Data, Bool>([:], lock: .init())
private let systemContactsCache = SystemContactsCache()
private let unknownThreadWarningCache = LowTrustCache()
private let intersectionQueue = DispatchQueue(label: "org.signal.contacts.intersection")
private let keyValueStore = KeyValueStore(collection: "OWSContactsManagerCollection")
private let skipContactAvatarBlurByServiceIdStore = KeyValueStore(collection: "OWSContactsManager.skipContactAvatarBlurByUuidStore")
private let skipGroupAvatarBlurByGroupIdStore = KeyValueStore(collection: "OWSContactsManager.skipGroupAvatarBlurByGroupIdStore")
private let nicknameManager: any NicknameManager
private let recipientDatabaseTable: any RecipientDatabaseTable
private let systemContactsFetcher: SystemContactsFetcher
private let usernameLookupManager: UsernameLookupManager
public var isEditingAllowed: Bool {
// We're only allowed to edit contacts on devices that can sync them. Otherwise the UX doesn't make sense.
return isSyncingAllowed
}
public var isSyncingAllowed: Bool {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
return tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false
}
/// 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, .restricted:
return .notAuthorized
case .authorized, .limited:
return .authorized
}
}
/// Must call `requestSystemContactsOnce` before accessing this method
public var syncingAuthorization: ContactAuthorizationForSyncing {
guard isSyncingAllowed 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 .limited:
return .limited
case .authorized:
return .authorized
}
}
public var sharingAuthorization: ContactAuthorizationForSharing {
switch self.systemContactsFetcher.rawAuthorizationStatus {
case .notDetermined:
return .notDetermined
case .denied, .restricted:
return .denied
case .authorized, .limited:
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(
appReadiness: AppReadiness,
nicknameManager: any NicknameManager,
recipientDatabaseTable: any RecipientDatabaseTable,
usernameLookupManager: any UsernameLookupManager
) {
self.nicknameManager = nicknameManager
self.recipientDatabaseTable = recipientDatabaseTable
self.systemContactsFetcher = SystemContactsFetcher(appReadiness: appReadiness)
self.usernameLookupManager = usernameLookupManager
super.init()
self.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 isSyncingAllowed else {
if let completion = completion {
Logger.warn("Editing contacts isn't available on linked devices.")
completion(OWSError.makeGenericError())
}
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 isSyncingAllowed 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() -> Promise<Void> {
guard isSyncingAllowed else {
owsFailDebug("Editing contacts isn't available on linked devices.")
return Promise<Void>(error: OWSError.makeAssertionError())
}
return Promise<Void> { future in
self.systemContactsFetcher.userRequestedRefresh { error in
if let error {
Logger.error("refreshing contacts failed with error: \(error)")
future.reject(error)
} else {
future.resolve(())
}
}
}
}
}
// 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 {
// TODO: [Contacts, iOS 18] Validate if limited contacts authorization is appropriate
case .restricted, .denied, .limited:
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: -
private class SystemContactsCache {
let fetchedSystemContacts = AtomicOptional<FetchedSystemContacts>(nil, lock: .init())
}
// MARK: -
private class LowTrustCache {
let contactCache = AtomicSet<ServiceId>(lock: .sharedGlobal)
let groupCache = AtomicSet<Data>(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 === avatarBlurringCache,
let storeKey = address.serviceId?.serviceIdUppercaseString,
skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx.asV2Read)
{
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 === unknownThreadWarningCache, hasWhitelistedGroupMember(groupThread: groupThread, tx: tx) {
lowTrustCache.add(groupThread: groupThread)
return false
}
return true
}
private func isInWhitelistedGroupWithLocalUser(otherAddress: SignalServiceAddress, tx: SDSAnyReadTransaction) -> Bool {
let cache = 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 SSKEnvironment.shared.profileManagerRef.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 = hasWhitelistedGroupMemberCache
let cacheKey = groupThread.groupId
if let cachedValue = cache[cacheKey] {
return cachedValue
}
let result: Bool = {
for groupMember in groupThread.groupMembership.fullMembers {
if SSKEnvironment.shared.profileManagerRef.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: unknownThreadWarningCache, tx: transaction)
}
// MARK: - Avatar Blurring
public func shouldBlurContactAvatar(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
isLowTrustContact(address: address, lowTrustCache: avatarBlurringCache, tx: transaction)
}
public func shouldBlurContactAvatar(contactThread: TSContactThread, transaction: SDSAnyReadTransaction) -> Bool {
isLowTrustContact(contactThread: contactThread, lowTrustCache: 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: avatarBlurringCache,
tx: transaction
) {
return false
}
// We can skip avatar blurring if the user has explicitly waived the blurring.
if skipGroupAvatarBlurByGroupIdStore.getBool(
groupThread.groupId.hexadecimalString,
defaultValue: false,
transaction: transaction.asV2Read
) {
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 = skipContactAvatarBlurByServiceIdStore.getBool(storeKey, defaultValue: false, transaction: tx.asV2Read)
guard !shouldSkipBlur else {
owsFailDebug("Value did not change.")
return
}
skipContactAvatarBlurByServiceIdStore.setBool(true, key: storeKey, transaction: tx.asV2Write)
if let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx) {
SSKEnvironment.shared.databaseStorageRef.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 !skipGroupAvatarBlurByGroupIdStore.getBool(
groupId.hexadecimalString,
defaultValue: false,
transaction: transaction.asV2Read
) else {
owsFailDebug("Value did not change.")
return
}
skipGroupAvatarBlurByGroupIdStore.setBool(
true,
key: groupId.hexadecimalString,
transaction: transaction.asV2Write
)
SSKEnvironment.shared.databaseStorageRef.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 = SSKEnvironment.shared.profileManagerImplRef.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
}
return Data(SHA256.hash(data: contactAvatarData))
}
}
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(intersectionQueue)
let (oldSignalAccounts, newSignalAccounts) = SSKEnvironment.shared.databaseStorageRef.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
SSKEnvironment.shared.databaseStorageRef.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.
SSKEnvironment.shared.profileManagerRef.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 updatedRecipientUniqueIds = SSKEnvironment.shared.databaseStorageRef.read { tx in
return phoneNumbersToUpdateInStorageService.compactMap {
let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
return recipientDatabaseTable.fetchRecipient(phoneNumber: $0, transaction: tx.asV2Read)?.uniqueId
}
}
SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(updatedRecipientUniqueIds: updatedRecipientUniqueIds)
}
private func updatePhoneNumberVisibilityIfNeeded(
oldSignalAccount: SignalAccount?,
newSignalAccount: SignalAccount?,
tx: DBWriteTransaction
) {
let aciToUpdate = SignalAccount.aciForPhoneNumberVisibilityUpdate(
oldAccount: oldSignalAccount,
newAccount: newSignalAccount
)
guard let aciToUpdate else {
return
}
let recipient = 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.
SSKEnvironment.shared.signalServiceAddressCacheRef.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) {
intersectionQueue.async { self._updateContacts(addressBookContacts, isUserRequested: isUserRequested) }
}
private func fetchPriorIntersectionPhoneNumbers(tx: SDSAnyReadTransaction) -> Set<String>? {
return keyValueStore.getSet(Constants.lastKnownContactPhoneNumbers, ofClass: NSString.self, transaction: tx.asV2Read) as Set<String>?
}
private func setPriorIntersectionPhoneNumbers(_ phoneNumbers: Set<String>, tx: SDSAnyWriteTransaction) {
keyValueStore.setObject(phoneNumbers, key: Constants.lastKnownContactPhoneNumbers, transaction: tx.asV2Write)
}
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<String>)
}
private func fetchIntersectionMode(isUserRequested: Bool, tx: SDSAnyReadTransaction) -> IntersectionMode {
if isUserRequested {
return .fullIntersection
}
let nextFullIntersectionDate = keyValueStore.getDate(Constants.nextFullIntersectionDate, transaction: tx.asV2Read)
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: SSKEnvironment.shared.phoneNumberUtilRef,
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) = SSKEnvironment.shared.databaseStorageRef.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 = Promise.wrapAsync {
return try await self.intersectContacts(phoneNumbersToIntersect)
}
intersectionPromise.done(on: intersectionQueue) { intersectedRecipients in
// Mark it as complete. If the app crashes after this transaction, we'll
// avoid a redundant (expensive) intersection when we retry.
SSKEnvironment.shared.databaseStorageRef.write { tx in
self.didFinishIntersection(
mode: intersectionMode,
phoneNumbers: phoneNumbersToIntersect,
tx: tx
)
}
// Save names to the database before generating notifications.
self.buildSignalAccountsAndUpdatePersistedState(for: fetchedSystemContacts)
try SSKEnvironment.shared.databaseStorageRef.write { tx in
self.postJoinNotificationsIfNeeded(
addressBookPhoneNumbers: systemContactPhoneNumbers,
phoneNumberRegistrationStatus: signalRecipientPhoneNumbers,
intersectedRecipients: intersectedRecipients,
tx: tx
)
try self.unhideRecipientsIfNeeded(
addressBookPhoneNumbers: systemContactPhoneNumbers,
tx: tx.asV2Write
)
}
}.catch(on: intersectionQueue) { error in
owsFailDebug("Couldn't intersect contacts: \(error)")
}
}
private func postJoinNotificationsIfNeeded(
addressBookPhoneNumbers: some Sequence<CanonicalPhoneNumber>,
phoneNumberRegistrationStatus: [String: Bool],
intersectedRecipients: some Sequence<SignalRecipient>,
tx: SDSAnyWriteTransaction
) {
let didIntersectAtLeastOnce = keyValueStore.getBool(Constants.didIntersectAddressBook, defaultValue: false, transaction: tx.asV2Read)
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.asV2Write)
return
}
guard SSKEnvironment.shared.preferencesRef.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<CanonicalPhoneNumber>,
tx: DBWriteTransaction
) throws {
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.
}
try DependenciesBridge.shared.recipientHidingManager.removeHiddenRecipient(
hiddenRecipient,
wasLocallyInitiated: true,
tx: tx
)
}
}
private func didFinishIntersection(
mode intersectionMode: IntersectionMode,
phoneNumbers: Set<String>,
tx: SDSAnyWriteTransaction
) {
switch intersectionMode {
case .fullIntersection:
setPriorIntersectionPhoneNumbers(phoneNumbers, tx: tx)
let nextFullIntersectionDate = Date(timeIntervalSinceNow: RemoteConfig.current.cdsSyncInterval)
keyValueStore.setDate(nextFullIntersectionDate, key: Constants.nextFullIntersectionDate, transaction: tx.asV2Write)
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<String>) async throws -> Set<SignalRecipient> {
if phoneNumbers.isEmpty {
return []
}
return try await Retry.performRepeatedly(
block: {
return try await SSKEnvironment.shared.contactDiscoveryManagerRef.lookUp(
phoneNumbers: phoneNumbers,
mode: .contactIntersection
)
},
onError: { error, attemptCount in
if case ContactDiscoveryError.rateLimit(retryAfter: _) = error {
Logger.error("Contact intersection hit rate limit with error: \(error)")
throw error
}
if error is ContactDiscoveryError, !error.isRetryable {
Logger.error("Contact intersection error suggests not to retry. Aborting without rescheduling.")
throw error
}
// TODO: Abort if another contact intersection succeeds in the meantime.
Logger.warn("Contact intersection failed with error: \(error). Rescheduling.")
try await Task.sleep(nanoseconds: OWSOperation.retryIntervalForExponentialBackoffNs(failureCount: attemptCount, maxBackoff: .infinity))
}
)
}
private static let unknownAddressFetchDateMap = AtomicDictionary<Aci, Date>(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) {
systemContactsCache.fetchedSystemContacts.set(fetchedSystemContacts)
cnContactCache.removeAllObjects()
NotificationCenter.default.postNotificationNameAsync(.OWSContactsManagerContactsDidChange, object: nil)
}
public func cnContact(withId cnContactId: String?) -> CNContact? {
guard let cnContactId else {
return nil
}
if let cnContact = cnContactCache[cnContactId] {
return cnContact
}
let cnContact = systemContactsFetcher.fetchCNContact(contactId: cnContactId)
if let cnContact {
cnContactCache[cnContactId] = cnContact
}
return cnContact
}
public func cnContactId(for phoneNumber: String) -> String? {
guard let phoneNumber = E164(phoneNumber) else {
return nil
}
let fetchedSystemContacts = 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<SignalServiceAddress, DisplayName> {
let tx = transaction.asV2Read
return .init(addresses).refine { addresses -> [DisplayName?] in
return addresses.map { address -> DisplayName? in
recipientDatabaseTable.fetchRecipient(address: address, tx: tx)
.flatMap { 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 SSKEnvironment.shared.profileManagerRef.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 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<SignalServiceAddress>, 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 fetchSignalAccounts(
for phoneNumbers: [String],
transaction: SDSAnyReadTransaction
) -> [SignalAccount?] {
return SignalAccountFinder().signalAccounts(for: phoneNumbers, tx: 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<SignalServiceAddress>,
transaction: SDSAnyReadTransaction
) -> [SignalServiceAddress] {
return sortedComparableNames(for: addresses, tx: transaction).map { $0.address }
}
public func sortedComparableNames(
for addresses: some Sequence<SignalServiceAddress>,
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
}
}
}