diff --git a/Signal/src/ViewControllers/ContactsPicker.swift b/Signal/src/ViewControllers/ContactsPicker.swift index 1c8297e45..a1bdcba71 100644 --- a/Signal/src/ViewControllers/ContactsPicker.swift +++ b/Signal/src/ViewControllers/ContactsPicker.swift @@ -145,20 +145,27 @@ open class ContactsPicker: UIViewController, UITableViewDelegate, UITableViewDat func getContacts(onError errorHandler: @escaping (_ error: Error) -> Void) { switch CNContactStore.authorizationStatus(for: CNEntityType.contacts) { case CNAuthorizationStatus.denied, CNAuthorizationStatus.restricted: + let title = NSLocalizedString("INVITE_FLOW_REQUIRES_CONTACT_ACCESS_TITLE", comment: "Alert title when contacts disabled while trying to invite contacts to signal") + let body = NSLocalizedString("INVITE_FLOW_REQUIRES_CONTACT_ACCESS_BODY", comment: "Alert body when contacts disabled while trying to invite contacts to signal") - let title = NSLocalizedString("AB_PERMISSION_MISSING_TITLE", comment: "Alert title when contacts disabled") - let body = NSLocalizedString("ADDRESSBOOK_RESTRICTED_ALERT_BODY", comment: "Alert body when contacts disabled") let alert = UIAlertController(title: title, message: body, preferredStyle: UIAlertControllerStyle.alert) - let dismissText = NSLocalizedString("DISMISS_BUTTON_TEXT", comment:"") + let dismissText = NSLocalizedString("TXT_CANCEL_TITLE", comment:"") - let okAction = UIAlertAction(title: dismissText, style: UIAlertActionStyle.default, handler: { _ in + let cancelAction = UIAlertAction(title: dismissText, style: .cancel, handler: { _ in let error = NSError(domain: "contactsPickerErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "No Contacts Access"]) self.contactsPickerDelegate?.contactsPicker(self, didContactFetchFailed: error) errorHandler(error) self.dismiss(animated: true, completion: nil) }) - alert.addAction(okAction) + alert.addAction(cancelAction) + + let settingsText = NSLocalizedString("OPEN_SETTINGS_BUTTON", comment:"Button text which opens the settings app") + let openSettingsAction = UIAlertAction(title: settingsText, style: .default, handler: { (_) in + UIApplication.shared.openSystemSettings() + }) + alert.addAction(openSettingsAction) + self.present(alert, animated: true, completion: nil) case CNAuthorizationStatus.notDetermined: @@ -208,6 +215,10 @@ open class ContactsPicker: UIViewController, UITableViewDelegate, UITableViewDat open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let dataSource = filteredSections + guard section < dataSource.count else { + return 0 + } + return dataSource[section].count } @@ -274,7 +285,15 @@ open class ContactsPicker: UIViewController, UITableViewDelegate, UITableViewDat open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { let dataSource = filteredSections + guard section < dataSource.count else { + return nil + } + if dataSource[section].count > 0 { + guard section < collation.sectionTitles.count else { + return nil + } + return collation.sectionTitles[section] } else { return nil diff --git a/Signal/src/ViewControllers/MessageComposeTableViewController.m b/Signal/src/ViewControllers/MessageComposeTableViewController.m index 94a95f40a..8381df6fd 100644 --- a/Signal/src/ViewControllers/MessageComposeTableViewController.m +++ b/Signal/src/ViewControllers/MessageComposeTableViewController.m @@ -34,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) OWSTableViewController *tableViewController; @property (nonatomic, readonly) UISearchBar *searchBar; +@property (nonatomic, readonly) NSLayoutConstraint *hideContactsPermissionReminderViewConstraint; // A list of possible phone numbers parsed from the search text as // E164 values. @@ -59,6 +60,18 @@ NS_ASSUME_NONNULL_BEGIN _contactsViewHelper.delegate = self; _nonContactAccountSet = [NSMutableSet set]; + ReminderView *contactsPermissionReminderView = [[ReminderView alloc] + initWithText:NSLocalizedString(@"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION", + @"Multiline label explaining why compose-screen contact picker is empty.") + tapAction:^{ + [[UIApplication sharedApplication] openSystemSettings]; + }]; + [self.view addSubview:contactsPermissionReminderView]; + [contactsPermissionReminderView autoPinWidthToSuperview]; + [contactsPermissionReminderView autoPinEdgeToSuperviewMargin:ALEdgeTop]; + _hideContactsPermissionReminderViewConstraint = + [contactsPermissionReminderView autoSetDimension:ALDimensionHeight toSize:0]; + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop target:self @@ -87,7 +100,8 @@ NS_ASSUME_NONNULL_BEGIN _tableViewController.tableViewStyle = UITableViewStylePlain; [self.view addSubview:self.tableViewController.view]; [_tableViewController.view autoPinWidthToSuperview]; - [_tableViewController.view autoPinEdgeToSuperviewEdge:ALEdgeTop]; + + [_tableViewController.view autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:contactsPermissionReminderView]; [_tableViewController.view autoPinToBottomLayoutGuideOfViewController:self withInset:0]; _tableViewController.tableView.tableHeaderView = searchBar; @@ -101,6 +115,20 @@ NS_ASSUME_NONNULL_BEGIN [self updateTableContents]; } +- (void)showContactsPermissionReminder:(BOOL)isVisible +{ + _hideContactsPermissionReminderViewConstraint.active = !isVisible; +} + +- (void)showSearchBar:(BOOL)isVisible +{ + if (isVisible) { + self.tableViewController.tableView.tableHeaderView = self.searchBar; + } else { + self.tableViewController.tableView.tableHeaderView = nil; + } +} + - (UIView *)createNoSignalContactsView { UIView *view = [UIView new]; @@ -151,7 +179,7 @@ NS_ASSUME_NONNULL_BEGIN UIButton *inviteContactsButton = [UIButton buttonWithType:UIButtonTypeCustom]; [inviteContactsButton setTitle:NSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON", - "Text for button at the top of the contact picker") + "Label for the cell that presents the 'invite contacts' workflow.") forState:UIControlStateNormal]; [inviteContactsButton setTitleColor:[UIColor ows_materialBlueColor] forState:UIControlStateNormal]; [inviteContactsButton.titleLabel setFont:[UIFont ows_regularFontWithSize:17.f]]; @@ -199,7 +227,7 @@ NS_ASSUME_NONNULL_BEGIN [self.navigationController.navigationBar setTranslucent:NO]; - [self showNoContactsModeIfNecessary]; + [self showContactAppropriateViews]; } #pragma mark - Table Contents @@ -240,20 +268,22 @@ NS_ASSUME_NONNULL_BEGIN [weakSelf.navigationController pushViewController:viewController animated:YES]; }]]; - // Invite Contacts - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = [UITableViewCell new]; - cell.textLabel.text = NSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON", - @"Label for the cell that presents the 'invite contacts' workflow."); - cell.textLabel.font = [UIFont ows_regularFontWithSize:18.f]; - cell.textLabel.textColor = [UIColor blackColor]; - cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - return cell; + if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) { + // Invite Contacts + [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ + UITableViewCell *cell = [UITableViewCell new]; + cell.textLabel.text = NSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON", + @"Label for the cell that presents the 'invite contacts' workflow."); + cell.textLabel.font = [UIFont ows_regularFontWithSize:18.f]; + cell.textLabel.textColor = [UIColor blackColor]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + customRowHeight:kActionCellHeight + actionBlock:^{ + [weakSelf presentInviteFlow]; + }]]; } - customRowHeight:kActionCellHeight - actionBlock:^{ - [weakSelf presentInviteFlow]; - }]]; // If the search string looks like a phone number, show either "new conversation..." cells and/or // "invite via SMS..." cells. @@ -357,18 +387,20 @@ NS_ASSUME_NONNULL_BEGIN if (!hasSearchText && helper.signalAccounts.count < 1) { // No Contacts - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = [UITableViewCell new]; - cell.textLabel.text = NSLocalizedString( - @"SETTINGS_BLOCK_LIST_NO_CONTACTS", @"A label that indicates the user has no Signal contacts."); - cell.textLabel.font = [UIFont ows_regularFontWithSize:15.f]; - cell.textLabel.textColor = [UIColor colorWithWhite:0.5f alpha:1.f]; - cell.textLabel.textAlignment = NSTextAlignmentCenter; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - return cell; + if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) { + [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ + UITableViewCell *cell = [UITableViewCell new]; + cell.textLabel.text = NSLocalizedString( + @"SETTINGS_BLOCK_LIST_NO_CONTACTS", @"A label that indicates the user has no Signal contacts."); + cell.textLabel.font = [UIFont ows_regularFontWithSize:15.f]; + cell.textLabel.textColor = [UIColor colorWithWhite:0.5f alpha:1.f]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + customRowHeight:kActionCellHeight + actionBlock:nil]]; } - customRowHeight:kActionCellHeight - actionBlock:nil]]; } if (hasSearchText && !hasSearchResults) { @@ -407,7 +439,7 @@ NS_ASSUME_NONNULL_BEGIN { [[Environment preferences] setHasDeclinedNoContactsView:YES]; - [self showNoContactsModeIfNecessary]; + [self showContactAppropriateViews]; } - (void)presentInviteFlow @@ -418,10 +450,20 @@ NS_ASSUME_NONNULL_BEGIN [self presentViewController:inviteFlow.actionSheetController animated:YES completion:nil]; } -- (void)showNoContactsModeIfNecessary +- (void)showContactAppropriateViews { - BOOL hasNoContacts = self.contactsViewHelper.signalAccounts.count < 1; - self.isNoContactsModeActive = (hasNoContacts && ![[Environment preferences] hasDeclinedNoContactsView]); + if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) { + BOOL hasNoContacts = self.contactsViewHelper.signalAccounts.count < 1; + self.isNoContactsModeActive = (hasNoContacts && ![[Environment preferences] hasDeclinedNoContactsView]); + [self showContactsPermissionReminder:NO]; + + [self showSearchBar:YES]; + } else { + // don't show "no signal contacts", show "no contact access" + self.isNoContactsModeActive = NO; + [self showContactsPermissionReminder:YES]; + [self showSearchBar:NO]; + } } - (void)setIsNoContactsModeActive:(BOOL)isNoContactsModeActive @@ -579,7 +621,7 @@ NS_ASSUME_NONNULL_BEGIN { [self updateTableContents]; - [self showNoContactsModeIfNecessary]; + [self showContactAppropriateViews]; } - (BOOL)shouldHideLocalNumber diff --git a/Signal/src/ViewControllers/SignalsViewController.m b/Signal/src/ViewControllers/SignalsViewController.m index 18c7f5a44..abcf37aa5 100644 --- a/Signal/src/ViewControllers/SignalsViewController.m +++ b/Signal/src/ViewControllers/SignalsViewController.m @@ -168,7 +168,8 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS @"SETTINGS_BUTTON_ACCESSIBILITY", @"Accessibility hint for the settings button"); - self.missingContactsPermissionView.text = NSLocalizedString(@"INBOX_VIEW_MISSING_CONTACTS_PERMISSION", @"Multi line label explainging how to show names instead of phone numbers in your inbox"); + self.missingContactsPermissionView.text = NSLocalizedString(@"INBOX_VIEW_MISSING_CONTACTS_PERMISSION", + @"Multiline label explaining how to show names instead of phone numbers in your inbox"); self.missingContactsPermissionView.tapAction = ^{ [[UIApplication sharedApplication] openSystemSettings]; }; @@ -278,9 +279,18 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS - (IBAction)composeNew { MessageComposeTableViewController *viewController = [MessageComposeTableViewController new]; - UINavigationController *navigationController = - [[UINavigationController alloc] initWithRootViewController:viewController]; - [self presentTopLevelModalViewController:navigationController animateDismissal:YES animatePresentation:YES]; + + [self.contactsManager requestSystemContactsOnceWithCompletion:^(NSError *_Nullable error) { + DDLogError(@"%@ Error when requesting contacts: %@", self.tag, error); + // Even if there was an error fetching contacts we proceed to the next screen. + // As the compose view will present the proper thing depending on contact access. + // + // We just want to make sure contact access is *complete* before showing the compose + // screen to avoid flicker. + UINavigationController *navigationController = + [[UINavigationController alloc] initWithRootViewController:viewController]; + [self presentTopLevelModalViewController:navigationController animateDismissal:YES animatePresentation:YES]; + }]; } - (void)swappedSegmentedControl { diff --git a/Signal/src/contact/OWSContactsManager.h b/Signal/src/contact/OWSContactsManager.h index 1de096163..5b6ba75f2 100644 --- a/Signal/src/contact/OWSContactsManager.h +++ b/Signal/src/contact/OWSContactsManager.h @@ -39,6 +39,7 @@ extern NSString *const OWSContactsManagerSignalAccountsDidChangeNotification; // Request systems contacts and start syncing changes. The user will see an alert // if they haven't previously. - (void)requestSystemContactsOnce; +- (void)requestSystemContactsOnceWithCompletion:(void (^_Nullable)(NSError *_Nullable error))completion; // Ensure's the app has the latest contacts, but won't prompt the user for contact // access if they haven't granted it. diff --git a/Signal/src/contact/OWSContactsManager.m b/Signal/src/contact/OWSContactsManager.m index 8e5808bd7..fe3e68e20 100644 --- a/Signal/src/contact/OWSContactsManager.m +++ b/Signal/src/contact/OWSContactsManager.m @@ -54,9 +54,15 @@ NSString *const OWSContactsManagerSignalAccountsDidChangeNotification = // Request contacts access if you haven't asked recently. - (void)requestSystemContactsOnce { - [self.systemContactsFetcher requestOnce]; + [self requestSystemContactsOnceWithCompletion:nil]; } +- (void)requestSystemContactsOnceWithCompletion:(void (^_Nullable)(NSError *_Nullable error))completion +{ + [self.systemContactsFetcher requestOnceWithCompletion:completion]; +} + + - (void)fetchSystemContactsIfAlreadyAuthorized { [self.systemContactsFetcher fetchIfAlreadyAuthorized]; diff --git a/Signal/src/contact/SystemContactsFetcher.swift b/Signal/src/contact/SystemContactsFetcher.swift index 79cc2ee8f..c3a67c90b 100644 --- a/Signal/src/contact/SystemContactsFetcher.swift +++ b/Signal/src/contact/SystemContactsFetcher.swift @@ -39,11 +39,19 @@ class SystemContactsFetcher: NSObject { CNContactEmailAddressesKey as CNKeyDescriptor ] - public func requestOnce() { + /** + * Ensures we've requested access for system contacts. This can be used in multiple places, + * where we might need contact access, but will ensure we don't wastefully reload contacts + * if we have already fetched contacts. + * + * @param completion completion handler is called on main thread. + */ + public func requestOnce(completion: ((Error?) -> Void)?) { AssertIsOnMainThread() guard !systemContactsHaveBeenRequestedAtLeastOnce else { Logger.debug("\(TAG) already requested system contacts") + completion?(nil) return } systemContactsHaveBeenRequestedAtLeastOnce = true @@ -51,25 +59,36 @@ class SystemContactsFetcher: NSObject { switch authorizationStatus { case .notDetermined: - contactStore.requestAccess(for: .contacts, completionHandler: { (granted, error) in + contactStore.requestAccess(for: .contacts) { (granted, error) in if let error = error { Logger.error("\(self.TAG) error fetching contacts: \(error)") + DispatchQueue.main.async { + completion?(error) + } + return + } + + guard granted else { + Logger.info("\(self.TAG) declined contact access.") + // This case should have been caught be the error guard a few lines up. assertionFailure() + DispatchQueue.main.async { + completion?(nil) + } + return } - if !granted { - // TODO, make this a one time dismissable admonishment - // e.g. remember across launches that the user has dismissed. - self.displayMissingContactsPermissionAlert() - } else { - self.updateContacts() + DispatchQueue.main.async { + self.updateContacts(completion: completion) } - }) + } case .authorized: - // TODO reset onetime admonishment reminder, so that we remind user again (once) if they've since toggled permissions. - self.updateContacts() + self.updateContacts(completion: completion) case .denied, .restricted: Logger.debug("\(TAG) contacts were \(self.authorizationStatus)") + DispatchQueue.main.async { + completion?(nil) + } } } @@ -79,15 +98,10 @@ class SystemContactsFetcher: NSObject { return } - updateContacts() - } - - private func displayMissingContactsPermissionAlert() { - let foo = UIApplication.shared.frontmostViewController - Logger.error("TODO") + updateContacts(completion: nil) } - private func updateContacts() { + private func updateContacts(completion: ((Error?) -> Void)?) { AssertIsOnMainThread() systemContactsHaveBeenRequestedAtLeastOnce = true @@ -105,11 +119,16 @@ class SystemContactsFetcher: NSObject { } catch let error as NSError { Logger.error("\(self.TAG) Failed to fetch contacts with error:\(error)") assertionFailure() + DispatchQueue.main.async { + completion?(error) + } + return } let contacts = systemContacts.map { Contact(systemContact: $0) } DispatchQueue.main.async { self.delegate?.systemContactsFetcher(self, updatedContacts: contacts) + completion?(nil) } } } @@ -123,7 +142,7 @@ class SystemContactsFetcher: NSObject { @objc private func contactStoreDidChange() { - updateContacts() + updateContacts(completion: nil) } } diff --git a/Signal/src/views/ReminderView.swift b/Signal/src/views/ReminderView.swift index f0875a2cf..71309dd71 100644 --- a/Signal/src/views/ReminderView.swift +++ b/Signal/src/views/ReminderView.swift @@ -41,8 +41,9 @@ class ReminderView: UIView { setupSubviews() } - convenience init(tapAction: @escaping () -> Void) { + convenience init(text: String, tapAction: @escaping () -> Void) { self.init(frame: .zero) + self.text = text self.tapAction = tapAction } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 6d2f1589b..f986e986a 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Button text to dismiss missing contacts permission alert */ "AB_PERMISSION_MISSING_ACTION_NOT_NOW" = "Not Now"; -/* Alert title when contacts disabled */ -"AB_PERMISSION_MISSING_TITLE" = "Sorry!"; - /* Action sheet item */ "ACCEPT_NEW_IDENTITY_ACTION" = "Accept new safety number"; @@ -22,9 +19,6 @@ /* Title for the 'add group member' view. */ "ADD_GROUP_MEMBER_VIEW_TITLE" = "Add Member"; -/* Alert body when contacts disabled */ -"ADDRESSBOOK_RESTRICTED_ALERT_BODY" = "Signal requires access to your contacts. Access to contacts is restricted. Signal will close. You can disable the restriction temporarily to let Signal access your contacts by going the Settings app >> General >> Restrictions >> Contacts >> Allow Changes."; - /* The label for the 'discard' button in alerts and action sheets. */ "ALERT_DISCARD_BUTTON" = "Discard"; @@ -223,6 +217,9 @@ /* Activity Sheet label */ "COMPARE_SAFETY_NUMBER_ACTION" = "Compare with Clipboard"; +/* Multiline label explaining why compose-screen contact picker is empty. */ +"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION" = "To see which of your contacts are Signal users, enable contacts access in your system settings."; + /* No comment provided by engineer. */ "CONFIRM_ACCOUNT_DESTRUCTION_TEXT" = "This will reset the application by deleting your messages and unregister you with the server. The app will close after deletion of data."; @@ -568,7 +565,7 @@ /* Call setup status label */ "IN_CALL_TERMINATED" = "Call Ended."; -/* Multi line label explainging how to show names instead of phone numbers in your inbox */ +/* Multiline label explaining how to show names instead of phone numbers in your inbox */ "INBOX_VIEW_MISSING_CONTACTS_PERMISSION" = "To see the names of your contacts, update your sytem settings to allow contact access."; /* notification body */ @@ -580,8 +577,13 @@ /* No comment provided by engineer. */ "INCOMING_INCOMPLETE_CALL" = "Incomplete incoming call from"; -/* Label for the cell that presents the 'invite contacts' workflow. - Text for button at the top of the contact picker */ +/* Alert body when contacts disabled while trying to invite contacts to signal */ +"INVITE_FLOW_REQUIRES_CONTACT_ACCESS_BODY" = "To invite your contacts, you need to allow Signal access to your contacts in the Settings app."; + +/* Alert title when contacts disabled while trying to invite contacts to signal */ +"INVITE_FLOW_REQUIRES_CONTACT_ACCESS_TITLE" = "Enable Contact Access"; + +/* Label for the cell that presents the 'invite contacts' workflow. */ "INVITE_FRIENDS_CONTACT_TABLE_BUTTON" = "Invite Friends to Signal"; /* Search */