// // 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 DatabaseResult { let theirAci: Aci let theirName: String let theirVerificationState: VerificationState let fingerprint: CombinedFingerprints let keyTransparencyState: KeyTransparencyState let shouldShowKeyTransparencyEducation: Bool } let contactManager = 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 databaseResult = db.read { tx -> DatabaseResult? in guard let theirAci else { return nil } let theirAddress = SignalServiceAddress(theirAci) let theirName = contactManager.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 } let keyTransparencyIsEnabled = keyTransparencyManager.isEnabled(tx: tx) let keyTransparencyCheckParams = keyTransparencyManager.prepareCheck( aci: theirAci, localIdentifiers: localIdentifiers, tx: tx, ) let keyTransparencyShouldShowEducation = keyTransparencyStore.shouldShowFirstTimeEducation(tx: tx) return DatabaseResult( theirAci: theirAci, theirName: theirName, theirVerificationState: theirVerificationState, fingerprint: CombinedFingerprints( local: .derive(forAci: localIdentifiers.aci, identityKey: myAciIdentityKey), remote: .derive(forAci: theirAci, identityKey: theirAciIdentityKey), ), keyTransparencyState: KeyTransparencyState( isEnabled: keyTransparencyIsEnabled, checkParams: keyTransparencyCheckParams, viewInitialState: keyTransparencyCheckParams == nil ? .unableToVerify : .readyToVerify, ), shouldShowKeyTransparencyEducation: keyTransparencyShouldShowEducation, ) } guard let databaseResult 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: databaseResult.theirAci, recipientName: databaseResult.theirName, recipientVerificationState: databaseResult.theirVerificationState, fingerprint: databaseResult.fingerprint, keyTransparencyState: databaseResult.keyTransparencyState, deps: FingerprintViewController.Deps( db: db, identityManager: identityManager, keyTransparencyManager: keyTransparencyManager, ), ) let navigationController = OWSNavigationController(rootViewController: fingerprintViewController) if databaseResult.shouldShowKeyTransparencyEducation { 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 recipientName: String private var recipientVerificationState: VerificationState { didSet { self.fingerprintCard.theirVerificationState = recipientVerificationState } } private let fingerprint: CombinedFingerprints private let keyTransparencyState: KeyTransparencyState private let deps: Deps? fileprivate init( recipientAci: Aci, recipientName: String, recipientVerificationState: VerificationState, fingerprint: CombinedFingerprints, 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.recipientName = recipientName 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) NotificationCenter.default.addObserver( self, selector: #selector(self.identityStateDidChange(_:)), name: .identityStateDidChange, object: nil, ) } public var preferredNavigationBarStyle: OWSNavigationBarStyle { return .solid } override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .portrait } override public func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .Signal.groupedBackground configureUI() } @objc private func identityStateDidChange(_ notification: Notification) { let databaseStorage = SSKEnvironment.shared.databaseStorageRef let identityManager = DependenciesBridge.shared.identityManager let recipientIdentity = databaseStorage.read { tx in return identityManager.recipientIdentity(for: SignalServiceAddress(recipientAci), tx: tx) } // Ensure the identity key hasn't changed (this influences the Safety // Number). If it changes, we want to dismiss the view so that we can // present it again with the new Safety Number. guard let recipientIdentity, (try? recipientIdentity.identityKeyObject) == fingerprint.remote.identityKey else { self.dismiss(animated: true) return } // The verification state may change (e.g., after scanning a QR code). We // want the UI to reflect the latest value. self.recipientVerificationState = VerificationState(recipientIdentity.verificationState) } // 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}}", ), self.recipientName, ) // 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: CombinedFingerprints var theirVerificationState: VerificationState { didSet { updateVerifyUnverifyButtonTitle() } } private weak var controller: FingerprintViewController? init( fingerprint: CombinedFingerprints, 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) updateVerifyUnverifyButtonTitle() 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) button.accessibilityLabel = CommonStrings.shareButton 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 return UIButton( configuration: configuration, primaryAction: UIAction { [weak self] _ in self?.controller?.didTapVerifyUnverify() }, ) }() private func updateVerifyUnverifyButtonTitle() { var configuration = self.verifyUnverifyButton.configuration 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.", ) } self.verifyUnverifyButton.configuration = configuration } @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: Theme.iconImage(.checkCircleFill)) 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.remote.identityKey let newVerificationState: VerificationState switch recipientVerificationState { case .verified: newVerificationState = .implicit(isAcknowledged: false) case .noLongerVerified, .implicit: newVerificationState = .verified } deps.identityManager.saveIdentityKey( identityKey, for: recipientAci, shouldUpdateStorageService: true, 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: self.recipientAci, recipientName: self.recipientName, 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: recipientName), animated: true) } } } // MARK: - extension FingerprintViewController: CompareSafetyNumbersActivityDelegate { func compareSafetyNumbersActivitySucceeded(activity: CompareSafetyNumbersActivity) { FingerprintScanViewController.showVerificationSucceeded( from: self, identityKey: fingerprint.remote.identityKey, recipientAci: recipientAci, contactName: recipientName, 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, recipientName: "Boba Fett", recipientVerificationState: theirVerificationState, fingerprint: CombinedFingerprints( local: .derive(forAci: .randomForTesting(), identityKey: .forPreview()), remote: .derive(forAci: recipientAci, identityKey: recipientIdentityKey), ), 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