// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "ContactsViewHelper.h" #import "Environment.h" #import "UIUtil.h" #import #import #import #import #import #import #import #import #import @import ContactsUI; NS_ASSUME_NONNULL_BEGIN @interface ContactsViewHelper () // This property is a cached value that is lazy-populated. @property (nonatomic, nullable) NSArray *nonSignalContacts; @property (nonatomic) NSDictionary *signalAccountMap; @property (nonatomic) NSArray *signalAccounts; @property (nonatomic, readonly) OWSBlockListCache *blockListCache; @property (nonatomic) BOOL shouldNotifyDelegateOfUpdatedContacts; @property (nonatomic) BOOL hasUpdatedContactsAtLeastOnce; @property (nonatomic) OWSProfileManager *profileManager; @property (nonatomic, readonly) FullTextSearcher *fullTextSearcher; @end #pragma mark - @implementation ContactsViewHelper - (instancetype)initWithDelegate:(id)delegate { self = [super init]; if (!self) { return self; } OWSAssertDebug(delegate); _delegate = delegate; _blockingManager = [OWSBlockingManager sharedManager]; _blockListCache = [OWSBlockListCache new]; [_blockListCache startObservingAndSyncStateWithDelegate:self]; _fullTextSearcher = FullTextSearcher.shared; _contactsManager = Environment.shared.contactsManager; _profileManager = [OWSProfileManager sharedManager]; // We don't want to notify the delegate in the `updateContacts`. // self.shouldNotifyDelegateOfUpdatedContacts = YES; [self updateContacts]; self.shouldNotifyDelegateOfUpdatedContacts = NO; [self observeNotifications]; return self; } - (void)observeNotifications { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(signalAccountsDidChange:) name:OWSContactsManagerSignalAccountsDidChangeNotification object:nil]; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)signalAccountsDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); [self updateContacts]; } #pragma mark - Contacts - (nullable SignalAccount *)fetchSignalAccountForRecipientId:(NSString *)recipientId { OWSAssertIsOnMainThread(); OWSAssertDebug(recipientId.length > 0); return self.signalAccountMap[recipientId]; } - (SignalAccount *)fetchOrBuildSignalAccountForRecipientId:(NSString *)recipientId { OWSAssertDebug(recipientId.length > 0); SignalAccount *_Nullable signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; return (signalAccount ?: [[SignalAccount alloc] initWithRecipientId:recipientId]); } - (BOOL)isSignalAccountHidden:(SignalAccount *)signalAccount { OWSAssertIsOnMainThread(); if ([self.delegate respondsToSelector:@selector(shouldHideLocalNumber)] && [self.delegate shouldHideLocalNumber] && [self isCurrentUser:signalAccount]) { return YES; } return NO; } - (BOOL)isCurrentUser:(SignalAccount *)signalAccount { OWSAssertIsOnMainThread(); NSString *localNumber = [TSAccountManager localNumber]; if ([signalAccount.recipientId isEqualToString:localNumber]) { return YES; } for (PhoneNumber *phoneNumber in signalAccount.contact.parsedPhoneNumbers) { if ([[phoneNumber toE164] isEqualToString:localNumber]) { return YES; } } return NO; } - (NSString *)localNumber { return [TSAccountManager localNumber]; } - (BOOL)isRecipientIdBlocked:(NSString *)recipientId { OWSAssertIsOnMainThread(); return [self.blockListCache isRecipientIdBlocked:recipientId]; } - (BOOL)isGroupIdBlocked:(NSData *)groupId { OWSAssertIsOnMainThread(); return [self.blockListCache isGroupIdBlocked:groupId]; } - (BOOL)isThreadBlocked:(TSThread *)thread { if ([thread isKindOfClass:[TSContactThread class]]) { TSContactThread *contactThread = (TSContactThread *)thread; return [self isRecipientIdBlocked:contactThread.contactIdentifier]; } else if ([thread isKindOfClass:[TSGroupThread class]]) { TSGroupThread *groupThread = (TSGroupThread *)thread; return [self isGroupIdBlocked:groupThread.groupModel.groupId]; } else { OWSFailDebug(@"%@ failure: unexpected thread: %@", self.logTag, thread.class); return NO; } } - (void)updateContacts { OWSAssertIsOnMainThread(); NSMutableDictionary *signalAccountMap = [NSMutableDictionary new]; NSMutableArray *signalAccounts = [NSMutableArray new]; for (SignalAccount *signalAccount in self.contactsManager.signalAccounts) { if (![self isSignalAccountHidden:signalAccount]) { signalAccountMap[signalAccount.recipientId] = signalAccount; [signalAccounts addObject:signalAccount]; } } self.signalAccountMap = [signalAccountMap copy]; self.signalAccounts = [signalAccounts copy]; self.nonSignalContacts = nil; // Don't fire delegate "change" events during initialization. if (self.shouldNotifyDelegateOfUpdatedContacts) { [self.delegate contactsViewHelperDidUpdateContacts]; self.hasUpdatedContactsAtLeastOnce = YES; } } - (NSArray *)searchTermsForSearchString:(NSString *)searchText { return [[[searchText ows_stripped] componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *_Nullable searchTerm, NSDictionary *_Nullable bindings) { return searchTerm.length > 0; }]]; } - (NSArray *)signalAccountsMatchingSearchString:(NSString *)searchText { // Check for matches against "Note to Self". NSMutableArray *signalAccountsToSearch = [self.signalAccounts mutableCopy]; SignalAccount *selfAccount = [[SignalAccount alloc] initWithRecipientId:self.localNumber]; [signalAccountsToSearch addObject:selfAccount]; return [self.fullTextSearcher filterSignalAccounts:signalAccountsToSearch withSearchText:searchText]; } - (BOOL)doesContact:(Contact *)contact matchSearchTerm:(NSString *)searchTerm { OWSAssertDebug(contact); OWSAssertDebug(searchTerm.length > 0); if ([contact.fullName.lowercaseString containsString:searchTerm.lowercaseString]) { return YES; } NSString *asPhoneNumber = [PhoneNumber removeFormattingCharacters:searchTerm]; if (asPhoneNumber.length > 0) { for (PhoneNumber *phoneNumber in contact.parsedPhoneNumbers) { if ([phoneNumber.toE164 containsString:asPhoneNumber]) { return YES; } } } return NO; } - (BOOL)doesContact:(Contact *)contact matchSearchTerms:(NSArray *)searchTerms { OWSAssertDebug(contact); OWSAssertDebug(searchTerms.count > 0); for (NSString *searchTerm in searchTerms) { if (![self doesContact:contact matchSearchTerm:searchTerm]) { return NO; } } return YES; } - (NSArray *)nonSignalContactsMatchingSearchString:(NSString *)searchText { NSArray *searchTerms = [self searchTermsForSearchString:searchText]; if (searchTerms.count < 1) { return [NSArray new]; } return [self.nonSignalContacts filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(Contact *contact, NSDictionary *_Nullable bindings) { return [self doesContact:contact matchSearchTerms:searchTerms]; }]]; } - (void)warmNonSignalContactsCacheAsync { OWSAssertIsOnMainThread(); if (self.nonSignalContacts != nil) { return; } NSMutableSet *nonSignalContactSet = [NSMutableSet new]; __block NSArray *nonSignalContacts; [OWSPrimaryStorage.dbReadConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) { for (Contact *contact in self.contactsManager.allContactsMap.allValues) { NSArray *signalRecipients = [contact signalRecipientsWithTransaction:transaction]; if (signalRecipients.count < 1) { [nonSignalContactSet addObject:contact]; } } nonSignalContacts = [nonSignalContactSet.allObjects sortedArrayUsingComparator:^NSComparisonResult(Contact *_Nonnull left, Contact *_Nonnull right) { return [left.fullName compare:right.fullName]; }]; } completionBlock:^{ self.nonSignalContacts = nonSignalContacts; }]; } - (nullable NSArray *)nonSignalContacts { OWSAssertIsOnMainThread(); if (!_nonSignalContacts) { NSMutableSet *nonSignalContacts = [NSMutableSet new]; [OWSPrimaryStorage.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { for (Contact *contact in self.contactsManager.allContactsMap.allValues) { NSArray *signalRecipients = [contact signalRecipientsWithTransaction:transaction]; if (signalRecipients.count < 1) { [nonSignalContacts addObject:contact]; } } }]; _nonSignalContacts = [nonSignalContacts.allObjects sortedArrayUsingComparator:^NSComparisonResult(Contact *_Nonnull left, Contact *_Nonnull right) { return [left.fullName compare:right.fullName]; }]; } return _nonSignalContacts; } #pragma mark - Editing - (void)presentMissingContactAccessAlertControllerFromViewController:(UIViewController *)viewController { [ContactsViewHelper presentMissingContactAccessAlertControllerFromViewController:viewController]; } + (void)presentMissingContactAccessAlertControllerFromViewController:(UIViewController *)viewController { UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"EDIT_CONTACT_WITHOUT_CONTACTS_PERMISSION_ALERT_TITLE", comment : @"Alert title for when the user has just tried to edit a " @"contacts after declining to give Signal contacts " @"permissions") message:NSLocalizedString(@"EDIT_CONTACT_WITHOUT_CONTACTS_PERMISSION_ALERT_BODY", comment : @"Alert body for when the user has just tried to edit a " @"contacts after declining to give Signal contacts " @"permissions") preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"AB_PERMISSION_MISSING_ACTION_NOT_NOW", @"Button text to dismiss missing contacts permission alert") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"not_now") style:UIAlertActionStyleCancel handler:nil]]; UIAlertAction *_Nullable openSystemSettingsAction = CurrentAppContext().openSystemSettingsAction; if (openSystemSettingsAction) { [alert addAction:openSystemSettingsAction]; } [viewController presentAlert:alert]; } - (void)presentContactViewControllerForRecipientId:(NSString *)recipientId fromViewController:(UIViewController *)fromViewController editImmediately:(BOOL)shouldEditImmediately { return; /** [self presentContactViewControllerForRecipientId:recipientId fromViewController:fromViewController editImmediately:shouldEditImmediately addToExistingCnContact:nil]; */ } - (void)presentContactViewControllerForRecipientId:(NSString *)recipientId fromViewController:(UIViewController *)fromViewController editImmediately:(BOOL)shouldEditImmediately addToExistingCnContact:(CNContact *_Nullable)existingContact { SignalAccount *signalAccount = [self fetchSignalAccountForRecipientId:recipientId]; if (!self.contactsManager.supportsContactEditing) { // Should not expose UI that lets the user get here. OWSFailDebug(@"Contact editing not supported."); return; } if (!self.contactsManager.isSystemContactsAuthorized) { [self presentMissingContactAccessAlertControllerFromViewController:fromViewController]; return; } CNContactViewController *_Nullable contactViewController; CNContact *_Nullable cnContact = nil; if (existingContact) { CNMutableContact *updatedContact = [existingContact mutableCopy]; NSMutableArray *phoneNumbers = (updatedContact.phoneNumbers ? [updatedContact.phoneNumbers mutableCopy] : [NSMutableArray new]); // Only add recipientId as a phone number for the existing contact // if its not already present. BOOL hasPhoneNumber = NO; for (CNLabeledValue *existingPhoneNumber in phoneNumbers) { CNPhoneNumber *phoneNumber = existingPhoneNumber.value; if ([phoneNumber.stringValue isEqualToString:recipientId]) { OWSFailDebug(@"We currently only should the 'add to existing contact' UI for phone numbers that don't " @"correspond to an existing user."); hasPhoneNumber = YES; break; } } if (!hasPhoneNumber) { CNPhoneNumber *phoneNumber = [CNPhoneNumber phoneNumberWithStringValue:recipientId]; CNLabeledValue *labeledPhoneNumber = [CNLabeledValue labeledValueWithLabel:CNLabelPhoneNumberMain value:phoneNumber]; [phoneNumbers addObject:labeledPhoneNumber]; updatedContact.phoneNumbers = phoneNumbers; // When adding a phone number to an existing contact, immediately enter // "edit" mode. shouldEditImmediately = YES; } cnContact = updatedContact; } if (signalAccount && !cnContact) { cnContact = [self.contactsManager cnContactWithId:signalAccount.contact.cnContactId]; } if (cnContact) { if (shouldEditImmediately) { // Not actually a "new" contact, but this brings up the edit form rather than the "Read" form // saving our users a tap in some cases when we already know they want to edit. contactViewController = [CNContactViewController viewControllerForNewContact:cnContact]; // Default title is "New Contact". We could give a more descriptive title, but anything // seems redundant - the context is sufficiently clear. contactViewController.title = @""; } else { contactViewController = [CNContactViewController viewControllerForContact:cnContact]; } } if (!contactViewController) { CNMutableContact *newContact = [CNMutableContact new]; CNPhoneNumber *phoneNumber = [CNPhoneNumber phoneNumberWithStringValue:recipientId]; CNLabeledValue *labeledPhoneNumber = [CNLabeledValue labeledValueWithLabel:CNLabelPhoneNumberMain value:phoneNumber]; newContact.phoneNumbers = @[ labeledPhoneNumber ]; newContact.givenName = [self.profileManager profileNameForRecipientId:recipientId]; contactViewController = [CNContactViewController viewControllerForNewContact:newContact]; } contactViewController.delegate = fromViewController; contactViewController.allowsActions = NO; contactViewController.allowsEditing = YES; contactViewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:CommonStrings.cancelButton style:UIBarButtonItemStylePlain target:fromViewController action:@selector(didFinishEditingContact)]; OWSNavigationController *modal = [[OWSNavigationController alloc] initWithRootViewController:contactViewController]; // We want the presentation to imply a "replacement" in this case. if (shouldEditImmediately) { modal.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; } [fromViewController presentViewController:modal animated:YES completion:nil]; } - (void)blockListCacheDidUpdate:(OWSBlockListCache *)blocklistCache { OWSAssertIsOnMainThread(); [self updateContacts]; } @end NS_ASSUME_NONNULL_END