// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation 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 ) { let fingerprintResult = databaseStorage.read { (tx) -> OWSFingerprintBuilder.FingerprintResult? in guard let theirAci else { return nil } let identityManager = DependenciesBridge.shared.identityManager let theirAddress = SignalServiceAddress(theirAci) guard let theirRecipientIdentity = identityManager.recipientIdentity(for: theirAddress, tx: tx.asV2Read) else { return nil } return OWSFingerprintBuilder( contactsManager: contactsManager, identityManager: identityManager, tsAccountManager: DependenciesBridge.shared.tsAccountManager ).fingerprints( theirAci: theirAci, theirRecipientIdentity: theirRecipientIdentity, tx: tx ) } guard let fingerprintResult 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.showLearnMoreUrl(from: vc) })) actionSheet.addAction(OWSActionSheets.cancelAction) viewController.presentActionSheet(actionSheet) return } let fingerprintViewController = FingerprintViewController( fingerprint: fingerprintResult.fingerprint, recipientAci: fingerprintResult.theirAci, recipientIdentity: fingerprintResult.theirRecipientIdentity ) let navigationController = OWSNavigationController(rootViewController: fingerprintViewController) viewController.present(navigationController, animated: true) } public var preferredNavigationBarStyle: OWSNavigationBarStyle { return .solid } public var navbarBackgroundColorOverride: UIColor? { return Self.backgroundColor } public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .portrait } private let recipientAci: Aci private let recipientIdentity: OWSRecipientIdentity private let identityKey: Data private let fingerprint: OWSFingerprint private var isVerified = false public init( fingerprint: OWSFingerprint, recipientAci: Aci, recipientIdentity: OWSRecipientIdentity ) { self.recipientAci = recipientAci // By capturing the identity key when we enter these views, we prevent the edge case // where the user verifies a key that we learned about while this view was open. self.recipientIdentity = recipientIdentity self.identityKey = recipientIdentity.identityKey self.fingerprint = fingerprint super.init() title = NSLocalizedString("PRIVACY_VERIFICATION_TITLE", comment: "Navbar title") navigationItem.leftBarButtonItem = .doneButton(dismissingFrom: self) identityStateChangeObserver = NotificationCenter.default.addObserver( forName: .identityStateDidChange, object: nil, queue: .main) { [weak self] _ in self?.identityStateDidChange() } } deinit { if let identityStateChangeObserver { NotificationCenter.default.removeObserver(identityStateChangeObserver) } } private static var backgroundColor: UIColor { return Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray02 } public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Self.backgroundColor configureUI() } public override func themeDidChange() { super.themeDidChange() view.backgroundColor = Self.backgroundColor updateVerificationStateLabel() setInstructionsText() setVerifyUnverifyButtonColors() } // MARK: UI private lazy var fingerprintCard = FingerprintCard(fingerprint: fingerprint, controller: self) private lazy var instructionsTextView: UITextView = { let textView = LinkingTextView() textView.delegate = self return textView }() private func setInstructionsText() { let instructionsFormat = OWSLocalizedString( "VERIFY_SAFETY_NUMBER_INSTRUCTIONS", comment: "Instructions for verifying your safety number. Embeds {{contact's name}}" ) // Link doesn't matter, we will override tap behavior. let learnMoreString = CommonStrings.learnMore.styled(with: .link(URL(string: Constants.learnMoreUrl)!)) instructionsTextView.attributedText = NSAttributedString.composed(of: [ String(format: instructionsFormat, fingerprint.theirName), " ", learnMoreString ]).styled( with: .font(.dynamicTypeFootnote), .color(Theme.secondaryTextAndIconColor), .alignment(.center) ) instructionsTextView.linkTextAttributes = [ .foregroundColor: Theme.primaryTextColor, .underlineColor: UIColor.clear, .underlineStyle: NSUnderlineStyle.single.rawValue ] } private lazy var verifyUnverifyButtonLabel = UILabel() private lazy var verifyUnverifyPillbox = PillBoxView() private lazy var verifyUnverifyButton: UIView = { verifyUnverifyPillbox.layer.masksToBounds = true verifyUnverifyPillbox.accessibilityIdentifier = "FingerprintViewController.verifyUnverifyButton" verifyUnverifyPillbox.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapVerifyUnverify))) verifyUnverifyButtonLabel.font = .systemFont(ofSize: 13, weight: .bold) verifyUnverifyButtonLabel.textAlignment = .center verifyUnverifyButtonLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) verifyUnverifyPillbox.addSubview(verifyUnverifyButtonLabel) verifyUnverifyButtonLabel.autoPinWidthToSuperview(withMargin: 24) verifyUnverifyButtonLabel.autoPinHeightToSuperview(withMargin: 12) return verifyUnverifyPillbox }() private func setVerifyUnverifyButtonColors() { verifyUnverifyButtonLabel.textColor = Theme.primaryTextColor verifyUnverifyPillbox.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray80 : .white } private func configureUI() { let scrollView = UIScrollView() view.addSubview(scrollView) let containerView = UIView() scrollView.addSubview(containerView) scrollView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) containerView.autoPinEdges(toEdgesOf: scrollView) containerView.autoPinWidth(toWidthOf: view) containerView.addSubview(fingerprintCard) containerView.addSubview(instructionsTextView) view.addSubview(verifyUnverifyButton) fingerprintCard.autoPinEdge(toSuperviewSafeArea: .top, withInset: 56) fingerprintCard.autoPinWidth(toWidthOf: containerView, offset: -.scaleFromIPhone5To7Plus(60, 105)) fingerprintCard.autoHCenterInSuperview() instructionsTextView.autoPinEdge(.leading, to: .leading, of: containerView, withOffset: .scaleFromIPhone5To7Plus(18, 28)) instructionsTextView.autoPinEdge(.trailing, to: .trailing, of: containerView, withOffset: -.scaleFromIPhone5To7Plus(18, 28)) instructionsTextView.autoPinEdge(.bottom, to: .bottom, of: scrollView, withOffset: -8) verifyUnverifyButton.autoHCenterInSuperview() verifyUnverifyButton.autoPinEdge(.top, to: .bottom, of: scrollView, withOffset: .scaleFromIPhone5To7Plus(12, 24)) verifyUnverifyButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: .scaleFromIPhone5To7Plus(16, 40)) instructionsTextView.autoPinEdge(.top, to: .bottom, of: fingerprintCard, withOffset: 24) updateVerificationStateLabel() setInstructionsText() setVerifyUnverifyButtonColors() } private func updateVerificationStateLabel() { let identityManager = DependenciesBridge.shared.identityManager isVerified = databaseStorage.read { tx in return identityManager.verificationState(for: SignalServiceAddress(recipientAci), tx: tx.asV2Read) == .verified } if isVerified { verifyUnverifyButtonLabel.text = NSLocalizedString( "PRIVACY_UNVERIFY_BUTTON", comment: "Button that lets user mark another user's identity as unverified." ) } else { verifyUnverifyButtonLabel.text = OWSLocalizedString( "PRIVACY_VERIFY_BUTTON", comment: "Button that lets user mark another user's identity as verified." ) } view.setNeedsLayout() } // MARK: - Fingerprint Card class FingerprintCard: UIView { private let fingerprint: OWSFingerprint private weak var controller: FingerprintViewController? init(fingerprint: OWSFingerprint, controller: FingerprintViewController) { self.fingerprint = fingerprint self.controller = controller super.init(frame: .zero) layer.cornerRadius = Constants.cornerRadius self.backgroundColor = UIColor(rgbHex: 0x506ecd) addSubview(shareButton) addSubview(qrCodeView) addSubview(safetyNumberLabel) 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.autoPinEdge(.bottom, to: .bottom, of: self, withOffset: -.scaleFromIPhone5To7Plus(27, 47)) safetyNumberLabel.autoHCenterInSuperview() // 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( Theme.iconImage(.buttonShare).withRenderingMode(.alwaysTemplate), 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 = Theme.lightThemeSecondaryTextAndIconColor containerView.addSubview(scanLabel) scanLabel.autoHCenterInSuperview() 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.accessibilityIdentifier = "FingerprintViewController.fingerprintLabel" label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapSafetyNumber))) return label }() @objc func didTapToScan() { controller?.didTapToScan() } @objc func didTapShare() { controller?.shareFingerprint(from: shareButton) } @objc func didTapSafetyNumber() { controller?.shareFingerprint(from: safetyNumberLabel) } enum Constants { static let cornerRadius: CGFloat = 18 } } // MARK: PillBoxView class PillBoxView: UIView { override var bounds: CGRect { didSet { self.layer.cornerRadius = bounds.height / 2 } } } // MARK: Actions private func didTapLearnMore() { Self.showLearnMoreUrl(from: self) } fileprivate static func showLearnMoreUrl(from viewController: UIViewController) { let learnMoreUrl = URL(string: "https://support.signal.org/hc/articles/213134107")! let safariVC = SFSafariViewController(url: learnMoreUrl) viewController.present(safariVC, animated: true) } @objc private func didTapVerifyUnverify(_ gestureRecognizer: UITapGestureRecognizer) { guard gestureRecognizer.state == .recognized else { return } databaseStorage.write { tx in let identityManager = DependenciesBridge.shared.identityManager let newVerificationState: VerificationState = isVerified ? .implicit(isAcknowledged: false) : .verified identityManager.saveIdentityKey(identityKey, for: recipientAci, tx: tx.asV2Write) _ = identityManager.setVerificationState( newVerificationState, of: identityKey, for: SignalServiceAddress(recipientAci), isUserInitiatedChange: true, tx: tx.asV2Write ) } 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(format: 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) } // MARK: Notifications private var identityStateChangeObserver: Any? private func identityStateDidChange() { AssertIsOnMainThread() updateVerificationStateLabel() } // MARK: - Constants enum Constants { static let cardHInset: CGFloat = .scaleFromIPhone5To7Plus(30, 53) static let learnMoreUrl = "https://support.signal.org/learnMore" } } extension FingerprintViewController: CompareSafetyNumbersActivityDelegate { public func compareSafetyNumbersActivitySucceeded(activity: CompareSafetyNumbersActivity) { FingerprintScanViewController.showVerificationSucceeded( from: self, identityKey: identityKey, recipientAci: recipientAci, contactName: fingerprint.theirName, tag: logTag ) } public func compareSafetyNumbersActivity(_ activity: CompareSafetyNumbersActivity, failedWithError error: Error) { let isUserError = (error as NSError).code == OWSErrorCode.userError.rawValue FingerprintScanViewController.showVerificationFailed( from: self, isUserError: isUserError, localizedErrorDescription: error.userErrorDescription, tag: logTag ) } } extension FingerprintViewController: UITextViewDelegate { public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { if URL.absoluteString == Constants.learnMoreUrl { self.didTapLearnMore() } return false } }