TM-SGNL-iOS/SignalUI/SafetyNumbers/FingerprintViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

499 lines
20 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
import Lottie
import PureLayout
import SafariServices
public import SignalServiceKit
import UIKit
public class FingerprintViewController: OWSViewController, OWSNavigationChildController {
public class func present(
for theirAci: Aci?,
from viewController: UIViewController
) {
let fingerprintResult = SSKEnvironment.shared.databaseStorageRef.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: SSKEnvironment.shared.contactManagerRef,
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 = SSKEnvironment.shared.databaseStorageRef.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/360007060632")!
let safariVC = SFSafariViewController(url: learnMoreUrl)
viewController.present(safariVC, animated: true)
}
@objc
private func didTapVerifyUnverify(_ gestureRecognizer: UITapGestureRecognizer) {
guard gestureRecognizer.state == .recognized else { return }
SSKEnvironment.shared.databaseStorageRef.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
}
}