Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/RecipientPickers/DeleteSystemContactViewController.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Contacts
import LibSignalClient
import SignalServiceKit

/// If we try and hide a recipient but fail because they correspond to
/// a system contact, we show this controller which provides a hook
/// to delete the system contact (which, if successful, then triggers a hide).
///
/// SHOULD ONLY BE DISPLAYED ON THE PRIMARY DEVICE
class DeleteSystemContactViewController: OWSTableViewController2 {
    /// Dependencies needed by this view controller.
    /// Note that these dependencies can be accessed
    /// as global properties on `OWSTableViewController2`,
    /// but we are moving towards more explicit dependencies.
    private let dependencies: Dependencies
    private struct Dependencies {
        let contactsManager: any ContactManager
        let databaseStorage: SDSDatabaseStorage
        let recipientHidingManager: RecipientHidingManager
        let tsAccountManager: TSAccountManager
    }

    /// The e164 of the contact represented by this contact card.
    private let e164: E164
    private let serviceId: ServiceId?

    /// The view controller that should present the toast
    /// confirming successful contact deletion. Note that
    /// this cannot be `self` because `self` dismisses upon
    /// successful deletion.
    private let viewControllerPresentingToast: UIViewController

    init(
        e164: E164,
        serviceId: ServiceId?,
        viewControllerPresentingToast: UIViewController,
        contactsManager: any ContactManager,
        databaseStorage: SDSDatabaseStorage,
        recipientHidingManager: RecipientHidingManager,
        tsAccountManager: TSAccountManager,
    ) {
        self.e164 = e164
        self.serviceId = serviceId
        self.viewControllerPresentingToast = viewControllerPresentingToast
        self.dependencies = Dependencies(
            contactsManager: contactsManager,
            databaseStorage: databaseStorage,
            recipientHidingManager: recipientHidingManager,
            tsAccountManager: tsAccountManager,
        )
        super.init()
    }

    private lazy var spinnerContainer: UIView = {
        let view = UIView()
        self.view.addSubview(view)
        view.autoPinEdgesToSuperviewEdges()
        view.backgroundColor = .Signal.backdrop
        view.addSubview(spinnerView)
        spinnerView.autoCenterInSuperview()
        view.isHidden = true
        return view
    }()

    private lazy var spinnerView = UIActivityIndicatorView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // This screen is for primary devices only. If a non primary
        // manages to get here bad things could happen.
        owsPrecondition(DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice)

        self.navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)
    }

    override func viewWillAppear(_ animated: Bool) {
        updateTableContents()
    }

    private enum Constants {
        /// Width and height for avatar image.
        static let avatarDiameter = CGFloat(112)
        /// Toast inset from bottom of view.
        static let toastInset = 8.0
    }

    /// Cell containing contact avatar.
    ///
    /// - Parameter image: The avatar image.
    private func avatarCell(image: UIImage) -> UITableViewCell {
        let cell = OWSTableItem.newCell()
        cell.selectionStyle = .none
        cell.contentView.backgroundColor = .clear

        let imageView = ContactDeletionAvatarImageView(image: image)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.setContentCompressionResistancePriority(.required, for: .vertical)

        cell.contentView.addSubview(imageView)
        NSLayoutConstraint.activate([
            imageView.heightAnchor.constraint(equalToConstant: Constants.avatarDiameter),
            imageView.widthAnchor.constraint(equalToConstant: Constants.avatarDiameter),
            cell.contentView.topAnchor.constraint(equalTo: imageView.topAnchor),
            cell.contentView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
            cell.contentView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
        ])
        return cell
    }

    /// Ordered array of names to be displayed on the contact card.
    /// Each name should have its own cell in the same section.
    /// If the contact has no associated names, this method returns
    /// an empty array.
    ///
    /// - Parameter nameComponents: The name components of the person whose names
    ///   we will return.
    private func names(systemContactName: DisplayName.SystemContactName?) -> [String] {
        guard let systemContactName else {
            return []
        }
        let config: DisplayName.Config = .current()
        if
            config.shouldUseSystemContactNicknames,
            let nickname = systemContactName.nameComponents.nickname
        {
            return [nickname]
        }
        var names = [String]()
        if let firstName = systemContactName.nameComponents.givenName {
            names.append(firstName)
        }
        if let lastName = systemContactName.nameComponents.familyName {
            names.append(lastName)
        }
        return names
    }

    private func updateTableContents() {
        let contents = OWSTableContents()

        let addressForProfileLookup = SignalServiceAddress(serviceId: serviceId, e164: e164)
        let (
            image,
            systemContactName,
        ) = dependencies.databaseStorage.read { tx in
            let image = SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                forAddress: addressForProfileLookup,
                diameterPixels: Constants.avatarDiameter * UIScreen.main.scale,
                localUserDisplayMode: .asUser,
                transaction: tx,
            )
            let systemContactName = dependencies.contactsManager.systemContactName(
                for: e164.stringValue,
                tx: tx,
            )
            return (image, systemContactName)
        }

        // Avatar
        let avatarSection = OWSTableSection()
        avatarSection.hasBackground = false
        let avatarItem = OWSTableItem(customCellBlock: { [weak self] in
            if
                let image,
                let avatarCell = self?.avatarCell(image: image)
            {
                return avatarCell
            }
            return UITableViewCell()
        })
        avatarSection.add(avatarItem)
        contents.add(avatarSection)

        // Name(s)
        let names = names(systemContactName: systemContactName)
        if !names.isEmpty {
            let nameSection = OWSTableSection()
            names.forEach { name in
                nameSection.add(
                    OWSTableItem.label(
                        withText: name.filterForDisplay,
                        accessoryType: .none,
                    ),
                )
            }
            contents.add(nameSection)
        }

        // Phone Number
        let formattedPhoneNum = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(e164.stringValue)
        let phoneNumberSection = OWSTableSection()
        phoneNumberSection.add(
            OWSTableItem.label(
                withText: formattedPhoneNum,
                accessoryType: .none,
            ),
        )
        contents.add(phoneNumberSection)

        // Delete Contact button
        let deleteContactSection = OWSTableSection()
        deleteContactSection.add(
            OWSTableItem(
                customCellBlock: {
                    return OWSTableItem.buildCell(
                        itemName: OWSLocalizedString(
                            "DELETE_CONTACT_BUTTON",
                            comment: "Title of button for deleting system contact.",
                        ),
                        textColor: .ows_accentRed,
                        accessoryType: .none,
                    )
                },
                actionBlock: { [weak self] in
                    guard let self else { return }
                    let displayName: DisplayName = systemContactName.map { .systemContactName($0) } ?? .unknown
                    self.displayDeleteContactActionSheet(
                        phoneNumber: self.e164.stringValue,
                        displayNameForToast: displayName.resolvedValue().formattedForActionSheetTitle(),
                    )
                },
            ),
        )
        contents.add(deleteContactSection)

        self.contents = contents
    }

    /// Displays the action sheet confirming that the user really
    /// wants to delete this contact from their system contacts.
    private func displayDeleteContactActionSheet(phoneNumber: String?, displayNameForToast: String) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "DELETE_CONTACT_ACTION_SHEET_TITLE",
                comment: "Title of action sheet confirming the user wants to delete a system contact.",
            ),
            message: OWSLocalizedString(
                "DELETE_CONTACT_ACTION_SHEET_EXPLANATION",
                comment: "An explanation of what happens in Signal when you remove a system contact.",
            ),
        )

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "DELETE_CONTACT_ACTION_SHEET_BUTTON",
                comment: "'Delete' button label on the delete contact confirmation action sheet",
            ),
            style: .destructive,
            handler: { [weak self] _ in
                self?.handleContactDelete(displayNameForToast: displayNameForToast)
            },
        ))
        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.cancelButton,
            style: .cancel,
        ))
        self.presentActionSheet(actionSheet)
    }

    private func handleContactDelete(displayNameForToast: String) {
        switch CNContactStore.authorizationStatus(for: .contacts) {
        case .limited:
            Logger.info("Limited contacts authorization; treating as authorized for compatibility")
            fallthrough

        case .authorized:
            break

        case .notDetermined, .denied, .restricted:
            fallthrough

        @unknown default:
            Logger.info("No contact permissions")
            showGenericErrorToastAndDismiss()
        }

        let signalAccount = self.dependencies.databaseStorage.read { tx in
            return SignalAccountFinder().signalAccount(
                for: self.e164,
                tx: tx,
            )
        }
        // In the case where we have more than one contact with the e164,
        // prefer the one with this id. Otherwise, choice is arbitrary.
        let preferredContactIdForDeletion = signalAccount?.cnContactId

        // Go to CNContacts as the source of truth for contacts.
        let contactStore = CNContactStore()
        let phoneNumPredicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: e164.stringValue))
        guard
            let contacts = try? contactStore.unifiedContacts(
                matching: phoneNumPredicate,
                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor],
            )
        else {
            Logger.error("Failed to fetch CNContacts!")
            showGenericErrorToastAndDismiss()
            return
        }
        var contactToDelete: CNContact?
        if let preferredContactIdForDeletion {
            contactToDelete = contacts.first(where: { $0.identifier == preferredContactIdForDeletion })
        }
        if contactToDelete == nil {
            contactToDelete = contacts.first
        }

        guard let contactToDelete = contactToDelete?.mutableCopy() as? CNMutableContact else {
            // No contact to delete! Done.
            Logger.warn("No contact to delete, exiting early.")
            showGenericErrorToastAndDismiss()
            return
        }

        let didFail = AtomicBool(false, lock: UnfairLock())
        // Set up the observer _before_ deleting, so its around
        // when the deletion happens.
        Task {
            do {
                try await withCooperativeTimeout(seconds: 5) {
                    for await _ in NotificationCenter.default.notifications(named: .OWSContactsManagerSignalAccountsDidChange).map({ _ in }) {
                        break
                    }
                }
            } catch {
            }

            if didFail.get() {
                return
            }

            defer {
                self.dismiss(animated: true)
            }

            // Check that the contact got deleted from our db.
            let isStillSystemContact = SSKEnvironment.shared.contactManagerRef.cnContactId(for: self.e164.stringValue) != nil
            if isStillSystemContact {
                // Can't hide; likely there was another contact with the same number.
                // Just exit.
                Logger.warn("Address still a system contact after deletion; possibly duplicate system contact")
                return
            }
            do {
                try await self.dependencies.databaseStorage.awaitableWrite { tx in
                    try self.dependencies.recipientHidingManager.addHiddenRecipient(
                        SignalServiceAddress(serviceId: self.serviceId, e164: self.e164),
                        inKnownMessageRequestState: false,
                        wasLocallyInitiated: true,
                        tx: tx,
                    )
                }
                await MainActor.run {
                    self.displayDeletedContactToast(displayNameForToast: displayNameForToast)
                }
            } catch {
                owsFailDebug("Failed to hide recipient")
            }
        }

        // Delete
        let saveRequest = CNSaveRequest()
        saveRequest.delete(contactToDelete)
        do {
            try contactStore.execute(saveRequest)
        } catch {
            didFail.set(true)
            Logger.error("Failed to delete CNContact!")
            showGenericErrorToastAndDismiss()
        }

        spinnerContainer.isHidden = false
        spinnerView.startAnimating()
        self.view.isUserInteractionEnabled = false
    }

    private func showGenericErrorToastAndDismiss() {
        ToastController(text: CommonStrings.somethingWentWrongError).presentToastView(
            from: .bottom,
            of: self.viewControllerPresentingToast.view,
            inset: self.view.safeAreaInsets.bottom + Constants.toastInset,
        )
        self.dismiss(animated: true)
    }

    /// Displays a toast confirming that a contact was
    /// successfully deleted.
    private func displayDeletedContactToast(displayNameForToast: String) {
        let toastMessage = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "DELETE_CONTACT_CONFIRMATION_TOAST",
                comment: "Toast message confirming the system contact was deleted. Embeds {{The name of the user who was deleted.}}.",
            ),
            displayNameForToast,
        )
        ToastController(text: toastMessage).presentToastView(
            from: .bottom,
            of: self.viewControllerPresentingToast.view,
            inset: self.view.safeAreaInsets.bottom + Constants.toastInset,
        )
    }
}

private class ContactDeletionAvatarImageView: UIImageView {
    override func layoutSubviews() {
        super.layoutSubviews()
        self.clipsToBounds = true
        layer.cornerRadius = self.frame.size.width / 2
    }
}