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.
414 lines
15 KiB
Swift
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
|
|
}
|
|
}
|