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

import Foundation
public import LibSignalClient
import Lottie
import PureLayout
import SafariServices
import SignalServiceKit
import UIKit

public class FingerprintViewController: OWSViewController, OWSNavigationChildController {

    public class func present(
        for theirAci: Aci?,
        from viewController: UIViewController,
    ) {
        struct FingerprintResult {
            let theirAci: Aci
            let theirRecipientIdentity: OWSRecipientIdentity
            let theirVerificationState: VerificationState
            let fingerprint: OWSFingerprint
        }

        let contactsManager = SSKEnvironment.shared.contactManagerRef
        let db = DependenciesBridge.shared.db
        let identityManager = DependenciesBridge.shared.identityManager
        let keyTransparencyManager = DependenciesBridge.shared.keyTransparencyManager
        let keyTransparencyStore = KeyTransparencyStore()
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager

        let fingerprintResult: FingerprintResult?
        let keyTransparencyState: KeyTransparencyState?
        let keyTransparencyShouldShowEducation: Bool
        (
            fingerprintResult,
            keyTransparencyState,
            keyTransparencyShouldShowEducation,
        ) = db.read { tx in
            guard let theirAci else {
                return (nil, nil, false)
            }

            let theirAddress = SignalServiceAddress(theirAci)
            let theirName = contactsManager.displayName(for: theirAddress, tx: tx).resolvedValue()
            let theirVerificationState = identityManager.verificationState(for: theirAddress, tx: tx)

            guard
                let theirRecipientIdentity = identityManager.recipientIdentity(for: theirAddress, tx: tx),
                let theirAciIdentityKey = try? theirRecipientIdentity.identityKeyObject,
                let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx),
                let myAciIdentityKey = identityManager.identityKeyPair(for: .aci, tx: tx)?.keyPair.identityKey
            else {
                return (nil, nil, false)
            }

            let keyTransparencyIsEnabled = keyTransparencyManager.isEnabled(tx: tx)
            let keyTransparencyCheckParams = keyTransparencyManager.prepareCheck(
                aci: theirAci,
                localIdentifiers: localIdentifiers,
                tx: tx,
            )
            let keyTransparencyShouldShowEducation = keyTransparencyStore.shouldShowFirstTimeEducation(tx: tx)

            return (
                FingerprintResult(
                    theirAci: theirAci,
                    theirRecipientIdentity: theirRecipientIdentity,
                    theirVerificationState: theirVerificationState,
                    fingerprint: OWSFingerprint(
                        myAci: localIdentifiers.aci,
                        theirAci: theirAci,
                        myAciIdentityKey: myAciIdentityKey,
                        theirAciIdentityKey: theirAciIdentityKey,
                        theirName: theirName,
                    ),
                ),
                KeyTransparencyState(
                    isEnabled: keyTransparencyIsEnabled,
                    checkParams: keyTransparencyCheckParams,
                    viewInitialState: keyTransparencyCheckParams == nil ? .unableToVerify : .readyToVerify,
                ),
                keyTransparencyShouldShowEducation,
            )
        }

        guard let fingerprintResult, let keyTransparencyState else {
            let actionSheet = ActionSheetController(message: OWSLocalizedString(
                "CANT_VERIFY_IDENTITY_EXCHANGE_MESSAGES",
                comment: "Alert shown when the user needs to exchange messages to see the safety number.",
            ))

            actionSheet.addAction(.init(title: CommonStrings.learnMore, style: .default, handler: { _ in
                guard let vc = CurrentAppContext().frontmostViewController() else {
                    return
                }
                Self.showUrl(URL.Support.safetyNumbers, from: vc)
            }))
            actionSheet.addAction(OWSActionSheets.cancelAction)

            viewController.presentActionSheet(actionSheet)
            return
        }

        let fingerprintViewController = FingerprintViewController(
            recipientAci: fingerprintResult.theirAci,
            recipientIdentity: fingerprintResult.theirRecipientIdentity,
            recipientVerificationState: fingerprintResult.theirVerificationState,
            fingerprint: fingerprintResult.fingerprint,
            keyTransparencyState: keyTransparencyState,
            deps: FingerprintViewController.Deps(
                db: db,
                identityManager: identityManager,
                keyTransparencyManager: keyTransparencyManager,
            ),
        )
        let navigationController = OWSNavigationController(rootViewController: fingerprintViewController)

        if keyTransparencyShouldShowEducation {
            let educationSheet = KeyTransparencyFirstTimeEducationHeroSheet {
                db.write { tx in
                    keyTransparencyStore.setShouldShowFirstTimeEducation(false, tx: tx)
                }

                viewController.present(navigationController, animated: true)
            }
            viewController.present(educationSheet, animated: true)
        } else {
            viewController.present(navigationController, animated: true)
        }
    }

    // MARK: -

    fileprivate struct Deps {
        let db: DB
        let identityManager: OWSIdentityManager
        let keyTransparencyManager: KeyTransparencyManager
    }

    fileprivate struct KeyTransparencyState {
        let isEnabled: Bool
        let checkParams: KeyTransparencyManager.CheckParams?
        let viewInitialState: KeyTransparencyView.State
    }

    private let recipientAci: Aci
    private let recipientIdentity: OWSRecipientIdentity
    private let recipientVerificationState: VerificationState
    private let fingerprint: OWSFingerprint
    private let keyTransparencyState: KeyTransparencyState

    private let deps: Deps?
    private var identityStateChangeObserver: AnyObject?

    fileprivate init(
        recipientAci: Aci,
        recipientIdentity: OWSRecipientIdentity,
        recipientVerificationState: VerificationState,
        fingerprint: OWSFingerprint,
        keyTransparencyState: KeyTransparencyState,
        deps: Deps?,
    ) {
        // We snapshot state when we present this view and dismiss the view when
        // there's an identity change, to avoid edge cases related to state
        // changing while this view is presented. (E.g., you verified them on
        // another device; you learned their identity key changed; etc.)
        self.recipientAci = recipientAci
        self.recipientIdentity = recipientIdentity
        self.recipientVerificationState = recipientVerificationState
        self.fingerprint = fingerprint
        self.keyTransparencyState = keyTransparencyState

        self.deps = deps

        super.init()

        title = NSLocalizedString("PRIVACY_VERIFICATION_TITLE", comment: "Navbar title")
        navigationItem.rightBarButtonItem = .doneButton(dismissingFrom: self)

        identityStateChangeObserver = NotificationCenter.default.addObserver(
            forName: .identityStateDidChange,
            object: nil,
            queue: .main,
        ) { [weak self] _ in
            self?.dismiss(animated: true)
        }
    }

    deinit {
        if let identityStateChangeObserver {
            NotificationCenter.default.removeObserver(identityStateChangeObserver)
        }
    }

    public var preferredNavigationBarStyle: OWSNavigationBarStyle {
        return .solid
    }

    override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }

    override public func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .Signal.groupedBackground
        configureUI()
    }

    // MARK: UI

    private lazy var fingerprintCard = FingerprintCard(
        fingerprint: fingerprint,
        theirVerificationState: recipientVerificationState,
        controller: self,
    )

    private lazy var instructionsTextView: UITextView = {
        let instructions = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "VERIFY_SAFETY_NUMBER_INSTRUCTIONS",
                comment: "Instructions for verifying your safety number. Embeds {{contact's name}}",
            ),
            fingerprint.theirName,
        )
        // Link doesn't matter, we will override tap behavior.
        let learnMore = CommonStrings.learnMore.styled(with: .link(URL(string: "https://signal.org")!))

        let textView = LinkingTextView { [weak self] in
            self?.didTapSafetyNumbersLearnMore()
        }
        textView.attributedText = NSAttributedString.composed(of: [
            instructions,
            " ",
            learnMore,
        ]).styled(
            with: .font(.dynamicTypeFootnote),
            .color(.Signal.secondaryLabel),
            .alignment(.center),
        )
        textView.linkTextAttributes = [.foregroundColor: UIColor.Signal.label]

        return textView
    }()

    private lazy var keyTransparencyView = KeyTransparencyView(
        initialState: keyTransparencyState.viewInitialState,
        controller: self,
    )

    private func configureUI() {
        let scrollView = UIScrollView()
        view.addSubview(scrollView)
        scrollView.autoPinEdgesToSuperviewEdges()

        let containerView = UIView()
        scrollView.addSubview(containerView)
        containerView.autoPinEdges(toEdgesOf: scrollView)
        containerView.autoPinWidth(toWidthOf: view)

        containerView.addSubview(fingerprintCard)
        containerView.addSubview(instructionsTextView)
        if keyTransparencyState.isEnabled {
            containerView.addSubview(keyTransparencyView)
        }

        fingerprintCard.autoPinEdge(toSuperviewSafeArea: .top, withInset: 10)
        fingerprintCard.autoPinWidth(toWidthOf: containerView, offset: -.scaleFromIPhone5To7Plus(60, 105))
        fingerprintCard.autoHCenterInSuperview()

        instructionsTextView.autoPinEdge(.top, to: .bottom, of: fingerprintCard, withOffset: 24)
        instructionsTextView.autoPinEdge(.leading, to: .leading, of: containerView, withOffset: .scaleFromIPhone5To7Plus(18, 28))
        instructionsTextView.autoPinEdge(.trailing, to: .trailing, of: containerView, withOffset: -.scaleFromIPhone5To7Plus(18, 28))

        if keyTransparencyState.isEnabled {
            keyTransparencyView.autoPinEdge(.top, to: .bottom, of: instructionsTextView, withOffset: 44)
            keyTransparencyView.autoPinEdge(.leading, to: .leading, of: containerView, withOffset: 16)
            keyTransparencyView.autoPinEdge(.trailing, to: .trailing, of: containerView, withOffset: -16)
            keyTransparencyView.autoPinEdge(.bottom, to: .bottom, of: scrollView, withOffset: -8)
        } else {
            instructionsTextView.autoPinEdge(.bottom, to: .bottom, of: scrollView, withOffset: -8)
        }
    }

    // MARK: - Fingerprint Card

    private final class FingerprintCard: UIView {
        private let fingerprint: OWSFingerprint
        private let theirVerificationState: VerificationState
        private weak var controller: FingerprintViewController?

        init(
            fingerprint: OWSFingerprint,
            theirVerificationState: VerificationState,
            controller: FingerprintViewController,
        ) {
            self.fingerprint = fingerprint
            self.theirVerificationState = theirVerificationState
            self.controller = controller
            super.init(frame: .zero)

            layer.cornerRadius = Constants.cornerRadius

            self.backgroundColor = UIColor(rgbHex: 0x506ecd)

            addSubview(shareButton)
            addSubview(qrCodeView)
            addSubview(safetyNumberLabel)
            addSubview(verifyUnverifyButton)

            shareButton.autoPinEdge(.top, to: .top, of: self, withOffset: 16)
            shareButton.autoPinEdge(.trailing, to: .trailing, of: self, withOffset: -16)

            qrCodeView.autoPinEdge(.top, to: .bottom, of: shareButton, withOffset: 8)
            // Set a minimum horizontal margin
            qrCodeView.autoPinEdge(.leading, to: .leading, of: self, withOffset: .scaleFromIPhone5To7Plus(44, 64), relation: .greaterThanOrEqual)
            qrCodeView.autoPinEdge(.trailing, to: .trailing, of: self, withOffset: -.scaleFromIPhone5To7Plus(44, 64), relation: .lessThanOrEqual)
            qrCodeView.autoHCenterInSuperview()

            safetyNumberLabel.autoPinEdge(.top, to: .bottom, of: qrCodeView, withOffset: 30)
            safetyNumberLabel.autoPinEdge(.leading, to: .leading, of: self, withOffset: .scaleFromIPhone5To7Plus(20, 35), relation: .greaterThanOrEqual)
            safetyNumberLabel.autoPinEdge(.trailing, to: .trailing, of: self, withOffset: -.scaleFromIPhone5To7Plus(20, 35), relation: .lessThanOrEqual)
            safetyNumberLabel.autoHCenterInSuperview()

            verifyUnverifyButton.autoPinEdge(.top, to: .bottom, of: safetyNumberLabel, withOffset: 30)
            verifyUnverifyButton.autoPinEdge(.leading, to: .leading, of: self, withOffset: .scaleFromIPhone5To7Plus(20, 35), relation: .greaterThanOrEqual)
            verifyUnverifyButton.autoPinEdge(.trailing, to: .trailing, of: self, withOffset: -.scaleFromIPhone5To7Plus(20, 35), relation: .lessThanOrEqual)
            verifyUnverifyButton.autoHCenterInSuperview()
            verifyUnverifyButton.autoPinEdge(.bottom, to: .bottom, of: self, withOffset: -20)

            // Cap QR code width to the width of the safety number
            // Prevents it from being too large on iPad
            let qrCodeWidthConstraint = qrCodeView.widthAnchor.constraint(equalTo: safetyNumberLabel.widthAnchor)
            qrCodeWidthConstraint.priority = .defaultHigh
            qrCodeWidthConstraint.autoInstall()
            safetyNumberLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
        }

        required init?(coder: NSCoder) {
            fatalError()
        }

        private lazy var shareButton: UIButton = {
            let button = UIButton()
            button.setTemplateImage(
                UIImage(named: "share"),
                tintColor: .white,
            )
            button.addTarget(self, action: #selector(didTapShare), for: .touchUpInside)
            return button
        }()

        private lazy var qrCodeView: UIView = {
            let containerView = UIView()
            containerView.backgroundColor = .white
            containerView.layer.cornerRadius = Constants.cornerRadius
            containerView.layer.masksToBounds = true

            let fingerprintImageView = UIImageView()
            fingerprintImageView.image = fingerprint.image
            // Don't antialias QR Codes.
            fingerprintImageView.layer.magnificationFilter = .nearest
            fingerprintImageView.layer.minificationFilter = .nearest
            fingerprintImageView.setCompressionResistanceLow()
            containerView.addSubview(fingerprintImageView)
            fingerprintImageView.autoPin(toAspectRatio: 1)
            fingerprintImageView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(margin: 20), excludingEdge: .bottom)

            let scanLabel = UILabel()
            scanLabel.text = NSLocalizedString("PRIVACY_TAP_TO_SCAN", comment: "Button that shows the 'scan with camera' view.")
            scanLabel.font = .systemFont(ofSize: .scaleFromIPhone5To7Plus(13, 15))
            scanLabel.textColor = .Signal.label.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
            scanLabel.numberOfLines = 0
            scanLabel.textAlignment = .center
            containerView.addSubview(scanLabel)
            scanLabel.autoPinWidthToSuperviewMargins()
            scanLabel.autoPinEdge(.top, to: .bottom, of: fingerprintImageView, withOffset: 12)
            scanLabel.autoPinEdge(.bottom, to: .bottom, of: containerView, withOffset: -14)

            containerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapToScan)))

            return containerView
        }()

        private lazy var safetyNumberLabel: UILabel = {
            let label = UILabel()
            label.text = fingerprint.displayableText
            label.font = UIFont(name: "Menlo-Regular", size: 23)
            label.textAlignment = .center
            label.textColor = .white
            label.numberOfLines = 3
            label.lineBreakMode = .byTruncatingTail
            label.adjustsFontSizeToFitWidth = true
            label.isUserInteractionEnabled = true
            label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapSafetyNumber)))
            return label
        }()

        private lazy var verifyUnverifyButton: UIButton = {
            let lightTheme = UITraitCollection(userInterfaceStyle: .light)

            var configuration = UIButton.Configuration.filled()
            configuration.titleAlignment = .center
            configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadlineClamped.semibold())
            configuration.baseBackgroundColor = .Signal.background.resolvedColor(with: lightTheme)
            configuration.baseForegroundColor = .Signal.label.resolvedColor(with: lightTheme)
            configuration.contentInsets = NSDirectionalEdgeInsets(hMargin: 16, vMargin: 12)
            configuration.cornerStyle = .capsule

            switch theirVerificationState {
            case .verified:
                configuration.title = OWSLocalizedString(
                    "PRIVACY_UNVERIFY_BUTTON",
                    comment: "Button that lets user mark another user's identity as unverified.",
                )
            case .noLongerVerified, .implicit:
                configuration.title = OWSLocalizedString(
                    "PRIVACY_VERIFY_BUTTON",
                    comment: "Button that lets user mark another user's identity as verified.",
                )
            }

            return UIButton(
                configuration: configuration,
                primaryAction: UIAction { [weak self] _ in
                    self?.controller?.didTapVerifyUnverify()
                },
            )
        }()

        @objc
        func didTapToScan() {
            controller?.didTapToScan()
        }

        @objc
        func didTapShare() {
            controller?.shareFingerprint(from: shareButton)
        }

        @objc
        func didTapSafetyNumber() {
            controller?.shareFingerprint(from: safetyNumberLabel)
        }

        private enum Constants {
            static let cornerRadius: CGFloat = 18
        }
    }

    // MARK: -

    fileprivate final class KeyTransparencyView: UIView {
        enum State {
            case unableToVerify
            case readyToVerify
            case verifying
            case verifiedSuccess
            case verifiedFailure
        }

        var state: State {
            didSet { updateForCurrentState() }
        }

        private weak var controller: FingerprintViewController?

        init(
            initialState: State,
            controller: FingerprintViewController,
        ) {
            self.state = initialState
            self.controller = controller
            super.init(frame: .zero)

            addSubview(sectionHeaderLabel)
            addSubview(verifyButton)
            addSubview(footerTextView)

            sectionHeaderLabel.autoPinEdge(toSuperviewEdge: .top, withInset: 12)
            sectionHeaderLabel.autoPinEdge(toSuperviewEdge: .leading, withInset: 26)
            sectionHeaderLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 26)

            verifyButton.autoPinEdge(.top, to: .bottom, of: sectionHeaderLabel, withOffset: 10)
            verifyButton.autoPinEdge(toSuperviewEdge: .leading, withInset: 16)
            verifyButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 16)

            footerTextView.autoPinEdge(.top, to: .bottom, of: verifyButton, withOffset: 12)
            footerTextView.autoPinEdge(toSuperviewEdge: .leading, withInset: 32)
            footerTextView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 32)
            footerTextView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 24)

            updateForCurrentState()
        }

        @available(*, unavailable)
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private func updateForCurrentState() {
            let leadingView: UIView
            let titleText: String
            let foregroundColor: UIColor
            let showChevron: Bool
            switch state {
            case .readyToVerify:
                leadingView = verifyButtonLeadingViewKey
                titleText = OWSLocalizedString(
                    "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_BUTTON_VERIFY",
                    comment: "Title for a button offering automatic key verification.",
                )
                foregroundColor = .Signal.label
                showChevron = false
            case .verifying:
                leadingView = verifyButtonLeadingViewSpinner
                titleText = OWSLocalizedString(
                    "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_BUTTON_VERIFYING",
                    comment: "Title for a button while automatic key verification is ongoing.",
                )
                foregroundColor = .Signal.label
                showChevron = false
            case .verifiedSuccess:
                leadingView = verifyButtonLeadingViewSuccess
                titleText = OWSLocalizedString(
                    "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_BUTTON_VERIFY_SUCCESS",
                    comment: "Title for a button when automatic key verification succeeds.",
                )
                foregroundColor = .Signal.label
                showChevron = true
            case .verifiedFailure, .unableToVerify:
                leadingView = verifyButtonLeadingViewFailure
                titleText = OWSLocalizedString(
                    "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_BUTTON_VERIFY_FAILURE",
                    comment: "Title for a button when automatic key verification fails.",
                )
                foregroundColor = .Signal.secondaryLabel
                showChevron = true
            }

            for view in verifyButtonLeadingViews {
                view.isHidden = view !== leadingView
            }
            verifyButton.configuration!.title = titleText
            verifyButton.configuration!.baseForegroundColor = foregroundColor
            if showChevron {
                verifyButton.configuration!.image = UIImage(named: "chevron-right-20")!
                verifyButton.contentHorizontalAlignment = .fill
            } else {
                verifyButton.configuration!.image = nil
                verifyButton.contentHorizontalAlignment = .leading
            }
        }

        // MARK: - Views

        private static let leadingViewSize: CGFloat = 24

        private lazy var sectionHeaderLabel: UILabel = {
            let label = UILabel()
            label.text = OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_HEADER",
                comment: "Header for automatic key verification",
            )
            label.font = .dynamicTypeBody.semibold()
            label.textColor = .Signal.label
            label.numberOfLines = 0
            return label
        }()

        private lazy var verifyButtonLeadingViewKey: UIImageView = {
            let imageView = UIImageView(image: UIImage(named: "key")!)
            imageView.tintColor = .Signal.label
            return imageView
        }()

        private lazy var verifyButtonLeadingViewSpinner: UIActivityIndicatorView = {
            let view = UIActivityIndicatorView(style: .medium)
            view.startAnimating()
            return view
        }()

        private lazy var verifyButtonLeadingViewSuccess: UIImageView = {
            let imageView = UIImageView(image: UIImage(named: "check-circle-fill")!)
            imageView.tintColor = .Signal.green
            return imageView
        }()

        private lazy var verifyButtonLeadingViewFailure: UIImageView = {
            let imageView = UIImageView(image: UIImage(named: "info")!)
            imageView.tintColor = .Signal.secondaryLabel
            return imageView
        }()

        private var verifyButtonLeadingViews: [UIView] {
            [
                verifyButtonLeadingViewKey,
                verifyButtonLeadingViewSpinner,
                verifyButtonLeadingViewSuccess,
                verifyButtonLeadingViewFailure,
            ]
        }

        private lazy var verifyButton: UIButton = {
            // Define overall insets for the button, with extra inset at the
            // leading edge since we'll be manually overlaying a view there.
            let inset: CGFloat = 16
            var buttonInsets = NSDirectionalEdgeInsets(margin: inset)
            buttonInsets.leading += Self.leadingViewSize + 12

            // This configuration is updated in updateForCurrentState() as well.
            var configuration = UIButton.Configuration.filled()
            configuration.imagePadding = 12
            configuration.imagePlacement = .trailing
            configuration.contentInsets = buttonInsets
            configuration.baseBackgroundColor = .Signal.tertiaryBackground
            configuration.cornerStyle = .capsule
            configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeBody)

            let button = UIButton(
                configuration: configuration,
                primaryAction: UIAction { [weak self] _ in
                    guard let self else { return }
                    controller?.didTapKeyTransparencyButton(state: state)
                },
            )

            for view in verifyButtonLeadingViews {
                button.addSubview(view)
                view.autoSetDimensions(to: .square(Self.leadingViewSize))
                view.autoPinEdge(.leading, to: .leading, of: button, withOffset: inset)
                view.autoVCenterInSuperview()
            }

            return button
        }()

        private lazy var footerTextView: LinkingTextView = {
            let textView = LinkingTextView { [weak self] in
                self?.controller?.didTapKeyTransparencyLearnMore()
            }

            let footerText = OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_FOOTER",
                comment: "Footer explaining that automatic verification is not available for all chats",
            )

            // Link doesn't matter, we override tap behavior
            let learnMoreLink = CommonStrings.learnMore.styled(with: .link(URL(string: "https://signal.org")!))

            textView.attributedText = NSAttributedString.composed(of: [
                footerText,
                " ",
                learnMoreLink,
            ]).styled(
                with: .font(.dynamicTypeCaption1),
                .color(.Signal.secondaryLabel),
            )
            textView.linkTextAttributes = [.foregroundColor: UIColor.Signal.label]

            return textView
        }()
    }

    // MARK: -

    private func didTapSafetyNumbersLearnMore() {
        Self.showUrl(URL.Support.safetyNumbers, from: self)
    }

    fileprivate func didTapKeyTransparencyLearnMore() {
        Self.showUrl(URL.Support.keyTransparency, from: self)
    }

    fileprivate static func showUrl(_ url: URL, from viewController: UIViewController) {
        let safariVC = SFSafariViewController(url: url)
        viewController.present(safariVC, animated: true)
    }

    fileprivate func didTapVerifyUnverify() {
        guard let deps else { return }

        deps.db.write { tx in
            let identityKey = fingerprint.theirAciIdentityKey

            let newVerificationState: VerificationState
            switch recipientVerificationState {
            case .verified:
                newVerificationState = .implicit(isAcknowledged: false)
            case .noLongerVerified, .implicit:
                newVerificationState = .verified
            }

            deps.identityManager.saveIdentityKey(identityKey, for: recipientAci, tx: tx)
            _ = deps.identityManager.setVerificationState(
                newVerificationState,
                of: identityKey.publicKey.keyBytes,
                for: SignalServiceAddress(recipientAci),
                isUserInitiatedChange: true,
                tx: tx,
            )
        }

        dismiss(animated: true)
    }

    private func shareFingerprint(from fromView: UIView) {
        let compareActivity = CompareSafetyNumbersActivity(delegate: self)

        let shareFormat = NSLocalizedString(
            "SAFETY_NUMBER_SHARE_FORMAT",
            comment: "Snippet to share {{safety number}} with a friend. sent e.g. via SMS",
        )
        let shareString = String.nonPluralLocalizedStringWithFormat(shareFormat, fingerprint.displayableText)

        let activityController = UIActivityViewController(
            activityItems: [shareString],
            applicationActivities: [compareActivity],
        )

        if let popoverPresentationController = activityController.popoverPresentationController {
            popoverPresentationController.sourceView = fromView
        }

        // This value was extracted by inspecting `activityType` in the activityController.completionHandler
        let iCloudActivityType = "com.apple.CloudDocsUI.AddToiCloudDrive"
        activityController.excludedActivityTypes = [
            .postToFacebook,
            .postToWeibo,
            .airDrop,
            .postToTwitter,
            .init(rawValue: iCloudActivityType), // This isn't being excluded. RADAR https://openradar.appspot.com/27493621
        ]

        present(activityController, animated: true)
    }

    fileprivate func didTapToScan() {
        let viewController = FingerprintScanViewController(
            recipientAci: recipientAci,
            recipientIdentity: recipientIdentity,
            fingerprint: self.fingerprint,
        )
        navigationController?.pushViewController(viewController, animated: true)
    }

    fileprivate func didTapKeyTransparencyButton(state: KeyTransparencyView.State) {
        owsPrecondition(keyTransparencyState.isEnabled)

        switch state {
        case .unableToVerify:
            present(KeyTransparencyNotAvailableHeroSheet(), animated: true)
        case .readyToVerify:
            guard
                let deps,
                let checkParams = keyTransparencyState.checkParams
            else { return }

            keyTransparencyView.state = .verifying
            Task { @MainActor [weak self] in
                do {
                    try await deps.keyTransparencyManager.performCheck(params: checkParams)
                    self?.keyTransparencyView.state = .verifiedSuccess
                } catch {
                    self?.keyTransparencyView.state = .verifiedFailure
                }
            }
        case .verifying:
            break
        case .verifiedSuccess:
            present(KeyTransparencySuccessHeroSheet(), animated: true)
        case .verifiedFailure:
            present(KeyTransparencyFailureHeroSheet(theirName: fingerprint.theirName), animated: true)
        }
    }
}

// MARK: -

extension FingerprintViewController: CompareSafetyNumbersActivityDelegate {

    func compareSafetyNumbersActivitySucceeded(activity: CompareSafetyNumbersActivity) {
        FingerprintScanViewController.showVerificationSucceeded(
            from: self,
            identityKey: fingerprint.theirAciIdentityKey,
            recipientAci: recipientAci,
            contactName: fingerprint.theirName,
            tag: "[\(type(of: self))]",
        )
    }

    func compareSafetyNumbersActivity(_ activity: CompareSafetyNumbersActivity, failedWithError error: CompareSafetyNumberError) {
        FingerprintScanViewController.showVerificationFailed(
            from: self,
            isUserError: error == .userError,
            localizedErrorDescription: error.localizedError,
            tag: "[\(type(of: self))]",
        )
    }
}

// MARK: -

private final class KeyTransparencyNotAvailableHeroSheet: HeroSheetViewController {
    init() {
        super.init(
            hero: .image(UIImage(named: "info")!, tintColor: .Signal.label),
            title: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_NOT_AVAILABLE_SHEET_TITLE",
                comment: "Title for a sheet explaining that encryption auto-verification is not available.",
            ),
            body: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_NOT_AVAILABLE_SHEET_BODY",
                comment: "Body for a sheet explaining that encryption auto-verification is not available.",
            ),
            primaryButton: .dismissing(title: CommonStrings.okButton),
        )
    }
}

// MARK: -

private final class KeyTransparencySuccessHeroSheet: HeroSheetViewController {
    init() {
        super.init(
            hero: .image(UIImage(named: "check-circle")!, tintColor: .Signal.label),
            title: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_SUCCESS_SHEET_TITLE",
                comment: "Title for a sheet explaining that encryption auto-verification succeeded.",
            ),
            body: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_SUCCESS_SHEET_BODY",
                comment: "Body for a sheet explaining that encryption auto-verification succeeded.",
            ),
            primaryButton: .dismissing(title: CommonStrings.okButton),
        )
    }
}

// MARK: -

private final class KeyTransparencyFailureHeroSheet: HeroSheetViewController {
    init(theirName: String) {
        super.init(
            hero: .image(UIImage(named: "check-circle")!, tintColor: .Signal.label),
            title: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_FAILURE_SHEET_TITLE",
                comment: "Title for a sheet explaining that encryption auto-verification did not succeed.",
            ),
            body: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_FAILURE_SHEET_BODY_FORMAT",
                    comment: "Body for a sheet explaining that encryption auto-verification did not succeed. Embeds {{ 1: the contact's name }}.",
                ),
                theirName,
            ),
            primaryButton: .dismissing(title: CommonStrings.okButton),
        )
    }
}

// MARK: -

private final class KeyTransparencyFirstTimeEducationHeroSheet: HeroSheetViewController {
    init(onContinue: @MainActor @escaping () -> Void) {
        super.init(
            hero: .image(UIImage(named: "safety-number-verification")!),
            title: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_EDUCATION_SHEET_TITLE",
                comment: "Title for a sheet introducing Key Transparency.",
            ),
            body: OWSLocalizedString(
                "SAFETY_NUMBERS_AUTOMATIC_VERIFICATION_EDUCATION_SHEET_BODY",
                comment: "Body for a sheet introducing Key Transparency.",
            ),
            primaryButton: HeroSheetViewController.Button(
                title: CommonStrings.continueButton,
                action: { sheet in
                    sheet.dismiss(animated: true) {
                        onContinue()
                    }
                },
            ),
        )
    }
}

// MARK: -

#if DEBUG

private extension IdentityKey {
    static func forPreview() -> IdentityKey {
        let randomBytes = Randomness.generateRandomBytes(32)
        return IdentityKey(publicKey: try! PublicKey(keyData: randomBytes))
    }
}

private final class FingerprintPreviewViewController: UINavigationController {
    init(
        theirVerificationState: VerificationState = .verified,
        keyTransparencyIsEnabled: Bool = true,
        keyTransparencyViewInitialState: FingerprintViewController.KeyTransparencyView.State = .readyToVerify,
    ) {
        let recipientAci = Aci.randomForTesting()
        let recipientIdentityKey = IdentityKey.forPreview()

        let fingerprintViewController = FingerprintViewController(
            recipientAci: recipientAci,
            recipientIdentity: OWSRecipientIdentity(
                uniqueId: UUID().uuidString,
                identityKey: recipientIdentityKey.publicKey.keyBytes,
                isFirstKnownKey: true,
                createdAt: Date().addingTimeInterval(-.week),
                verificationState: .default,
            ),
            recipientVerificationState: theirVerificationState,
            fingerprint: OWSFingerprint(
                myAci: .randomForTesting(),
                theirAci: recipientAci,
                myAciIdentityKey: .forPreview(),
                theirAciIdentityKey: recipientIdentityKey,
                theirName: "Boba Fett",
            ),
            keyTransparencyState: FingerprintViewController.KeyTransparencyState(
                isEnabled: keyTransparencyIsEnabled,
                checkParams: nil,
                viewInitialState: keyTransparencyViewInitialState,
            ),
            deps: nil,
        )

        super.init(rootViewController: fingerprintViewController)
    }

    required init?(coder aDecoder: NSCoder) { fatalError("") }
}

@available(iOS 17, *)
#Preview("Not Verified") {
    FingerprintPreviewViewController(theirVerificationState: .noLongerVerified)
}

@available(iOS 17, *)
#Preview("Verified") {
    FingerprintPreviewViewController(theirVerificationState: .verified)
}

@available(iOS 17, *)
#Preview("KT Unavailable") {
    FingerprintPreviewViewController(keyTransparencyViewInitialState: .unableToVerify)
}

@available(iOS 17, *)
#Preview("KT Running") {
    FingerprintPreviewViewController(keyTransparencyViewInitialState: .verifying)
}

@available(iOS 17, *)
#Preview("KT Success") {
    FingerprintPreviewViewController(keyTransparencyViewInitialState: .verifiedSuccess)
}

@available(iOS 17, *)
#Preview("KT Failure") {
    FingerprintPreviewViewController(keyTransparencyViewInitialState: .verifiedFailure)
}

@available(iOS 17, *)
#Preview("KT Disabled") {
    FingerprintPreviewViewController(keyTransparencyIsEnabled: false)
}

#endif