session-ios/SignalMessaging/Views/ContactsViewHelper.m

431 lines
16 KiB
Objective-C

//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ContactsViewHelper.h"
#import "Environment.h"
#import "UIUtil.h"
#import <SignalCoreKit/NSString+SSK.h>
#import <SignalMessaging/OWSProfileManager.h>
#import <SignalMessaging/SignalMessaging-Swift.h>
#import <SignalServiceKit/AppContext.h>
#import <SignalServiceKit/Contact.h>
#import <SignalServiceKit/OWSBlockingManager.h>
#import <SignalServiceKit/PhoneNumber.h>
#import <SignalServiceKit/SignalAccount.h>
#import <SignalServiceKit/TSAccountManager.h>
@import ContactsUI;
NS_ASSUME_NONNULL_BEGIN
@interface ContactsViewHelper () <OWSBlockListCacheDelegate>
// This property is a cached value that is lazy-populated.
@property (nonatomic, nullable) NSArray<Contact *> *nonSignalContacts;
@property (nonatomic) NSDictionary<NSString *, SignalAccount *> *signalAccountMap;
@property (nonatomic) NSArray<SignalAccount *> *signalAccounts;
@property (nonatomic, readonly) OWSBlockListCache *blockListCache;
@property (nonatomic) BOOL shouldNotifyDelegateOfUpdatedContacts;
@property (nonatomic) BOOL hasUpdatedContactsAtLeastOnce;
@property (nonatomic) OWSProfileManager *profileManager;
@property (nonatomic, readonly) ConversationSearcher *conversationSearcher;
@end
#pragma mark -
@implementation ContactsViewHelper
- (instancetype)initWithDelegate:(id<ContactsViewHelperDelegate>)delegate
{
self = [super init];
if (!self) {
return self;
}
OWSAssertDebug(delegate);
_delegate = delegate;
_blockingManager = [OWSBlockingManager sharedManager];
_blockListCache = [OWSBlockListCache new];
[_blockListCache startObservingAndSyncStateWithDelegate:self];
_conversationSearcher = ConversationSearcher.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<NSString *, SignalAccount *> *signalAccountMap = [NSMutableDictionary new];
NSMutableArray<SignalAccount *> *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<NSString *> *)searchTermsForSearchString:(NSString *)searchText
{
return [[[searchText ows_stripped]
componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]
filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *_Nullable searchTerm,
NSDictionary<NSString *, id> *_Nullable bindings) {
return searchTerm.length > 0;
}]];
}
- (NSArray<SignalAccount *> *)signalAccountsMatchingSearchString:(NSString *)searchText
{
return [self.conversationSearcher filterSignalAccounts:self.signalAccounts 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<NSString *> *)searchTerms
{
OWSAssertDebug(contact);
OWSAssertDebug(searchTerms.count > 0);
for (NSString *searchTerm in searchTerms) {
if (![self doesContact:contact matchSearchTerm:searchTerm]) {
return NO;
}
}
return YES;
}
- (NSArray<Contact *> *)nonSignalContactsMatchingSearchString:(NSString *)searchText
{
NSArray<NSString *> *searchTerms = [self searchTermsForSearchString:searchText];
if (searchTerms.count < 1) {
return [NSArray new];
}
return [self.nonSignalContacts filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(Contact *contact,
NSDictionary<NSString *, id> *_Nullable bindings) {
return [self doesContact:contact matchSearchTerms:searchTerms];
}]];
}
- (nullable NSArray<Contact *> *)nonSignalContacts
{
if (!_nonSignalContacts) {
NSMutableSet<Contact *> *nonSignalContacts = [NSMutableSet new];
[OWSPrimaryStorage.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
for (Contact *contact in self.contactsManager.allContactsMap.allValues) {
NSArray<SignalRecipient *> *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 *alertController = [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];
[alertController
addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"AB_PERMISSION_MISSING_ACTION_NOT_NOW",
@"Button text to dismiss missing contacts permission alert")
style:UIAlertActionStyleCancel
handler:nil]];
UIAlertAction *_Nullable openSystemSettingsAction = CurrentAppContext().openSystemSettingsAction;
if (openSystemSettingsAction) {
[alertController addAction:openSystemSettingsAction];
}
[viewController presentViewController:alertController animated:YES completion:nil];
}
- (void)presentContactViewControllerForRecipientId:(NSString *)recipientId
fromViewController:(UIViewController<ContactEditingDelegate> *)fromViewController
editImmediately:(BOOL)shouldEditImmediately
{
[self presentContactViewControllerForRecipientId:recipientId
fromViewController:fromViewController
editImmediately:shouldEditImmediately
addToExistingCnContact:nil];
}
- (void)presentContactViewControllerForRecipientId:(NSString *)recipientId
fromViewController:(UIViewController<ContactEditingDelegate> *)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<CNLabeledValue *> *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<CNPhoneNumber *> *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<CNPhoneNumber *> *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