Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/AppSettings/Account/DeleteAccountConfirmationViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

class DeleteAccountConfirmationViewController: OWSTableViewController2 {
    private var country: PhoneNumberCountry!

    private lazy var nationalNumberTextField: UITextField = {
        let textField = UITextField()
        textField.returnKeyType = .done
        textField.autocorrectionType = .no
        textField.spellCheckingType = .no
        textField.keyboardType = .phonePad
        textField.textColor = .Signal.label
        textField.delegate = self
        return textField
    }()

    private let nameLabel: UILabel = {
        let label = UILabel()
        label.textColor = .Signal.label
        return label
    }()

    // Don't allow swipe to dismiss
    override var isModalInPresentation: Bool {
        get { true }
        set {}
    }

    private let appReadiness: AppReadinessSetter

    init(appReadiness: AppReadinessSetter) {
        self.appReadiness = appReadiness
        super.init()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        shouldAvoidKeyboard = true

        navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)
        navigationItem.rightBarButtonItem = .init(title: CommonStrings.deleteButton, style: .done, target: self, action: #selector(didTapDelete))
        if #available(iOS 26, *) {
            navigationItem.rightBarButtonItem?.tintColor = .Signal.red
        } else {
            navigationItem.rightBarButtonItem?.setTitleTextAttributes(
                [.foregroundColor: UIColor.Signal.red],
                for: .normal,
            )
        }

        populateDefaultCountryCode()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        nationalNumberTextField.becomeFirstResponder()
    }

    func updateTableContents() {
        let contents = OWSTableContents()

        let headerSection = OWSTableSection()
        headerSection.hasBackground = false
        headerSection.add(.init(customCellBlock: { [weak self] in
            guard let self else { return UITableViewCell() }
            return self.buildHeaderCell()
        }))
        contents.add(headerSection)

        let confirmSection = OWSTableSection()
        confirmSection.headerTitle = OWSLocalizedString(
            "DELETE_ACCOUNT_CONFIRMATION_SECTION_TITLE",
            comment: "Section header",
        )

        confirmSection.add(.disclosureItem(
            withText: OWSLocalizedString(
                "DELETE_ACCOUNT_CONFIRMATION_COUNTRY_CODE_TITLE",
                comment: "Title for the 'country code' row of the 'delete account confirmation' view controller.",
            ),
            accessoryText: "\(country.plusPrefixedCallingCode) (\(country.countryCode))",
            actionBlock: { [weak self] in
                guard let self else { return }
                let countryCodeController = CountryCodeViewController(delegate: self)
                self.present(OWSNavigationController(rootViewController: countryCodeController), animated: true)
            },
        ))
        confirmSection.add(.init(
            customCellBlock: { [weak self] in
                guard let self else { return UITableViewCell() }
                return self.phoneNumberCell
            },
            actionBlock: { [weak self] in
                self?.nationalNumberTextField.becomeFirstResponder()
            },
        ))
        contents.add(confirmSection)

        self.contents = contents
    }

    func buildHeaderCell() -> UITableViewCell {
        let imageView = UIImageView(image: Theme.isDarkThemeEnabled ? #imageLiteral(resourceName: "delete-account-dark") : #imageLiteral(resourceName: "delete-account-light"))
        imageView.autoSetDimensions(to: CGSize(square: 112))
        let imageContainer = UIView()
        imageContainer.addSubview(imageView)
        imageView.autoPinEdge(toSuperviewEdge: .top)
        imageView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 12)
        imageView.autoHCenterInSuperview()

        let titleLabel = UILabel.titleLabelForRegistration(text: OWSLocalizedString(
            "DELETE_ACCOUNT_CONFIRMATION_TITLE",
            comment: "Title for the 'delete account' confirmation view.",
        ))

        let descriptionLabel = UILabel.explanationLabelForRegistration(text: OWSLocalizedString(
            "DELETE_ACCOUNT_CONFIRMATION_DESCRIPTION",
            comment: "Description for the 'delete account' confirmation view.",
        ))

        let headerView = UIStackView(arrangedSubviews: [
            imageContainer,
            titleLabel,
            descriptionLabel,
        ])
        headerView.axis = .vertical
        headerView.spacing = 12

        let cell = OWSTableItem.newCell()
        cell.contentView.addSubview(headerView)
        headerView.autoPinEdgesToSuperviewMargins()
        return cell
    }

    lazy var phoneNumberCell: UITableViewCell = {
        let cell = OWSTableItem.newCell()
        cell.preservesSuperviewLayoutMargins = true
        cell.contentView.preservesSuperviewLayoutMargins = true

        nameLabel.text = OWSLocalizedString(
            "DELETE_ACCOUNT_CONFIRMATION_PHONE_NUMBER_TITLE",
            comment: "Title for the 'phone number' row of the 'delete account confirmation' view controller.",
        )
        nameLabel.textColor = .Signal.label
        nameLabel.font = OWSTableItem.primaryLabelFont
        nameLabel.adjustsFontForContentSizeCategory = true
        nameLabel.lineBreakMode = .byTruncatingTail
        nameLabel.autoSetDimension(.height, toSize: 24, relation: .greaterThanOrEqual)

        nationalNumberTextField.font = OWSTableItem.accessoryLabelFont
        nationalNumberTextField.placeholder = TextFieldFormatting.exampleNationalNumber(
            forCountryCode: country.countryCode,
            includeExampleLabel: false,
        )

        nameLabel.setCompressionResistanceHigh()
        nationalNumberTextField.setContentHuggingHorizontalHigh()

        let contentRow = UIStackView(arrangedSubviews: [
            nameLabel,
            nationalNumberTextField,
        ])
        contentRow.spacing = OWSTableItem.iconSpacing
        contentRow.alignment = .center
        cell.contentView.addSubview(contentRow)
        contentRow.autoPinEdgesToSuperviewMargins()

        return cell
    }()

    @objc
    private func didTapDelete() {
        guard hasEnteredLocalNumber else {
            OWSActionSheets.showActionSheet(
                title: OWSLocalizedString(
                    "DELETE_ACCOUNT_CONFIRMATION_WRONG_NUMBER",
                    comment: "Title for the action sheet when you enter the wrong number on the 'delete account confirmation' view controller.",
                ),
            )
            return
        }

        guard SSKEnvironment.shared.reachabilityManagerRef.isReachable else {
            OWSActionSheets.showActionSheet(
                title: OWSLocalizedString(
                    "DELETE_ACCOUNT_CONFIRMATION_NO_INTERNET",
                    comment: "Title for the action sheet when you have no internet on the 'delete account confirmation' view controller.",
                ),
            )
            return
        }

        nationalNumberTextField.resignFirstResponder()

        showDeletionConfirmUI_checkPayments()
    }

    private func showDeletionConfirmUI_checkPayments() {
        if
            SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled,
            let paymentBalance = SUIEnvironment.shared.paymentsSwiftRef.currentPaymentBalance,
            !paymentBalance.amount.isZero
        {
            showDeleteAccountPaymentsConfirmationUI(paymentBalance: paymentBalance.amount)
        } else {
            showDeletionConfirmUI()
        }
    }

    private func showDeleteAccountPaymentsConfirmationUI(paymentBalance: TSPaymentAmount) {
        let title = OWSLocalizedString(
            "SETTINGS_DELETE_ACCOUNT_PAYMENTS_BALANCE_ALERT_TITLE",
            comment: "Title for the alert confirming whether the user wants transfer their payments balance before deleting their account.",
        )

        let formattedBalance = PaymentsFormat.format(
            paymentAmount: paymentBalance,
            isShortForm: false,
            withCurrencyCode: true,
            withSpace: true,
        )
        let messageFormat = OWSLocalizedString(
            "SETTINGS_DELETE_ACCOUNT_PAYMENTS_BALANCE_ALERT_MESSAGE_FORMAT",
            comment: "Body for the alert confirming whether the user wants transfer their payments balance before deleting their account. Embeds: {{ the current payment balance }}.",
        )
        let message = String.nonPluralLocalizedStringWithFormat(messageFormat, formattedBalance)

        let actionSheet = ActionSheetController(title: title, message: message)

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "SETTINGS_DELETE_ACCOUNT_PAYMENTS_BALANCE_ALERT_TRANSFER",
                comment: "Button for transferring the user's payments balance before deleting their account.",
            ),
            style: .default,
        ) { [weak self] _ in
            self?.transferPaymentsButton()
        })

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "SETTINGS_DELETE_ACCOUNT_PAYMENTS_BALANCE_ALERT_DONT_TRANSFER",
                comment: "Button for to _not_ transfer the user's payments balance before deleting their account.",
            ),
            style: .destructive,
        ) { [weak self] _ in
            self?.showDeletionConfirmUI()
        })

        actionSheet.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(actionSheet)
    }

    private func transferPaymentsButton() {
        dismiss(animated: true) { [appReadiness] in
            guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
                owsFailDebug("Could not identify frontmostViewController")
                return
            }
            guard let navigationController = frontmostViewController.navigationController else {
                owsFailDebug("Missing navigationController.")
                return
            }
            var viewControllers = navigationController.viewControllers
            _ = viewControllers.removeLast()
            viewControllers.append(PaymentsSettingsViewController(mode: .inAppSettings, appReadiness: appReadiness))
            viewControllers.append(PaymentsTransferOutViewController(transferAmount: nil))
            navigationController.setViewControllers(viewControllers, animated: true)
        }
    }

    private func showDeletionConfirmUI() {

        OWSActionSheets.showConfirmationAlert(
            title: OWSLocalizedString(
                "DELETE_ACCOUNT_CONFIRMATION_ACTION_SHEEET_TITLE",
                comment: "Title for the action sheet confirmation title of the 'delete account confirmation' view controller.",
            ),
            message: OWSLocalizedString(
                "DELETE_ACCOUNT_CONFIRMATION_ACTION_SHEEET_MESSAGE",
                comment: "Title for the action sheet message of the 'delete account confirmation' view controller.",
            ),
            proceedTitle: OWSLocalizedString(
                "DELETE_ACCOUNT_CONFIRMATION_ACTION_SHEEET_ACTION",
                comment: "Title for the action sheet 'delete' action of the 'delete account confirmation' view controller.",
            ),
            proceedStyle: .destructive,
            proceedAction: { [weak self] _ in self?.deleteAccount() },
        )
    }

    private func deleteAccount() {
        Task {
            let overlayView = UIView()
            overlayView.backgroundColor = self.tableBackgroundColor.withAlphaComponent(0.9)
            overlayView.alpha = 0
            self.navigationController?.view.addSubview(overlayView)
            overlayView.autoPinEdgesToSuperviewEdges()

            let progressView = AnimatedProgressView(
                loadingText: OWSLocalizedString(
                    "DELETE_ACCOUNT_CONFIRMATION_IN_PROGRESS",
                    comment: "Indicates the work we are doing while deleting the account",
                ),
            )
            self.navigationController?.view.addSubview(progressView)
            progressView.autoCenterInSuperview()

            progressView.startAnimating { overlayView.alpha = 1 }

            do {
                try await { () async throws -> Never in
                    try await self.deleteDonationSubscriptionIfNecessary()
                    try await self.deleteBackupIfNecessary()
                    try await self.leaveGroups()
                    try await self.unregisterAccount()
                    resetAppDataAndExit()
                }()
            } catch {
                owsFailDebug("Failed to delete account! \(error)")

                progressView.stopAnimating(success: false) {
                    overlayView.alpha = 0
                } completion: {
                    overlayView.removeFromSuperview()
                    progressView.removeFromSuperview()

                    OWSActionSheets.showActionSheet(
                        title: OWSLocalizedString(
                            "DELETE_ACCOUNT_CONFIRMATION_DELETE_FAILED",
                            comment: "Title for the action sheet when delete failed on the 'delete account confirmation' view controller.",
                        ),
                    )
                }
            }
        }
    }

    private func deleteBackupIfNecessary() async throws {
        let backupKeyService = DependenciesBridge.shared.backupKeyService
        let backupSettingsStore = BackupSettingsStore()
        let db = DependenciesBridge.shared.db
        let logger = PrefixedLogger(prefix: "[Backups]")
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager

        let (localIdentifiers, currentBackupPlan): (
            LocalIdentifiers?,
            BackupPlan,
        ) = db.read { tx in
            return (
                tsAccountManager.localIdentifiers(tx: tx),
                backupSettingsStore.backupPlan(tx: tx),
            )
        }

        guard let localIdentifiers else {
            return
        }

        switch currentBackupPlan {
        case .disabled:
            logger.info("Backups disabled: skipping delete.")
            return
        case .disabling:
            // If we're disabling then BackupDisablingManager is actively trying
            // to delete our remote backup, too. Might as well try here too.
            break
        case .free, .paid, .paidExpiringSoon, .paidAsTester:
            break
        }

        logger.info("Attempting to delete Backups!")
        try await backupKeyService.deleteBackupKey(
            localIdentifiers: localIdentifiers,
            auth: .implicit(),
            logger: logger,
        )
    }

    private func deleteDonationSubscriptionIfNecessary() async throws {
        let activeSubscriptionId = SSKEnvironment.shared.databaseStorageRef.read {
            DonationSubscriptionManager.getSubscriberID(transaction: $0)
        }
        guard let activeSubscriptionId else {
            return
        }
        Logger.info("Found subscriber ID. Canceling subscription...")
        return try await DonationSubscriptionManager.cancelSubscription(for: activeSubscriptionId)
    }

    private func leaveGroups() async throws {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef

        var sendUpdatePromises = [Promise<Void>]()
        for uniqueId in databaseStorage.read(block: ThreadFinder().fetchUniqueIds(tx:)) {
            let leavePromise = await databaseStorage.awaitableWrite { tx -> Promise<[Promise<Void>]> in
                guard
                    let thread = TSThread.fetchViaCache(uniqueId: uniqueId, transaction: tx),
                    let groupThread = thread as? TSGroupThread,
                    groupThread.isGroupV2Thread,
                    let groupModel = groupThread.groupModel as? TSGroupModelV2
                else {
                    return .value([])
                }
                if groupModel.groupMembership.isLocalUserRequestingMember {
                    return Promise.wrapAsync {
                        try await GroupManager.cancelRequestToJoin(groupModel: groupModel)
                        // There's no messages to send when canceling a join request because we
                        // don't know who's in the the group/who needs to be notified.
                        return []
                    }
                } else {
                    return GroupManager.localLeaveGroupOrDeclineInvite(
                        groupThread: groupThread,
                        isDeletingAccount: true,
                        tx: tx,
                    )
                }
            }
            do {
                sendUpdatePromises.append(contentsOf: try await leavePromise.awaitable())
            } catch GroupsV2Error.groupBlocked, GroupsV2Error.localUserNotInGroup {
                // Can't do anything about these groups; ignore the errors.
            }
        }
        for sendUpdatePromise in sendUpdatePromises {
            do {
                try await sendUpdatePromise.awaitable()
            } catch {
                Logger.warn("Couldn't send group update, but we've already left, so ignoring: \(error)")
            }
        }
    }

    private func unregisterAccount() async throws {
        Logger.info("Unregistering...")
        try await DependenciesBridge.shared.registrationStateChangeManager.unregisterFromService()
    }

    private func resetAppDataAndExit() -> Never {
        let keyFetcher = SSKEnvironment.shared.databaseStorageRef.keyFetcher
        SignalApp.shared.resetAppDataAndExit(keyFetcher: keyFetcher)
    }

    // MARK: -

    private var hasEnteredLocalNumber: Bool {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        guard let localNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber else {
            owsFailDebug("local number unexpectedly nil")
            return false
        }

        guard let nationalNumber = nationalNumberTextField.text else {
            return false
        }

        let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
        let parsedNumber = phoneNumberUtil.parsePhoneNumber(countryCode: country.countryCode, nationalNumber: nationalNumber)

        return localNumber == parsedNumber?.e164
    }
}

// MARK: - CountryCodeViewControllerDelegate

extension DeleteAccountConfirmationViewController: CountryCodeViewControllerDelegate {
    func countryCodeViewController(_ vc: CountryCodeViewController, didSelectCountry country: PhoneNumberCountry) {
        updateCountry(country)
    }

    private func populateDefaultCountryCode() {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
        let defaultCountry: PhoneNumberCountry
        if
            let localNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber,
            let localCountry = PhoneNumberCountry.buildCountry(forCountryCode: phoneNumberUtil.preferredCountryCode(forLocalNumber: localNumber))
        {
            defaultCountry = localCountry
        } else {
            owsFailDebug("Couldn't determine local country.")
            defaultCountry = .defaultValue
        }
        updateCountry(defaultCountry)
    }

    private func updateCountry(_ country: PhoneNumberCountry) {
        self.country = country
        updateTableContents()
    }
}

// MARK: - UITextFieldDelegate

extension DeleteAccountConfirmationViewController: UITextFieldDelegate {
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        didTapDelete()
        return false
    }

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        TextFieldFormatting.phoneNumberTextField(textField, changeCharactersIn: range, replacementString: string, plusPrefixedCallingCode: country.plusPrefixedCallingCode)
        return false
    }
}