TM-SGNL-iOS/SignalUI/RecipientPickers/DeleteSystemContactViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

405 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 = .black.withAlphaComponent(0.15)
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
}
var didFail = false
// Set up the observer _before_ deleting, so its around
// when the deletion happens.
NotificationCenter.default.observe(once: .OWSContactsManagerSignalAccountsDidChange)
.asVoid()
.timeout(on: DispatchQueue.main, seconds: 5, substituteValue: ())
.observe(on: DispatchQueue.main) { [weak self] _ in
guard let self, !didFail else { 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
}
self.dependencies.databaseStorage.write { tx in
do {
try self.dependencies.recipientHidingManager.addHiddenRecipient(
SignalServiceAddress(serviceId: self.serviceId, e164: self.e164),
inKnownMessageRequestState: false,
wasLocallyInitiated: true,
tx: tx.asV2Write
)
self.displayDeletedContactToast(displayNameForToast: displayNameForToast)
} catch {
owsFailDebug("Failed to hide recipient")
}
}
}
// Delete
let saveRequest = CNSaveRequest()
saveRequest.delete(contactToDelete)
do {
try contactStore.execute(saveRequest)
} catch {
didFail = 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
}
}