// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "OWSContactsManager.h" #import "Environment.h" #import "NSAttributedString+OWS.h" #import "OWSFormat.h" #import "OWSProfileManager.h" #import "OWSUserProfile.h" #import "ViewControllerUtils.h" #import #import #import #import #import #import #import #import #import #import @import Contacts; NS_ASSUME_NONNULL_BEGIN NSString *const OWSContactsManagerSignalAccountsDidChangeNotification = @"OWSContactsManagerSignalAccountsDidChangeNotification"; NSString *const OWSContactsManagerCollection = @"OWSContactsManagerCollection"; NSString *const OWSContactsManagerKeyLastKnownContactPhoneNumbers = @"OWSContactsManagerKeyLastKnownContactPhoneNumbers"; NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsManagerKeyNextFullIntersectionDate2"; @interface OWSContactsManager () @property (nonatomic) BOOL isContactsUpdateInFlight; // This reflects the contents of the device phone book and includes // contacts that do not correspond to any signal account. @property (atomic) NSArray *allContacts; @property (atomic) NSDictionary *allContactsMap; @property (atomic) NSArray *signalAccounts; @property (atomic) NSDictionary *signalAccountMap; @property (nonatomic, readonly) SystemContactsFetcher *systemContactsFetcher; @property (nonatomic, readonly) YapDatabaseConnection *dbReadConnection; @property (nonatomic, readonly) YapDatabaseConnection *dbWriteConnection; @property (nonatomic, readonly) NSCache *cnContactCache; @property (nonatomic, readonly) NSCache *cnContactAvatarCache; @property (atomic) BOOL isSetup; @end #pragma mark - @implementation OWSContactsManager - (id)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage { self = [super init]; if (!self) { return self; } // TODO: We need to configure the limits of this cache. _avatarCache = [ImageCache new]; _dbReadConnection = primaryStorage.newDatabaseConnection; _dbWriteConnection = primaryStorage.newDatabaseConnection; _allContacts = @[]; _allContactsMap = @{}; _signalAccountMap = @{}; _signalAccounts = @[]; _systemContactsFetcher = [SystemContactsFetcher new]; _systemContactsFetcher.delegate = self; _cnContactCache = [NSCache new]; _cnContactCache.countLimit = 50; _cnContactAvatarCache = [NSCache new]; _cnContactAvatarCache.countLimit = 25; OWSSingletonAssert(); [AppReadiness runNowOrWhenAppWillBecomeReady:^{ [self setup]; [self startObserving]; }]; return self; } - (void)setup { __block NSMutableArray *signalAccounts; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { NSUInteger signalAccountCount = [SignalAccount numberOfKeysInCollectionWithTransaction:transaction]; OWSLogInfo(@"loading %lu signal accounts from cache.", (unsigned long)signalAccountCount); signalAccounts = [[NSMutableArray alloc] initWithCapacity:signalAccountCount]; [SignalAccount enumerateCollectionObjectsWithTransaction:transaction usingBlock:^(SignalAccount *signalAccount, BOOL * _Nonnull stop) { [signalAccounts addObject:signalAccount]; }]; }]; [signalAccounts sortUsingComparator:self.signalAccountComparator]; [self updateSignalAccounts:signalAccounts]; } - (dispatch_queue_t)serialQueue { static dispatch_queue_t _serialQueue; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _serialQueue = dispatch_queue_create("org.whispersystems.contacts.buildSignalAccount", DISPATCH_QUEUE_SERIAL); }); return _serialQueue; } #pragma mark - System Contact Fetching // Request contacts access if you haven't asked recently. - (void)requestSystemContactsOnce { [self requestSystemContactsOnceWithCompletion:nil]; } - (void)requestSystemContactsOnceWithCompletion:(void (^_Nullable)(NSError *_Nullable error))completion { [self.systemContactsFetcher requestOnceWithCompletion:completion]; } - (void)fetchSystemContactsOnceIfAlreadyAuthorized { [self.systemContactsFetcher fetchOnceIfAlreadyAuthorized]; } - (void)userRequestedSystemContactsRefreshWithCompletion:(void (^)(NSError *_Nullable error))completionHandler { [self.systemContactsFetcher userRequestedRefreshWithCompletion:completionHandler]; } - (BOOL)isSystemContactsAuthorized { return self.systemContactsFetcher.isAuthorized; } - (BOOL)isSystemContactsDenied { return self.systemContactsFetcher.isDenied; } - (BOOL)systemContactsHaveBeenRequestedAtLeastOnce { return self.systemContactsFetcher.systemContactsHaveBeenRequestedAtLeastOnce; } - (BOOL)supportsContactEditing { return self.systemContactsFetcher.supportsContactEditing; } #pragma mark - CNContacts - (nullable CNContact *)cnContactWithId:(nullable NSString *)contactId { OWSAssertDebug(self.cnContactCache); if (!contactId) { return nil; } CNContact *_Nullable cnContact; @synchronized(self.cnContactCache) { cnContact = [self.cnContactCache objectForKey:contactId]; if (!cnContact) { cnContact = [self.systemContactsFetcher fetchCNContactWithContactId:contactId]; if (cnContact) { [self.cnContactCache setObject:cnContact forKey:contactId]; } } } return cnContact; } - (nullable NSData *)avatarDataForCNContactId:(nullable NSString *)contactId { // Don't bother to cache avatar data. CNContact *_Nullable cnContact = [self cnContactWithId:contactId]; return [Contact avatarDataForCNContact:cnContact]; } - (nullable UIImage *)avatarImageForCNContactId:(nullable NSString *)contactId { OWSAssertDebug(self.cnContactAvatarCache); if (!contactId) { return nil; } UIImage *_Nullable avatarImage; @synchronized(self.cnContactAvatarCache) { avatarImage = [self.cnContactAvatarCache objectForKey:contactId]; if (!avatarImage) { NSData *_Nullable avatarData = [self avatarDataForCNContactId:contactId]; if (avatarData && [avatarData ows_isValidImage]) { avatarImage = [UIImage imageWithData:avatarData]; } if (avatarImage) { [self.cnContactAvatarCache setObject:avatarImage forKey:contactId]; } } } return avatarImage; } #pragma mark - SystemContactsFetcherDelegate - (void)systemContactsFetcher:(SystemContactsFetcher *)systemsContactsFetcher updatedContacts:(NSArray *)contacts isUserRequested:(BOOL)isUserRequested { BOOL shouldClearStaleCache = YES; [self updateWithContacts:contacts isUserRequested:isUserRequested shouldClearStaleCache:shouldClearStaleCache]; } - (void)systemContactsFetcher:(SystemContactsFetcher *)systemContactsFetcher hasAuthorizationStatus:(enum ContactStoreAuthorizationStatus)authorizationStatus { if (authorizationStatus == ContactStoreAuthorizationStatusRestricted || authorizationStatus == ContactStoreAuthorizationStatusDenied) { // Clear the contacts cache if access to the system contacts is revoked. [self updateWithContacts:@[] isUserRequested:NO shouldClearStaleCache:YES]; } } #pragma mark - Intersection - (NSSet *)recipientIdsForIntersectionWithContacts:(NSArray *)contacts { OWSAssertDebug(contacts); NSMutableSet *recipientIds = [NSMutableSet set]; for (Contact *contact in contacts) { for (PhoneNumber *phoneNumber in contact.parsedPhoneNumbers) { [recipientIds addObject:phoneNumber.toE164]; } } return recipientIds; } - (void)intersectContacts:(NSArray *)contacts isUserRequested:(BOOL)isUserRequested completion:(void (^)(NSError *_Nullable error))completion { OWSAssertDebug(contacts); OWSAssertDebug(completion); dispatch_async(self.serialQueue, ^{ __block BOOL isFullIntersection = YES; __block NSSet *allContactRecipientIds; __block NSSet *recipientIdsForIntersection; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { // Contact updates initiated by the user should always do a full intersection. if (!isUserRequested) { NSDate *_Nullable nextFullIntersectionDate = [transaction dateForKey:OWSContactsManagerKeyNextFullIntersectionDate inCollection:OWSContactsManagerCollection]; if (nextFullIntersectionDate && [nextFullIntersectionDate isAfterNow]) { isFullIntersection = NO; } } allContactRecipientIds = [self recipientIdsForIntersectionWithContacts:contacts]; recipientIdsForIntersection = allContactRecipientIds; if (!isFullIntersection) { // Do a "delta" intersection instead of a "full" intersection: // only intersect new contacts which were not in the last successful // "full" intersection. NSSet *_Nullable lastKnownContactPhoneNumbers = [transaction objectForKey:OWSContactsManagerKeyLastKnownContactPhoneNumbers inCollection:OWSContactsManagerCollection]; if (lastKnownContactPhoneNumbers) { // Do a "delta" sync which only intersects recipient ids not included // in the last full intersection. NSMutableSet *newRecipientIds = [allContactRecipientIds mutableCopy]; [newRecipientIds minusSet:lastKnownContactPhoneNumbers]; recipientIdsForIntersection = newRecipientIds; } else { // Without a list of "last known" contact phone numbers, we'll have to do a full intersection. isFullIntersection = YES; } } }]; OWSAssertDebug(recipientIdsForIntersection); if (recipientIdsForIntersection.count < 1) { OWSLogInfo(@"Skipping intersection; no contacts to intersect."); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ completion(nil); }); return; } else if (isFullIntersection) { OWSLogInfo(@"Doing full intersection with %zu contacts.", recipientIdsForIntersection.count); } else { OWSLogInfo(@"Doing delta intersection with %zu contacts.", recipientIdsForIntersection.count); } [self intersectContacts:recipientIdsForIntersection retryDelaySeconds:1.0 success:^(NSSet *registeredRecipients) { [self markIntersectionAsComplete:allContactRecipientIds isFullIntersection:isFullIntersection]; completion(nil); } failure:^(NSError *error) { completion(error); }]; }); } - (void)markIntersectionAsComplete:(NSSet *)recipientIdsForIntersection isFullIntersection:(BOOL)isFullIntersection { OWSAssertDebug(recipientIdsForIntersection.count > 0); dispatch_async(self.serialQueue, ^{ [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [transaction setObject:recipientIdsForIntersection forKey:OWSContactsManagerKeyLastKnownContactPhoneNumbers inCollection:OWSContactsManagerCollection]; if (isFullIntersection) { // Don't do a full intersection more often than once every 6 hours. const NSTimeInterval kMinFullIntersectionInterval = 6 * kHourInterval; NSDate *nextFullIntersectionDate = [NSDate dateWithTimeIntervalSince1970:[NSDate new].timeIntervalSince1970 + kMinFullIntersectionInterval]; [transaction setDate:nextFullIntersectionDate forKey:OWSContactsManagerKeyNextFullIntersectionDate inCollection:OWSContactsManagerCollection]; } }]; }); } - (void)intersectContacts:(NSSet *)recipientIds retryDelaySeconds:(double)retryDelaySeconds success:(void (^)(NSSet *))successParameter failure:(void (^)(NSError *))failureParameter { OWSAssertDebug(recipientIds.count > 0); OWSAssertDebug(retryDelaySeconds > 0); OWSAssertDebug(successParameter); OWSAssertDebug(failureParameter); void (^success)(NSArray *) = ^(NSArray *registeredRecipientIds) { OWSLogInfo(@"Successfully intersected contacts."); successParameter([NSSet setWithArray:registeredRecipientIds]); }; void (^failure)(NSError *) = ^(NSError *error) { if ([error.domain isEqualToString:OWSSignalServiceKitErrorDomain] && error.code == OWSErrorCodeContactsUpdaterRateLimit) { OWSLogError(@"Contact intersection hit rate limit with error: %@", error); failureParameter(error); return; } OWSLogWarn(@"Failed to intersect contacts with error: %@. Rescheduling", error); // Retry with exponential backoff. // // TODO: Abort if another contact intersection succeeds in the meantime. dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryDelaySeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self intersectContacts:recipientIds retryDelaySeconds:retryDelaySeconds * 2.0 success:successParameter failure:failureParameter]; }); }; [[ContactsUpdater sharedUpdater] lookupIdentifiers:recipientIds.allObjects success:success failure:failure]; } - (void)startObserving { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileWillChange:) name:kNSNotificationName_OtherUsersProfileWillChange object:nil]; } - (void)otherUsersProfileWillChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); [AppReadiness runNowOrWhenAppDidBecomeReady:^{ NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; OWSAssertDebug(recipientId.length > 0); [self.avatarCache removeAllImagesForKey:recipientId]; }]; } - (void)updateWithContacts:(NSArray *)contacts isUserRequested:(BOOL)isUserRequested shouldClearStaleCache:(BOOL)shouldClearStaleCache { dispatch_async(self.serialQueue, ^{ NSMutableDictionary *allContactsMap = [NSMutableDictionary new]; for (Contact *contact in contacts) { for (PhoneNumber *phoneNumber in contact.parsedPhoneNumbers) { NSString *phoneNumberE164 = phoneNumber.toE164; if (phoneNumberE164.length > 0) { allContactsMap[phoneNumberE164] = contact; } } } dispatch_async(dispatch_get_main_queue(), ^{ self.allContacts = contacts; self.allContactsMap = [allContactsMap copy]; [self.cnContactCache removeAllObjects]; [self.cnContactAvatarCache removeAllObjects]; [self.avatarCache removeAllImages]; [self intersectContacts:contacts isUserRequested:isUserRequested completion:^(NSError *_Nullable error) { // TODO: Should we do this on error? [self buildSignalAccountsAndClearStaleCache:shouldClearStaleCache]; }]; }); }); } - (void)buildSignalAccountsAndClearStaleCache:(BOOL)shouldClearStaleCache { dispatch_async(self.serialQueue, ^{ NSMutableArray *signalAccounts = [NSMutableArray new]; NSArray *contacts = self.allContacts; // We use a transaction only to load the SignalRecipients for each contact, // in order to avoid database deadlock. NSMutableDictionary *> *contactIdToSignalRecipientsMap = [NSMutableDictionary new]; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { for (Contact *contact in contacts) { NSArray *signalRecipients = [contact signalRecipientsWithTransaction:transaction]; contactIdToSignalRecipientsMap[contact.uniqueId] = signalRecipients; } }]; NSMutableSet *seenRecipientIds = [NSMutableSet new]; for (Contact *contact in contacts) { NSArray *signalRecipients = contactIdToSignalRecipientsMap[contact.uniqueId]; for (SignalRecipient *signalRecipient in [signalRecipients sortedArrayUsingSelector:@selector((compare:))]) { if ([seenRecipientIds containsObject:signalRecipient.recipientId]) { OWSLogDebug(@"Ignoring duplicate contact: %@, %@", signalRecipient.recipientId, contact.fullName); continue; } [seenRecipientIds addObject:signalRecipient.recipientId]; SignalAccount *signalAccount = [[SignalAccount alloc] initWithSignalRecipient:signalRecipient]; signalAccount.contact = contact; if (signalRecipients.count > 1) { signalAccount.hasMultipleAccountContact = YES; signalAccount.multipleAccountLabelText = [[self class] accountLabelForContact:contact recipientId:signalRecipient.recipientId]; } [signalAccounts addObject:signalAccount]; } } NSMutableDictionary *oldSignalAccounts = [NSMutableDictionary new]; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [SignalAccount enumerateCollectionObjectsWithTransaction:transaction usingBlock:^(id _Nonnull object, BOOL *_Nonnull stop) { OWSAssertDebug([object isKindOfClass:[SignalAccount class]]); SignalAccount *oldSignalAccount = (SignalAccount *)object; oldSignalAccounts[oldSignalAccount.uniqueId] = oldSignalAccount; }]; }]; NSMutableArray *accountsToSave = [NSMutableArray new]; for (SignalAccount *signalAccount in signalAccounts) { SignalAccount *_Nullable oldSignalAccount = oldSignalAccounts[signalAccount.uniqueId]; // keep track of which accounts are still relevant, so we can clean up orphans [oldSignalAccounts removeObjectForKey:signalAccount.uniqueId]; if (oldSignalAccount == nil) { // new Signal Account [accountsToSave addObject:signalAccount]; continue; } if ([oldSignalAccount isEqual:signalAccount]) { // Same value, no need to save. continue; } // value changed, save account [accountsToSave addObject:signalAccount]; } // Update cached SignalAccounts on disk [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { OWSLogInfo(@"Saving %lu SignalAccounts", (unsigned long)accountsToSave.count); for (SignalAccount *signalAccount in accountsToSave) { OWSLogVerbose(@"Saving SignalAccount: %@", signalAccount); [signalAccount saveWithTransaction:transaction]; } if (shouldClearStaleCache) { OWSLogInfo(@"Removing %lu old SignalAccounts.", (unsigned long)oldSignalAccounts.count); for (SignalAccount *signalAccount in oldSignalAccounts.allValues) { OWSLogVerbose(@"Removing old SignalAccount: %@", signalAccount); [signalAccount removeWithTransaction:transaction]; } } else { // In theory we want to remove SignalAccounts if the user deletes the corresponding system contact. // However, as of iOS11.2 CNContactStore occasionally gives us only a subset of the system contacts. // Because of that, it's not safe to clear orphaned accounts. // Because we still want to give users a way to clear their stale accounts, if they pull-to-refresh // their contacts we'll clear the cached ones. // RADAR: https://bugreport.apple.com/web/?problemID=36082946 if (oldSignalAccounts.allValues.count > 0) { OWSLogWarn(@"NOT Removing %lu old SignalAccounts.", (unsigned long)oldSignalAccounts.count); for (SignalAccount *signalAccount in oldSignalAccounts.allValues) { OWSLogVerbose(@"Ensuring old SignalAccount is not inadvertently lost: %@", signalAccount); [signalAccounts addObject:signalAccount]; } // re-sort signal accounts since we've appended some orphans [signalAccounts sortUsingComparator:self.signalAccountComparator]; } } }]; dispatch_async(dispatch_get_main_queue(), ^{ [self updateSignalAccounts:signalAccounts]; }); }); } - (void)updateSignalAccounts:(NSArray *)signalAccounts { OWSAssertIsOnMainThread(); if ([signalAccounts isEqual:self.signalAccounts]) { OWSLogDebug(@"SignalAccounts unchanged."); return; } NSMutableDictionary *signalAccountMap = [NSMutableDictionary new]; for (SignalAccount *signalAccount in signalAccounts) { signalAccountMap[signalAccount.recipientId] = signalAccount; } self.signalAccountMap = [signalAccountMap copy]; self.signalAccounts = [signalAccounts copy]; [self.profileManager setContactRecipientIds:signalAccountMap.allKeys]; self.isSetup = YES; [[NSNotificationCenter defaultCenter] postNotificationNameAsync:OWSContactsManagerSignalAccountsDidChangeNotification object:nil]; } // TODO dependency inject, avoid circular dependencies. - (OWSProfileManager *)profileManager { return [OWSProfileManager sharedManager]; } - (NSString *_Nullable)cachedContactNameForRecipientId:(NSString *)recipientId { SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; return [self cachedContactNameForRecipientId:recipientId signalAccount:signalAccount]; } - (NSString *_Nullable)cachedContactNameForRecipientId:(NSString *)recipientId transaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(transaction); SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId transaction:transaction]; return [self cachedContactNameForRecipientId:recipientId signalAccount:signalAccount]; } - (NSString *_Nullable)cachedContactNameForRecipientId:(NSString *)recipientId signalAccount:(nullable SignalAccount *)signalAccount { OWSAssertDebug(recipientId.length > 0); if (!signalAccount) { // search system contacts for no-longer-registered signal users, for which there will be no SignalAccount Contact *_Nullable nonSignalContact = self.allContactsMap[recipientId]; if (!nonSignalContact) { return nil; } return nonSignalContact.fullName; } NSString *fullName = signalAccount.contactFullName; if (fullName.length == 0) { return nil; } NSString *multipleAccountLabelText = signalAccount.multipleAccountLabelText; if (multipleAccountLabelText.length == 0) { return fullName; } return [NSString stringWithFormat:@"%@ (%@)", fullName, multipleAccountLabelText]; } - (NSString *_Nullable)cachedFirstNameForRecipientId:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; return signalAccount.contact.firstName.filterStringForDisplay; } - (NSString *_Nullable)cachedLastNameForRecipientId:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; return signalAccount.contact.lastName.filterStringForDisplay; } #pragma mark - View Helpers // TODO move into Contact class. + (NSString *)accountLabelForContact:(Contact *)contact recipientId:(NSString *)recipientId { OWSAssertDebug(contact); OWSAssertDebug(recipientId.length > 0); OWSAssertDebug([contact.textSecureIdentifiers containsObject:recipientId]); if (contact.textSecureIdentifiers.count <= 1) { return nil; } // 1. Find the phone number type of this account. NSString *phoneNumberLabel = [contact nameForPhoneNumber:recipientId]; // 2. Find all phone numbers for this contact of the same type. NSMutableArray *phoneNumbersWithTheSameName = [NSMutableArray new]; for (NSString *textSecureIdentifier in contact.textSecureIdentifiers) { if ([phoneNumberLabel isEqualToString:[contact nameForPhoneNumber:textSecureIdentifier]]) { [phoneNumbersWithTheSameName addObject:textSecureIdentifier]; } } OWSAssertDebug([phoneNumbersWithTheSameName containsObject:recipientId]); if (phoneNumbersWithTheSameName.count > 1) { NSUInteger index = [[phoneNumbersWithTheSameName sortedArrayUsingSelector:@selector((compare:))] indexOfObject:recipientId]; NSString *indexText = [OWSFormat formatInt:(int)index + 1]; phoneNumberLabel = [NSString stringWithFormat:NSLocalizedString(@"PHONE_NUMBER_TYPE_AND_INDEX_NAME_FORMAT", @"Format for phone number label with an index. Embeds {{Phone number label " @"(e.g. 'home')}} and {{index, e.g. 2}}."), phoneNumberLabel, indexText]; } return phoneNumberLabel.filterStringForDisplay; } - (BOOL)phoneNumber:(PhoneNumber *)phoneNumber1 matchesNumber:(PhoneNumber *)phoneNumber2 { return [phoneNumber1.toE164 isEqualToString:phoneNumber2.toE164]; } #pragma mark - Whisper User Management - (BOOL)isSystemContact:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); return self.allContactsMap[recipientId] != nil; } - (BOOL)isSystemContactWithSignalAccount:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); return [self hasSignalAccountForRecipientId:recipientId]; } - (BOOL)hasNameInSystemContactsForRecipientId:(NSString *)recipientId { return [self cachedContactNameForRecipientId:recipientId].length > 0; } - (NSString *)unknownContactName { return NSLocalizedString( @"UNKNOWN_CONTACT_NAME", @"Displayed if for some reason we can't determine a contacts phone number *or* name"); } - (nullable NSString *)formattedProfileNameForRecipientId:(NSString *)recipientId { NSString *_Nullable profileName = [self.profileManager profileNameForRecipientWithID:recipientId]; if (profileName.length == 0) { return nil; } NSString *profileNameFormatString = NSLocalizedString(@"PROFILE_NAME_LABEL_FORMAT", @"Prepend a simple marker to differentiate the profile name, embeds the contact's {{profile name}}."); return profileName; } - (nullable NSString *)profileNameForRecipientId:(NSString *)recipientId { return [self.profileManager profileNameForRecipientWithID:recipientId]; } - (nullable NSString *)nameFromSystemContactsForRecipientId:(NSString *)recipientId { return [self cachedContactNameForRecipientId:recipientId]; } - (nullable NSString *)nameFromSystemContactsForRecipientId:(NSString *)recipientId transaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(transaction); return [self cachedContactNameForRecipientId:recipientId transaction:transaction]; } - (NSString *)displayNameForPhoneIdentifier:(NSString *_Nullable)recipientId { OWSAssertDebug(recipientId.length > 0); if (!recipientId) { return self.unknownContactName; } NSString *_Nullable displayName = [self nameFromSystemContactsForRecipientId:recipientId]; // Fall back to just using their recipientId if (displayName.length < 1) { displayName = recipientId; } return displayName; } - (NSString *)displayNameForPhoneIdentifier:(NSString *_Nullable)recipientId transaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(transaction); if (!recipientId) { return self.unknownContactName; } NSString *_Nullable displayName = [self nameFromSystemContactsForRecipientId:recipientId transaction:transaction]; // Fall back to just using their recipientId if (displayName.length < 1) { displayName = recipientId; } return displayName; } - (NSString *_Nonnull)displayNameForSignalAccount:(SignalAccount *)signalAccount { OWSAssertDebug(signalAccount); return [self displayNameForPhoneIdentifier:signalAccount.recipientId]; } - (NSAttributedString *_Nonnull)formattedDisplayNameForSignalAccount:(SignalAccount *)signalAccount font:(UIFont *)font { OWSAssertDebug(signalAccount); OWSAssertDebug(font); return [self formattedFullNameForRecipientId:signalAccount.recipientId font:font]; } - (NSAttributedString *)formattedFullNameForRecipientId:(NSString *)recipientId font:(UIFont *)font { OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(font); UIFont *boldFont = [UIFont ows_mediumFontWithSize:font.pointSize]; NSDictionary *boldFontAttributes = @{ NSFontAttributeName : boldFont, NSForegroundColorAttributeName : [Theme boldColor] }; NSDictionary *normalFontAttributes = @{ NSFontAttributeName : font, NSForegroundColorAttributeName : [Theme primaryColor] }; NSDictionary *firstNameAttributes = (self.shouldSortByGivenName ? boldFontAttributes : normalFontAttributes); NSDictionary *lastNameAttributes = (self.shouldSortByGivenName ? normalFontAttributes : boldFontAttributes); NSString *cachedFirstName = [self cachedFirstNameForRecipientId:recipientId]; NSString *cachedLastName = [self cachedLastNameForRecipientId:recipientId]; NSMutableAttributedString *formattedName = [NSMutableAttributedString new]; if (cachedFirstName.length > 0 && cachedLastName.length > 0) { NSAttributedString *firstName = [[NSAttributedString alloc] initWithString:cachedFirstName attributes:firstNameAttributes]; NSAttributedString *lastName = [[NSAttributedString alloc] initWithString:cachedLastName attributes:lastNameAttributes]; NSString *_Nullable cnContactId = self.allContactsMap[recipientId].cnContactId; CNContact *_Nullable cnContact = [self cnContactWithId:cnContactId]; if (!cnContact) { // If we don't have a CNContact for this recipient id, make one. // Presumably [CNContactFormatter nameOrderForContact:] tries // to localizes its result based on the languages/scripts used // in the contact's fields. CNMutableContact *formatContact = [CNMutableContact new]; formatContact.givenName = firstName.string; formatContact.familyName = lastName.string; cnContact = formatContact; } CNContactDisplayNameOrder nameOrder = [CNContactFormatter nameOrderForContact:cnContact]; NSAttributedString *_Nullable leftName, *_Nullable rightName; if (nameOrder == CNContactDisplayNameOrderGivenNameFirst) { leftName = firstName; rightName = lastName; } else { leftName = lastName; rightName = firstName; } [formattedName appendAttributedString:leftName]; [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:@" " attributes:normalFontAttributes]]; [formattedName appendAttributedString:rightName]; } else if (cachedFirstName.length > 0) { [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:cachedFirstName attributes:firstNameAttributes]]; } else if (cachedLastName.length > 0) { [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:cachedLastName attributes:lastNameAttributes]]; } else { // Else, fall back to using just their recipientId NSString *phoneString = [PhoneNumber bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber:recipientId]; return [[NSAttributedString alloc] initWithString:phoneString attributes:normalFontAttributes]; } // Append unique label for contacts with multiple Signal accounts SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; if (signalAccount && signalAccount.multipleAccountLabelText) { OWSAssertDebug(signalAccount.multipleAccountLabelText.length > 0); [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:@" (" attributes:normalFontAttributes]]; [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:signalAccount.multipleAccountLabelText attributes:normalFontAttributes]]; [formattedName appendAttributedString:[[NSAttributedString alloc] initWithString:@")" attributes:normalFontAttributes]]; } return formattedName; } - (NSString *)contactOrProfileNameForPhoneIdentifier:(NSString *)recipientId { // Prefer a saved name from system contacts, if available NSString *_Nullable savedContactName = [self cachedContactNameForRecipientId:recipientId]; if (savedContactName.length > 0) { return savedContactName; } NSString *_Nullable profileName = [self.profileManager profileNameForRecipientWithID:recipientId]; if (profileName.length > 0) { NSString *numberAndProfileNameFormat = NSLocalizedString(@"PROFILE_NAME_AND_PHONE_NUMBER_LABEL_FORMAT", @"Label text combining the phone number and profile name separated by a simple demarcation character. " @"Phone number should be most prominent. '%1$@' is replaced with {{phone number}} and '%2$@' is replaced " @"with {{profile name}}"); NSString *numberAndProfileName = [NSString stringWithFormat:numberAndProfileNameFormat, recipientId, profileName]; return numberAndProfileName; } // else fall back to recipient id return recipientId; } - (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId { return [[NSAttributedString alloc] initWithString:[self contactOrProfileNameForPhoneIdentifier:recipientId]]; } - (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId primaryFont:(UIFont *)primaryFont secondaryFont:(UIFont *)secondaryFont { OWSAssertDebug(primaryFont); OWSAssertDebug(secondaryFont); return [self attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId primaryAttributes:@{ NSFontAttributeName : primaryFont, } secondaryAttributes:@{ NSFontAttributeName : secondaryFont, }]; } - (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId primaryAttributes:(NSDictionary *)primaryAttributes secondaryAttributes:(NSDictionary *)secondaryAttributes { OWSAssertDebug(primaryAttributes.count > 0); OWSAssertDebug(secondaryAttributes.count > 0); // Prefer a saved name from system contacts, if available NSString *_Nullable savedContactName = [self cachedContactNameForRecipientId:recipientId]; if (savedContactName.length > 0) { return [[NSAttributedString alloc] initWithString:savedContactName attributes:primaryAttributes]; } NSString *_Nullable profileName = [self.profileManager profileNameForRecipientWithID:recipientId]; if (profileName.length > 0) { return [[NSAttributedString alloc] initWithString:profileName]; // Loki: Original code // ======== // NSAttributedString *result = // [[NSAttributedString alloc] initWithString:recipientId attributes:primaryAttributes]; // result = [result rtlSafeAppend:[[NSAttributedString alloc] initWithString:@" "]]; // result = [result rtlSafeAppend:[[NSAttributedString alloc] initWithString:@"~" attributes:secondaryAttributes]]; // result = [result // rtlSafeAppend:[[NSAttributedString alloc] initWithString:profileName attributes:secondaryAttributes]]; // return [result copy]; // ======== } // else fall back to recipient id return [[NSAttributedString alloc] initWithString:recipientId attributes:primaryAttributes]; } // TODO refactor attributed counterparts to use this as a helper method? - (NSString *)stringForConversationTitleWithPhoneIdentifier:(NSString *)recipientId { // Prefer a saved name from system contacts, if available NSString *_Nullable savedContactName = [self cachedContactNameForRecipientId:recipientId]; if (savedContactName.length > 0) { return savedContactName; } NSString *formattedPhoneNumber = [PhoneNumber bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber:recipientId]; NSString *_Nullable profileName = [self.profileManager profileNameForRecipientWithID:recipientId]; if (profileName.length > 0) { NSString *numberAndProfileNameFormat = NSLocalizedString(@"PROFILE_NAME_AND_PHONE_NUMBER_LABEL_FORMAT", @"Label text combining the phone number and profile name separated by a simple demarcation character. " @"Phone number should be most prominent. '%1$@' is replaced with {{phone number}} and '%2$@' is replaced " @"with {{profile name}}"); NSString *numberAndProfileName = [NSString stringWithFormat:numberAndProfileNameFormat, formattedPhoneNumber, profileName]; return numberAndProfileName; } // else fall back phone number return formattedPhoneNumber; } - (nullable SignalAccount *)fetchSignalAccountForRecipientId:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); __block SignalAccount *signalAccount = self.signalAccountMap[recipientId]; // If contact intersection hasn't completed, it might exist on disk // even if it doesn't exist in memory yet. if (!signalAccount) { [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { signalAccount = [SignalAccount fetchObjectWithUniqueID:recipientId transaction:transaction]; }]; } return signalAccount; } - (nullable SignalAccount *)fetchSignalAccountForRecipientId:(NSString *)recipientId transaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(transaction); __block SignalAccount *signalAccount = self.signalAccountMap[recipientId]; // If contact intersection hasn't completed, it might exist on disk // even if it doesn't exist in memory yet. if (!signalAccount) { signalAccount = [SignalAccount fetchObjectWithUniqueID:recipientId transaction:transaction]; } return signalAccount; } - (SignalAccount *)fetchOrBuildSignalAccountForRecipientId:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; return (signalAccount ?: [[SignalAccount alloc] initWithRecipientId:recipientId]); } - (BOOL)hasSignalAccountForRecipientId:(NSString *)recipientId { return [self fetchSignalAccountForRecipientId:recipientId] != nil; } - (UIImage *_Nullable)systemContactImageForPhoneIdentifier:(NSString *_Nullable)identifier { if (identifier.length == 0) { return nil; } Contact *contact = self.allContactsMap[identifier]; if (!contact) { // If we haven't loaded system contacts yet, we may have a cached // copy in the db contact = [self fetchSignalAccountForRecipientId:identifier].contact; } return [self avatarImageForCNContactId:contact.cnContactId]; } - (nullable UIImage *)profileImageForPhoneIdentifier:(nullable NSString *)identifier { if (identifier.length == 0) { return nil; } return [self.profileManager profileAvatarForRecipientId:identifier]; } - (nullable NSData *)profileImageDataForPhoneIdentifier:(nullable NSString *)identifier { if (identifier.length == 0) { return nil; } return [self.profileManager profileAvatarDataForRecipientId:identifier]; } - (UIImage *_Nullable)imageForPhoneIdentifier:(NSString *_Nullable)identifier { if (identifier.length == 0) { return nil; } // Prefer the contact image from the local address book if available UIImage *_Nullable image = [self systemContactImageForPhoneIdentifier:identifier]; // Else try to use the image from their profile if (image == nil) { image = [self profileImageForPhoneIdentifier:identifier]; } return image; } - (NSComparisonResult)compareSignalAccount:(SignalAccount *)left withSignalAccount:(SignalAccount *)right { return self.signalAccountComparator(left, right); } - (NSComparisonResult (^)(SignalAccount *left, SignalAccount *right))signalAccountComparator { return ^NSComparisonResult(SignalAccount *left, SignalAccount *right) { NSString *leftName = [self comparableNameForSignalAccount:left]; NSString *rightName = [self comparableNameForSignalAccount:right]; NSComparisonResult nameComparison = [leftName caseInsensitiveCompare:rightName]; if (nameComparison == NSOrderedSame) { return [left.recipientId compare:right.recipientId]; } return nameComparison; }; } - (BOOL)shouldSortByGivenName { return [[CNContactsUserDefaults sharedDefaults] sortOrder] == CNContactSortOrderGivenName; } - (NSString *)comparableNameForSignalAccount:(SignalAccount *)signalAccount { NSString *_Nullable name; if (signalAccount.contact) { if (self.shouldSortByGivenName) { name = signalAccount.contact.comparableNameFirstLast; } else { name = signalAccount.contact.comparableNameLastFirst; } } if (name.length < 1) { name = signalAccount.recipientId; } return name; } NS_ASSUME_NONNULL_END @end