996 lines
39 KiB
Swift
996 lines
39 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
|
|
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
|