TM-SGNL-iOS/Signal/Registration/UserInterface/RegistrationVerificationViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

707 lines
26 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
import SignalUI
// MARK: - RegistrationVerificationValidationError
public enum RegistrationVerificationValidationError: Equatable {
case invalidVerificationCode(invalidCode: String)
/// We tried to send via sms and failed, but voice code might work
/// so we are on this screen now. An error should be shown.
case failedInitialTransport(failedTransport: Registration.CodeTransport)
/// A third party provider failed to send an sms or call to the session's number.
/// May be permanent (the user should probably use a different number)
/// or transient (the user should try again later).
/// Regardless we let the user submit a code or retry.
case providerFailure(isPermanent: Bool)
/// Requesting a code failed with some unknown error; show a
/// generic dialog and let the user dismiss. They might have actually
/// gotten a code, so let them submit or resend.
case genericCodeRequestError(isNetworkError: Bool)
// These three errors are what happens when we try and
// take the three respective actions but are rejected
// with a timeout. The State should have timeout information.
case smsResendTimeout
case voiceResendTimeout
case submitCodeTimeout
}
// MARK: - RegistrationVerificationState
public struct RegistrationVerificationState: Equatable {
let e164: E164
let nextSMSDate: Date?
let nextCallDate: Date?
let nextVerificationAttemptDate: Date?
// If false, no option to go back and change e164 will be shown.
let canChangeE164: Bool
let showHelpText: Bool
let validationError: RegistrationVerificationValidationError?
public enum ExitConfiguration: Equatable {
case noExitAllowed
case exitReRegistration
case exitChangeNumber
}
let exitConfiguration: ExitConfiguration
}
// MARK: - RegistrationVerificationPresenter
protocol RegistrationVerificationPresenter: AnyObject {
func returnToPhoneNumberEntry()
func requestSMSCode()
func requestVoiceCode()
func submitVerificationCode(_ code: String)
func exitRegistration()
}
// MARK: - RegistrationVerificationViewController
class RegistrationVerificationViewController: OWSViewController {
public init(
state: RegistrationVerificationState,
presenter: RegistrationVerificationPresenter
) {
self.state = state
self.presenter = presenter
super.init()
}
@available(*, unavailable)
public override init() {
owsFail("This should not be called")
}
public func updateState(_ state: RegistrationVerificationState) {
self.state = state
}
deinit {
nowTimer?.invalidate()
nowTimer = nil
}
// MARK: Internal state
private var state: RegistrationVerificationState {
didSet { render() }
}
private weak var presenter: RegistrationVerificationPresenter?
private var now = Date() {
didSet { render() }
}
private var nowTimer: Timer?
private var canRequestSMSCode: Bool {
guard let nextDate = state.nextSMSDate else { return false }
return nextDate <= now
}
private var canRequestVoiceCode: Bool {
guard let nextDate = state.nextCallDate else { return false }
return nextDate <= now
}
private var previouslyRenderedValidationError: RegistrationVerificationValidationError?
// MARK: Rendering
private func button(
title: String = "",
selector: Selector,
accessibilityIdentifierSuffix: String
) -> UIButton {
let result = UIButton(type: .system)
result.addTarget(self, action: selector, for: .touchUpInside)
result.setTitle(title, for: .normal)
if let titleLabel = result.titleLabel {
titleLabel.font = .dynamicTypeSubheadlineClamped
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.textAlignment = .center
result.heightAnchor.constraint(
greaterThanOrEqualTo: titleLabel.heightAnchor
).isActive = true
} else {
owsFailBeta("Button has no title label")
}
result.accessibilityIdentifier = "registration.verification.\(accessibilityIdentifierSuffix)"
return result
}
private lazy var titleLabel: UILabel = {
let result = UILabel.titleLabelForRegistration(text: OWSLocalizedString(
"ONBOARDING_VERIFICATION_TITLE_LABEL",
comment: "Title label for the onboarding verification page"
))
result.accessibilityIdentifier = "registration.verification.titleLabel"
return result
}()
private var explanationLabelText: String {
let format = OWSLocalizedString(
"ONBOARDING_VERIFICATION_TITLE_DEFAULT_FORMAT",
comment: "Format for the title of the 'onboarding verification' view. Embeds {{the user's phone number}}."
)
return String(format: format, state.e164.stringValue.e164FormattedAsPhoneNumberWithoutBreaks)
}
private lazy var explanationLabel: UILabel = {
let result = UILabel.explanationLabelForRegistration(text: explanationLabelText)
result.accessibilityIdentifier = "registration.verification.explanationLabel"
return result
}()
private lazy var wrongNumberButton = button(
title: OWSLocalizedString(
"ONBOARDING_VERIFICATION_BACK_LINK",
comment: "Label for the link that lets users change their phone number in the onboarding views."
),
selector: #selector(didTapWrongNumberButton),
accessibilityIdentifierSuffix: "wrongNumberButton"
)
private lazy var verificationCodeView: RegistrationVerificationCodeView = {
let result = RegistrationVerificationCodeView()
result.delegate = self
return result
}()
private lazy var helpButton = button(
title: OWSLocalizedString(
"ONBOARDING_VERIFICATION_HELP_LINK",
comment: "Label for a button to get help entering a verification code when registering."
),
selector: #selector(didTapHelpButton),
accessibilityIdentifierSuffix: "helpButton"
)
private lazy var resendSMSCodeButton = button(
selector: #selector(didTapResendSMSCode),
accessibilityIdentifierSuffix: "resendSMSCodeButton"
)
private lazy var requestVoiceCodeButton = button(
selector: #selector(didTapSendVoiceCode),
accessibilityIdentifierSuffix: "requestVoiceCodeButton"
)
private lazy var contextButton: ContextMenuButton = {
let result = ContextMenuButton(empty: ())
result.autoSetDimensions(to: .square(40))
return result
}()
private lazy var contextBarButton = UIBarButtonItem(
customView: contextButton,
accessibilityIdentifier: "registration.verificationCode.contextButton"
)
public override func viewDidLoad() {
super.viewDidLoad()
navigationItem.setHidesBackButton(true, animated: false)
initialRender()
// We don't need this timer in all cases but it's simpler to start it in all cases.
nowTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.now = Date()
}
}
private var isViewAppeared = false
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
verificationCodeView.becomeFirstResponder()
showValidationErrorUiIfNecessary()
isViewAppeared = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isViewAppeared = false
}
public override func themeDidChange() {
super.themeDidChange()
render()
}
private func initialRender() {
let scrollView = UIScrollView()
view.addSubview(scrollView)
scrollView.autoPinWidthToSuperview()
scrollView.autoPinEdge(.top, to: .top, of: keyboardLayoutGuideViewSafeArea)
scrollView.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 12
stackView.directionalLayoutMargins = .layoutMarginsForRegistration(traitCollection.horizontalSizeClass)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.setContentHuggingHigh()
scrollView.addSubview(stackView)
stackView.autoPinWidth(toWidthOf: scrollView)
stackView.heightAnchor.constraint(
greaterThanOrEqualTo: scrollView.contentLayoutGuide.heightAnchor
).isActive = true
stackView.heightAnchor.constraint(
greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor
).isActive = true
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(explanationLabel)
stackView.addArrangedSubview(wrongNumberButton)
stackView.setCustomSpacing(24, after: wrongNumberButton)
stackView.addArrangedSubview(verificationCodeView)
stackView.setCustomSpacing(24, after: verificationCodeView)
stackView.addArrangedSubview(helpButton)
stackView.addArrangedSubview(UIView.vStretchingSpacer())
let resendButtonsContainer = UIStackView(arrangedSubviews: [
resendSMSCodeButton,
requestVoiceCodeButton
])
resendButtonsContainer.axis = .horizontal
resendButtonsContainer.distribution = .fillEqually
stackView.addArrangedSubview(resendButtonsContainer)
render()
}
private func render() {
switch state.exitConfiguration {
case .noExitAllowed:
navigationItem.leftBarButtonItem = nil
case .exitReRegistration:
navigationItem.leftBarButtonItem = contextBarButton
contextButton.setActions(actions: [
UIAction(
title: OWSLocalizedString(
"EXIT_REREGISTRATION",
comment: "Button to exit re-registration, shown in context menu."
),
handler: { [weak self] _ in
self?.presenter?.exitRegistration()
}
)
])
case .exitChangeNumber:
navigationItem.leftBarButtonItem = contextBarButton
contextButton.setActions(actions: [
UIAction(
title: OWSLocalizedString(
"EXIT_CHANGE_NUMBER",
comment: "Button to exit change number, shown in context menu."
),
handler: { [weak self] _ in
self?.presenter?.exitRegistration()
}
)
])
}
contextButton.setImage(Theme.iconImage(.buttonMore), for: .normal)
contextButton.tintColor = Theme.accentBlueColor
renderResendButton(
button: resendSMSCodeButton,
date: state.nextSMSDate,
enabledString: OWSLocalizedString(
"ONBOARDING_VERIFICATION_RESEND_CODE_BUTTON",
comment: "Label for button to resend SMS verification code."
),
countdownFormat: OWSLocalizedString(
"ONBOARDING_VERIFICATION_RESEND_CODE_COUNTDOWN_FORMAT",
comment: "Format string for button counting down time until SMS code can be resent. Embeds {{time remaining}}."
)
)
renderResendButton(
button: requestVoiceCodeButton,
date: state.nextCallDate,
enabledString: OWSLocalizedString(
"ONBOARDING_VERIFICATION_CALL_ME_BUTTON",
comment: "Label for button to perform verification with a phone call."
),
countdownFormat: OWSLocalizedString(
"ONBOARDING_VERIFICATION_CALL_ME_COUNTDOWN_FORMAT",
comment: "Format string for button counting down time until phone call verification can be performed. Embeds {{time remaining}}."
)
)
if isViewAppeared {
showValidationErrorUiIfNecessary()
}
view.backgroundColor = Theme.backgroundColor
titleLabel.textColor = .colorForRegistrationTitleLabel
explanationLabel.textColor = .colorForRegistrationExplanationLabel
explanationLabel.text = explanationLabelText
wrongNumberButton.isHidden = state.canChangeE164.negated
helpButton.isHidden = state.showHelpText.negated
verificationCodeView.updateColors()
}
private lazy var retryAfterFormatter: DateFormatter = {
let result = DateFormatter()
result.dateFormat = "m:ss"
result.timeZone = TimeZone(identifier: "UTC")!
return result
}()
private func renderResendButton(
button: UIButton,
date: Date?,
enabledString: String,
countdownFormat: String
) {
// UIButton will flash when we update the title.
UIView.performWithoutAnimation {
defer { button.layoutIfNeeded() }
guard let date else {
button.isHidden = true
button.isEnabled = false
return
}
if date <= now {
button.isEnabled = true
button.setTitle(enabledString, for: .normal)
} else {
button.isEnabled = false
button.setTitle(
{
let timeRemaining = max(date.timeIntervalSince(now), 0)
let durationString = retryAfterFormatter.string(from: Date(timeIntervalSinceReferenceDate: timeRemaining))
return String(format: countdownFormat, durationString)
}(),
for: .normal
)
}
}
}
private func showValidationErrorUiIfNecessary() {
let oldError = previouslyRenderedValidationError
let newError = state.validationError
previouslyRenderedValidationError = newError
guard let newError, oldError != newError else { return }
switch newError {
case .invalidVerificationCode(let code):
let message = OWSLocalizedString(
"REGISTRATION_VERIFICATION_ERROR_INVALID_VERIFICATION_CODE",
comment: "During registration and re-registration, users may have to enter a code to verify ownership of their phone number. If they enter an invalid code, they will see this error message."
)
if verificationCodeView.verificationCode == code {
verificationCodeView.clear()
}
OWSActionSheets.showActionSheet(title: nil, message: message)
case .providerFailure(let isPermanent):
let message: String
if isPermanent {
message = OWSLocalizedString(
"REGISTRATION_PROVIDER_FAILURE_MESSAGE_PERMANENT",
comment: "Error shown if an SMS/call service provider is permanently unable to send a verification code to the provided number."
)
} else {
message = OWSLocalizedString(
"REGISTRATION_PROVIDER_FAILURE_MESSAGE_TRANSIENT",
comment: "Error shown if an SMS/call service provider is temporarily unable to send a verification code to the provided number."
)
}
OWSActionSheets.showActionSheet(title: nil, message: message)
case .genericCodeRequestError(let isNetworkError):
let title: String?
let message: String
if isNetworkError {
title = OWSLocalizedString(
"REGISTRATION_NETWORK_ERROR_TITLE",
comment: "A network error occurred during registration, and an error is shown to the user. This is the title on that error sheet."
)
message = OWSLocalizedString(
"REGISTRATION_NETWORK_ERROR_BODY",
comment: "A network error occurred during registration, and an error is shown to the user. This is the body on that error sheet."
)
} else {
title = nil
message = CommonStrings.somethingWentWrongTryAgainLaterError
}
OWSActionSheets.showActionSheet(title: title, message: message)
case .failedInitialTransport(let failedTransport):
let errorMessage: String
let alternativeTransportButtonText: String
let alternativeTransport: Registration.CodeTransport
switch failedTransport {
case .sms:
errorMessage = OWSLocalizedString(
"REGISTRATION_SMS_CODE_FAILED_TRY_VOICE_ERROR",
comment: "Error message when sending a verification code via sms failed, but resending via voice call might succeed."
)
alternativeTransportButtonText = OWSLocalizedString(
"REGISTRATION_SMS_CODE_FAILED_TRY_VOICE_BUTTON",
comment: "Button when sending a verification code via sms failed, but resending via voice call might succeed."
)
alternativeTransport = .voice
case .voice:
errorMessage = OWSLocalizedString(
"REGISTRATION_VOICE_CODE_FAILED_TRY_SMS_ERROR",
comment: "Error message when sending a verification code via voice call failed, but resending via sms might succeed."
)
alternativeTransportButtonText = OWSLocalizedString(
"REGISTRATION_VOICE_CODE_FAILED_TRY_SMS_BUTTON",
comment: "Button when sending a verification code via voice call failed, but resending via sms might succeed."
)
alternativeTransport = .sms
}
let actionSheet = ActionSheetController(title: nil, message: errorMessage)
actionSheet.addAction(.init(
title: alternativeTransportButtonText,
accessibilityIdentifier: nil,
handler: { [weak self] _ in
switch alternativeTransport {
case .sms:
self?.presenter?.requestSMSCode()
case .voice:
self?.presenter?.requestVoiceCode()
}
}
))
actionSheet.addAction(.init(
title: CommonStrings.cancelButton,
accessibilityIdentifier: nil
))
self.present(actionSheet, animated: true)
return
case .smsResendTimeout, .voiceResendTimeout:
let message = OWSLocalizedString(
"REGISTER_RATE_LIMITING_ALERT",
comment: "Body of action sheet shown when rate-limited during registration."
)
OWSActionSheets.showActionSheet(title: nil, message: message)
case .submitCodeTimeout:
guard let nextVerificationAttemptDate = state.nextVerificationAttemptDate else {
return
}
let now = Date()
if now >= nextVerificationAttemptDate {
return
}
let format = OWSLocalizedString(
"REGISTRATION_SUBMIT_CODE_RATE_LIMIT_ALERT_FORMAT",
comment: "Alert shown when submitting a verification code too many times. Embeds {{ duration }}, such as \"5:00\""
)
let formatter: DateFormatter = {
let result = DateFormatter()
result.dateFormat = "m:ss"
result.timeZone = TimeZone(identifier: "UTC")!
return result
}()
let timeRemaining = max(nextVerificationAttemptDate.timeIntervalSince(now), 0)
let durationString = formatter.string(from: Date(timeIntervalSinceReferenceDate: timeRemaining))
let message = String(format: format, durationString)
OWSActionSheets.showActionSheet(title: nil, message: message)
}
}
// MARK: Events
@objc
private func didTapWrongNumberButton() {
Logger.info("")
presenter?.returnToPhoneNumberEntry()
}
@objc
private func didTapHelpButton() {
Logger.info("")
self.present(RegistrationVerificationHelpSheetViewController(), animated: true)
}
@objc
private func didTapResendSMSCode() {
Logger.info("")
guard canRequestSMSCode else { return }
presentActionSheet(.forRegistrationVerificationConfirmation(
mode: .sms,
e164: state.e164.stringValue,
didConfirm: { [weak self] in self?.presenter?.requestSMSCode() },
didRequestEdit: { [weak self] in self?.presenter?.returnToPhoneNumberEntry() }
))
}
@objc
private func didTapSendVoiceCode() {
Logger.info("")
guard canRequestVoiceCode else { return }
presentActionSheet(.forRegistrationVerificationConfirmation(
mode: .voice,
e164: state.e164.stringValue,
didConfirm: { [weak self] in self?.presenter?.requestVoiceCode() },
didRequestEdit: { [weak self] in self?.presenter?.returnToPhoneNumberEntry() }
))
}
}
// MARK: - RegistrationVerificationCodeViewDelegate
extension RegistrationVerificationViewController: RegistrationVerificationCodeViewDelegate {
func codeViewDidChange() {
if verificationCodeView.isComplete {
Logger.info("Submitting verification code")
verificationCodeView.resignFirstResponder()
// Clear any errors so we render new ones.
previouslyRenderedValidationError = nil
presenter?.submitVerificationCode(verificationCodeView.verificationCode)
}
}
}
// MARK: - RegistrationVerificationHelpSheetViewController
private class RegistrationVerificationHelpSheetViewController: InteractiveSheetViewController {
private var intrinsicSizeObservation: NSKeyValueObservation?
public init() {
super.init()
scrollView.bounces = false
scrollView.isScrollEnabled = false
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 12
stackView.addArrangedSubview(header)
stackView.setCustomSpacing(20, after: header)
let bulletPoints = bulletPoints
stackView.addArrangedSubviews(bulletPoints)
// TODO[Registration]: there should be a contact support link here.
let insets = UIEdgeInsets(top: 20, left: 24, bottom: 80, right: 24)
contentView.addSubview(scrollView)
scrollView.autoPinEdgesToSuperviewEdges()
scrollView.addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges(with: insets)
stackView.autoConstrainAttribute(.width, to: .width, of: contentView, withOffset: -insets.totalWidth)
self.allowsExpansion = false
intrinsicSizeObservation = stackView.observe(\.bounds, changeHandler: { [weak self] stackView, _ in
self?.minimizedHeight = stackView.bounds.height + insets.totalHeight
self?.scrollView.isScrollEnabled = (self?.maxHeight ?? 0) < stackView.bounds.height
})
}
override public func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
scrollView.isScrollEnabled = self.maxHeight < stackView.bounds.height
}
let scrollView = UIScrollView()
let stackView = UIStackView()
let header: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.dynamicTypeTitle2.semibold()
label.text = OWSLocalizedString(
"ONBOARDING_VERIFICATION_HELP_LINK",
comment: "Label for a button to get help entering a verification code when registering."
)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
return label
}()
let bulletPoints: [UIView] = {
return [
OWSLocalizedString(
"ONBOARDING_VERIFICATION_HELP_BULLET_1",
comment: "First bullet point for the explainer sheet for registering via verification code."
),
OWSLocalizedString(
"ONBOARDING_VERIFICATION_HELP_BULLET_2",
comment: "Second bullet point for the explainer sheet for registering via verification code."
),
OWSLocalizedString(
"ONBOARDING_VERIFICATION_HELP_BULLET_3",
comment: "Third bullet point for the explainer sheet for registering via verification code."
)
].map { text in
return RegistrationVerificationHelpSheetViewController.listPointView(text: text)
}
}()
private static func listPointView(text: String) -> UIStackView {
let stackView = UIStackView(frame: .zero)
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 8
let label = UILabel()
label.text = text
label.numberOfLines = 0
label.textColor = Theme.primaryTextColor
label.font = .dynamicTypeBodyClamped
let bulletPoint = UIView()
bulletPoint.backgroundColor = UIColor(rgbHex: 0xC4C4C4)
stackView.addArrangedSubview(.spacer(withWidth: 4))
stackView.addArrangedSubview(bulletPoint)
stackView.addArrangedSubview(label)
bulletPoint.autoSetDimensions(to: .init(width: 4, height: 14))
label.setCompressionResistanceHigh()
return stackView
}
}