Signal-iOS/SignalUI/Views/ContactsViewHelper.swift
Igor Solomennikov 8b5e737098
Re-worked Add to System Contacts flow.
The main user-visible change is that adding to system contacts UI will now always be presented modally instead of views being pushed onto current navigation stack.

I have refactored repeated permissions check + view presentation code from individual call sites into a new "flow" class that is now also responsible for presenting contact editing UI.

These changes also fix two issues:
• "Add to Existing Contact" option was missing from group participant's info sheet.
• "Contact Details" was opened in editing mode from individual chat settings screen.
2023-12-08 13:20:42 -08:00

400 lines
14 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Contacts
import ContactsUI
import LibSignalClient
import SafariServices
import SignalMessaging
import SignalServiceKit
@objc
public protocol ContactsViewHelperObserver: AnyObject {
func contactsViewHelperDidUpdateContacts()
}
public class ContactsViewHelper: Dependencies {
public init() {
AppReadiness.runNowOrWhenUIDidBecomeReadySync {
// setup() - especially updateContacts() - can
// be expensive, so we don't want to run that
// directly in runNowOrWhenAppDidBecomeReadySync().
// That could cause 0x8badf00d crashes.
//
// On the other hand, the user might quickly
// open a view (like the compose view) that uses
// this helper. If the helper hasn't completed
// setup, that view won't be able to display a
// list of users to pick from. Therefore, we
// can't use runNowOrWhenAppDidBecomeReadyAsync()
// which might not run for many seconds after
// the app becomes ready.
//
// Therefore we dispatch async to the main queue.
// We'll run very soon after app UI becomes ready,
// without introducing the risk of a 0x8badf00d
// crash.
DispatchQueue.main.async {
self.setup()
}
}
}
deinit {
notificationObservers.forEach { NotificationCenter.default.removeObserver($0) }
}
private func setup() {
guard !CurrentAppContext().isNSE else { return }
updateContacts()
setupNotificationObservations()
}
// MARK: Notifications
private var notificationObservers = [Any]()
private func setupNotificationObservations() {
notificationObservers.append(contentsOf: [
NotificationCenter.default.addObserver(
forName: .OWSContactsManagerSignalAccountsDidChange,
object: nil,
queue: nil,
using: { [weak self] _ in
self?.updateContacts()
}
),
NotificationCenter.default.addObserver(
forName: .profileWhitelistDidChange,
object: nil,
queue: nil,
using: { [weak self] _ in
self?.updateContacts()
}
),
NotificationCenter.default.addObserver(
forName: BlockingManager.blockListDidChange,
object: nil,
queue: nil,
using: { [weak self] _ in
self?.updateContacts()
}
),
NotificationCenter.default.addObserver(
forName: RecipientHidingManagerImpl.hideListDidChange,
object: nil,
queue: nil,
using: { [weak self] _ in
// Hiding a recipient who is a system contact or is someone you've
// chatted with 1:1 updates the profile whitelist, which already
// triggers a call to `updateContacts`. However, recipients who
// do not fit into these categories need this other mechanism to
// trigger `updateContacts`.
self?.updateContacts()
}
)
])
}
// MARK: Observation
private let observers = NSHashTable<ContactsViewHelperObserver>.weakObjects()
public func addObserver(_ observer: ContactsViewHelperObserver) {
AssertIsOnMainThread()
observers.add(observer)
}
// MARK: Contacts Data
public private(set) var allSignalAccounts: [SignalAccount] = []
private var phoneNumberSignalAccountMap: [String: SignalAccount] = [:]
private var serviceIdSignalAccountMap: [ServiceId: SignalAccount] = [:]
public var hasUpdatedContactsAtLeastOnce: Bool {
contactsManagerImpl.hasLoadedSystemContacts
}
public func localAddress() -> SignalServiceAddress? {
owsAssertBeta(!CurrentAppContext().isNSE)
return DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress
}
public func fetchSignalAccount(for address: SignalServiceAddress) -> SignalAccount? {
AssertIsOnMainThread()
owsAssertBeta(!CurrentAppContext().isNSE)
if let serviceId = address.serviceId, let signalAccount = serviceIdSignalAccountMap[serviceId] {
return signalAccount
}
if let phoneNumber = address.phoneNumber, let signalAccount = phoneNumberSignalAccountMap[phoneNumber] {
return signalAccount
}
return nil
}
public func signalAccounts(matching searchText: String, transaction tx: SDSAnyReadTransaction) -> [SignalAccount] {
owsAssertBeta(!CurrentAppContext().isNSE)
// Check for matches against "Note to Self".
var signalAccountsToSearch = allSignalAccounts
if let localAddress = localAddress() {
signalAccountsToSearch.append(SignalAccount(address: localAddress))
}
return fullTextSearcher.filterSignalAccounts(
signalAccountsToSearch,
searchText: searchText,
transaction: tx
)
}
public func signalAccounts(includingLocalUser: Bool) -> [SignalAccount] {
switch includingLocalUser {
case true:
return allSignalAccounts
case false:
let localNumber = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber
return allSignalAccounts
.filter { !($0.recipientAddress.isLocalAddress || $0.contact?.hasPhoneNumber(localNumber) == true) }
}
}
private func updateContacts() {
AssertIsOnMainThread()
owsAssertDebug(!CurrentAppContext().isNSE)
let (systemContacts, signalConnections) = databaseStorage.read { tx in
// All "System Contact"s that we believe are registered.
let systemContacts = self.contactsManagerImpl.unsortedSignalAccounts(transaction: tx)
// All Signal Connections that we believe are registered. In theory, this
// should include your system contacts and the people you chat with.
let signalConnections = self.profileManagerImpl.allWhitelistedRegisteredAddresses(tx: tx)
return (systemContacts, signalConnections)
}
var signalAccounts = systemContacts
var seenAddresses = Set(signalAccounts.lazy.map { $0.recipientAddress })
for address in signalConnections {
guard seenAddresses.insert(address).inserted else {
// We prefer the copy from contactsManager which will appear first in
// accountsToProcess; don't overwrite it.
continue
}
signalAccounts.append(SignalAccount(address: address))
}
var phoneNumberMap = [String: SignalAccount]()
var serviceIdMap = [ServiceId: SignalAccount]()
for signalAccount in signalAccounts {
if let phoneNumber = signalAccount.recipientPhoneNumber {
phoneNumberMap[phoneNumber] = signalAccount
}
if let serviceId = signalAccount.recipientServiceId {
serviceIdMap[serviceId] = signalAccount
}
}
phoneNumberSignalAccountMap = phoneNumberMap
serviceIdSignalAccountMap = serviceIdMap
allSignalAccounts = contactsManagerImpl.sortSignalAccountsWithSneakyTransaction(signalAccounts)
fireDidUpdateContacts()
}
private func fireDidUpdateContacts() {
for delegate in observers.allObjects {
delegate.contactsViewHelperDidUpdateContacts()
}
}
}
// MARK: Presenting Permission-Gated Views
public extension ContactsViewHelper {
private enum Constant {
static let contactsAccessNotAllowedLearnMoreURL = URL(string: "https://support.signal.org/hc/articles/360007319011#ipad_contacts")!
}
enum ReadPurpose {
case share
case invite
}
private enum Access {
case edit
case read(ReadPurpose)
}
enum AuthorizedBehavior {
case runAction(() -> Void)
case pushViewController(on: UINavigationController, viewController: () -> UIViewController?)
}
enum UnauthorizedBehavior {
case presentError(from: UIViewController)
}
func checkEditAuthorization(
authorizedBehavior: AuthorizedBehavior,
unauthorizedBehavior: UnauthorizedBehavior
) {
AssertIsOnMainThread()
switch contactsManagerImpl.editingAuthorization {
case .notAllowed:
performWhenNotAllowed(unauthorizedBehavior: unauthorizedBehavior)
case .denied, .restricted:
performWhenDenied(unauthorizedBehavior: unauthorizedBehavior, access: .edit)
case .authorized:
perform(authorizedBehavior: authorizedBehavior)
}
}
func checkReadAuthorization(
purpose: ReadPurpose,
authorizedBehavior: AuthorizedBehavior,
unauthorizedBehavior: UnauthorizedBehavior
) {
let deniedBlock = {
self.performWhenDenied(unauthorizedBehavior: unauthorizedBehavior, access: .read(purpose))
}
switch contactsManagerImpl.sharingAuthorization {
case .notDetermined:
CNContactStore().requestAccess(for: .contacts) { granted, error in
DispatchQueue.main.async {
if granted {
self.perform(authorizedBehavior: authorizedBehavior)
} else {
deniedBlock()
}
}
}
case .authorized:
perform(authorizedBehavior: authorizedBehavior)
case .denied:
deniedBlock()
}
}
private func perform(authorizedBehavior: AuthorizedBehavior) {
switch authorizedBehavior {
case .runAction(let authorizedBlock):
authorizedBlock()
case .pushViewController(on: let navigationController, viewController: let viewControllerBlock):
guard let viewController = viewControllerBlock() else {
return owsFailDebug("Missing contactViewController.")
}
navigationController.pushViewController(viewController, animated: true)
}
}
private func performWhenDenied(unauthorizedBehavior: UnauthorizedBehavior, access: Access) {
switch unauthorizedBehavior {
case .presentError(from: let viewController):
Self.presentContactAccessDeniedAlert(from: viewController, access: access)
}
}
private func performWhenNotAllowed(unauthorizedBehavior: UnauthorizedBehavior) {
switch unauthorizedBehavior {
case .presentError(from: let viewController):
Self.presentContactAccessNotAllowedAlert(from: viewController)
}
}
private static func presentContactAccessDeniedAlert(from viewController: UIViewController, access: Access) {
owsAssertDebug(!CurrentAppContext().isNSE)
let title: String
let message: String
switch access {
case .edit:
title = OWSLocalizedString(
"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 = OWSLocalizedString(
"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"
)
case .read(let readPurpose):
switch readPurpose {
case .share:
title = OWSLocalizedString(
"CONTACT_SHARING_NO_ACCESS_TITLE",
comment: "Alert title when contacts disabled while trying to share a contact."
)
message = OWSLocalizedString(
"CONTACT_SHARING_NO_ACCESS_BODY",
comment: "Alert body when contacts disabled while trying to share a contact."
)
case .invite:
title = OWSLocalizedString(
"INVITE_FLOW_REQUIRES_CONTACT_ACCESS_TITLE",
comment: "Alert title when contacts disabled while trying to invite contacts to signal"
)
message = OWSLocalizedString(
"INVITE_FLOW_REQUIRES_CONTACT_ACCESS_BODY",
comment: "Alert body when contacts disabled while trying to invite contacts to signal"
)
}
}
let actionSheet = ActionSheetController(title: title, message: message)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"AB_PERMISSION_MISSING_ACTION_NOT_NOW",
comment: "Button text to dismiss missing contacts permission alert"
),
accessibilityIdentifier: UIView.accessibilityIdentifier(containerName: "ContactAccess", name: "not_now"),
style: .cancel
))
if let openSystemSettingsAction = AppContextUtils.openSystemSettingsAction(completion: nil) {
actionSheet.addAction(openSystemSettingsAction)
}
viewController.presentActionSheet(actionSheet)
}
private static func presentContactAccessNotAllowedAlert(from viewController: UIViewController) {
let actionSheet = ActionSheetController(
message: OWSLocalizedString(
"LINKED_DEVICE_CONTACTS_NOT_ALLOWED",
comment: "Shown in an alert when trying to edit a contact."
)
)
actionSheet.addAction(ActionSheetAction(title: CommonStrings.learnMore) { [weak viewController] _ in
guard let viewController else { return }
presentContactAccessNotAllowedLearnMore(from: viewController)
})
actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton, style: .cancel))
viewController.presentActionSheet(actionSheet)
}
static func presentContactAccessNotAllowedLearnMore(from viewController: UIViewController) {
viewController.present(
SFSafariViewController(url: Constant.contactsAccessNotAllowedLearnMoreURL),
animated: true
)
}
}