499 lines
20 KiB
Swift
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
|
|
}
|
|
}
|