Signal-iOS/SignalUI/RecipientPickers/DeleteSystemContactViewController.swift
Igor Solomennikov 6dd2815fde
Unify backdropColor values.
This is the color of UI layer that dims content underneath when presenting something modally (eg action sheet).

Values were taken from UIDimmingView that UIKit uses to obscure content under modally presented view controller.
2025-10-24 14:43:08 -07:00

414 lines
15 KiB
Swift

//
// 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 = 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
}
await self.dependencies.databaseStorage.awaitableWrite { tx in
do {
try self.dependencies.recipientHidingManager.addHiddenRecipient(
SignalServiceAddress(serviceId: self.serviceId, e164: self.e164),
inKnownMessageRequestState: false,
wasLocallyInitiated: true,
tx: tx
)
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(
format: 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 public func layoutSubviews() {
super.layoutSubviews()
self.clipsToBounds = true
layer.cornerRadius = self.frame.size.width / 2
}
}