500 lines
19 KiB
Swift
500 lines
19 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
// MARK: - RegistrationChangePhoneNumberPresenter
|
|
|
|
protocol RegistrationChangePhoneNumberPresenter: AnyObject {
|
|
|
|
func submitProspectiveChangeNumberE164(newE164: E164)
|
|
|
|
func exitRegistration()
|
|
}
|
|
|
|
// MARK: - RegistrationChangePhoneNumberViewController
|
|
|
|
class RegistrationChangePhoneNumberViewController: OWSTableViewController2 {
|
|
|
|
private var state: RegistrationPhoneNumberViewState.ChangeNumberInitialEntry
|
|
private weak var presenter: RegistrationChangePhoneNumberPresenter?
|
|
|
|
private let oldValueViews: ChangePhoneNumberValueViews
|
|
private let newValueViews: ChangePhoneNumberValueViews
|
|
|
|
public init(
|
|
state: RegistrationPhoneNumberViewState.ChangeNumberInitialEntry,
|
|
presenter: RegistrationChangePhoneNumberPresenter
|
|
) {
|
|
self.state = state
|
|
self.presenter = presenter
|
|
if state.hasConfirmed {
|
|
self.oldValueViews = ChangePhoneNumberValueViews(e164: state.oldE164, type: .oldNumber)
|
|
self.newValueViews = ChangePhoneNumberValueViews(e164: state.newE164, type: .newNumber)
|
|
} else {
|
|
self.oldValueViews = ChangePhoneNumberValueViews(e164: nil, type: .oldNumber)
|
|
self.newValueViews = ChangePhoneNumberValueViews(e164: nil, type: .newNumber)
|
|
}
|
|
|
|
super.init()
|
|
|
|
oldValueViews.delegate = self
|
|
newValueViews.delegate = self
|
|
}
|
|
|
|
public func updateState(_ newState: RegistrationPhoneNumberViewState.ChangeNumberInitialEntry) {
|
|
self.state = newState
|
|
updateTableContents()
|
|
|
|
if let invalidNumberError = state.invalidNumberError {
|
|
showInvalidPhoneNumberAlertIfNecessary(for: invalidNumberError.invalidE164.stringValue)
|
|
}
|
|
}
|
|
|
|
private var previousInvalidE164: String?
|
|
|
|
private func showInvalidPhoneNumberAlertIfNecessary(for e164: String) {
|
|
let shouldShowAlert = e164 != previousInvalidE164
|
|
if shouldShowAlert {
|
|
OWSActionSheets.showActionSheet(
|
|
title: OWSLocalizedString(
|
|
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_TITLE",
|
|
comment: "Title of alert indicating that users needs to enter a valid phone number to register."
|
|
),
|
|
message: OWSLocalizedString(
|
|
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE",
|
|
comment: "Message of alert indicating that users needs to enter a valid phone number to register."
|
|
)
|
|
)
|
|
}
|
|
|
|
previousInvalidE164 = e164
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
title = OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_VIEW_TITLE",
|
|
comment: "Title for the 'change phone number' views in settings.")
|
|
|
|
navigationItem.leftBarButtonItem = .cancelButton { [weak self] in
|
|
self?.presenter?.exitRegistration()
|
|
}
|
|
|
|
updateTableContents()
|
|
}
|
|
|
|
fileprivate func updateNavigationBar() {
|
|
navigationItem.rightBarButtonItem = .doneButton { [weak self] in
|
|
self?.tryToContinue()
|
|
}
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
updateTableContents()
|
|
}
|
|
|
|
public override func themeDidChange() {
|
|
super.themeDidChange()
|
|
|
|
updateTableContents()
|
|
}
|
|
|
|
func updateTableContents() {
|
|
let contents = OWSTableContents()
|
|
|
|
contents.add(buildTableSection(valueViews: oldValueViews))
|
|
contents.add(buildTableSection(valueViews: newValueViews))
|
|
|
|
self.contents = contents
|
|
|
|
updateNavigationBar()
|
|
}
|
|
|
|
fileprivate func buildTableSection(valueViews: ChangePhoneNumberValueViews) -> OWSTableSection {
|
|
let section = OWSTableSection()
|
|
section.headerTitle = valueViews.sectionHeaderTitle
|
|
|
|
let countryCodeFormat = OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_COUNTRY_CODE_FORMAT",
|
|
comment: "Format for the 'country code' in the 'change phone number' settings. Embeds: {{ %1$@ the numeric country code prefix, %2$@ the country code abbreviation }}.")
|
|
let countryCodeFormatted = String(format: countryCodeFormat, valueViews.callingCode, valueViews.countryCode)
|
|
section.add(.item(name: OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_COUNTRY_CODE_FIELD",
|
|
comment: "Label for the 'country code' row in the 'change phone number' settings."),
|
|
textColor: Theme.primaryTextColor,
|
|
accessoryText: countryCodeFormatted,
|
|
accessoryType: .disclosureIndicator,
|
|
accessibilityIdentifier: valueViews.accessibilityIdentifier_PhoneNumber) { [weak self] in
|
|
self?.showCountryCodePicker(valueViews: valueViews)
|
|
})
|
|
section.add(.item(name: OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_PHONE_NUMBER_FIELD",
|
|
comment: "Label for the 'phone number' row in the 'change phone number' settings."),
|
|
textColor: Theme.primaryTextColor,
|
|
accessoryContentView: valueViews.phoneNumberTextField,
|
|
accessibilityIdentifier: valueViews.accessibilityIdentifier_CountryCode))
|
|
|
|
switch valueViews.type {
|
|
case .newNumber:
|
|
if
|
|
let e164 = self.state.newE164,
|
|
let warningLabelText = state.invalidNumberError?.warningLabelText(e164: e164)
|
|
{
|
|
section.add(.init(customCellBlock: {
|
|
let cell = OWSTableItem.buildCell(
|
|
itemName: warningLabelText,
|
|
textColor: .ows_accentRed
|
|
)
|
|
cell.isUserInteractionEnabled = false
|
|
return cell
|
|
}))
|
|
}
|
|
case .oldNumber:
|
|
break
|
|
}
|
|
|
|
// The purpose of the example phone number is to indicate to the user that they should enter
|
|
// their phone number _without_ a country calling code (e.g. +1 or +44) but _with_ area code, etc.
|
|
func tryToFormatPhoneNumber(_ phoneNumber: String) -> String? {
|
|
guard let formatted = PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber,
|
|
withSpecifiedCountryCodeString: valueViews.countryCode).nilIfEmpty else {
|
|
owsFailDebug("Invalid phone number. phoneNumber: \(phoneNumber), callingCode: \(valueViews.callingCode).")
|
|
return nil
|
|
}
|
|
// Remove the "country calling code".
|
|
guard formatted.hasPrefix(valueViews.callingCode) else {
|
|
owsFailDebug("Example phone number missing calling code. phoneNumber: \(phoneNumber), callingCode: \(valueViews.callingCode).")
|
|
return nil
|
|
}
|
|
guard let formattedWithoutCallingCode = String(formatted.dropFirst(valueViews.callingCode.count)).nilIfEmpty else {
|
|
owsFailDebug("Invalid phone number. phoneNumber: \(phoneNumber), callingCode: \(valueViews.callingCode).")
|
|
return nil
|
|
}
|
|
return formattedWithoutCallingCode
|
|
}
|
|
if let examplePhoneNumber = phoneNumberUtil.examplePhoneNumber(forCountryCode: valueViews.countryCode),
|
|
let formattedPhoneNumber = tryToFormatPhoneNumber(examplePhoneNumber) {
|
|
let exampleFormat = OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_EXAMPLE_PHONE_NUMBER_FORMAT",
|
|
comment: "Format for 'example phone numbers' in the 'change phone number' settings. Embeds: {{ the example phone number }}")
|
|
section.footerTitle = String(format: exampleFormat, formattedPhoneNumber)
|
|
}
|
|
|
|
return section
|
|
}
|
|
|
|
fileprivate func showCountryCodePicker(valueViews: ChangePhoneNumberValueViews) {
|
|
let countryCodeController = CountryCodeViewController()
|
|
countryCodeController.countryCodeDelegate = valueViews
|
|
countryCodeController.interfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait
|
|
let navigationController = OWSNavigationController(rootViewController: countryCodeController)
|
|
self.present(navigationController, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func tryToParseNewE164() -> E164? {
|
|
func tryToParse(
|
|
_ valueViews: ChangePhoneNumberValueViews,
|
|
isOldValue: Bool
|
|
) -> E164? {
|
|
switch valueViews.tryToParse() {
|
|
case .noNumber:
|
|
showInvalidPhoneNumberAlert(isOldValue: isOldValue)
|
|
return nil
|
|
case .invalidNumber:
|
|
showInvalidPhoneNumberAlert(isOldValue: isOldValue)
|
|
return nil
|
|
case .validNumber(let e164):
|
|
|
|
return e164
|
|
}
|
|
}
|
|
|
|
guard let oldE164 = tryToParse(oldValueViews, isOldValue: true) else {
|
|
return nil
|
|
}
|
|
guard let newE164 = tryToParse(newValueViews, isOldValue: false) else {
|
|
return nil
|
|
}
|
|
|
|
guard oldE164 == state.oldE164 else {
|
|
showIncorrectOldPhoneNumberAlert()
|
|
return nil
|
|
}
|
|
|
|
guard newE164 != state.oldE164 else {
|
|
showIdenticalPhoneNumbersAlert()
|
|
return nil
|
|
}
|
|
|
|
guard state.invalidNumberError?.canSubmit(e164: newE164) != false else {
|
|
showInvalidPhoneNumberAlert(isOldValue: false)
|
|
return nil
|
|
}
|
|
|
|
return newE164
|
|
}
|
|
|
|
private func tryToContinue() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let newE164 = tryToParseNewE164() else {
|
|
return
|
|
}
|
|
|
|
oldValueViews.phoneNumberTextField.resignFirstResponder()
|
|
newValueViews.phoneNumberTextField.resignFirstResponder()
|
|
|
|
presenter?.submitProspectiveChangeNumberE164(newE164: newE164)
|
|
}
|
|
|
|
private func showInvalidPhoneNumberAlert(isOldValue: Bool) {
|
|
let message = (isOldValue
|
|
? OWSLocalizedString(
|
|
"CHANGE_PHONE_NUMBER_INVALID_PHONE_NUMBER_ALERT_MESSAGE_OLD",
|
|
comment: "Error indicating that the user's old phone number is not valid.")
|
|
: OWSLocalizedString(
|
|
"CHANGE_PHONE_NUMBER_INVALID_PHONE_NUMBER_ALERT_MESSAGE_NEW",
|
|
comment: "Error indicating that the user's new phone number is not valid."))
|
|
OWSActionSheets.showActionSheet(title: nil, message: message)
|
|
}
|
|
|
|
private func showIncorrectOldPhoneNumberAlert() {
|
|
let message = OWSLocalizedString(
|
|
"CHANGE_PHONE_NUMBER_INCORRECT_OLD_PHONE_NUMBER_ALERT_MESSAGE",
|
|
comment: "Error indicating that the user's old phone number was not entered correctly.")
|
|
OWSActionSheets.showActionSheet(title: nil, message: message)
|
|
}
|
|
|
|
private func showIdenticalPhoneNumbersAlert() {
|
|
let message = OWSLocalizedString(
|
|
"CHANGE_PHONE_NUMBER_IDENTICAL_PHONE_NUMBERS_ALERT_MESSAGE",
|
|
comment: "Error indicating that the user's old and new phone numbers are identical.")
|
|
OWSActionSheets.showActionSheet(title: nil, message: message)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension RegistrationChangePhoneNumberViewController: ChangePhoneNumberValueViewsDelegate {
|
|
fileprivate func valueDidChange(valueViews: ChangePhoneNumberValueViews) {
|
|
AssertIsOnMainThread()
|
|
|
|
updateNavigationBar()
|
|
}
|
|
|
|
fileprivate func valueDidPressEnter(valueViews: ChangePhoneNumberValueViews) {
|
|
}
|
|
|
|
fileprivate func valueDidUpdateCountryState(valueViews: ChangePhoneNumberValueViews) {
|
|
updateTableContents()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private protocol ChangePhoneNumberValueViewsDelegate: AnyObject {
|
|
func valueDidChange(valueViews: ChangePhoneNumberValueViews)
|
|
func valueDidPressEnter(valueViews: ChangePhoneNumberValueViews)
|
|
func valueDidUpdateCountryState(valueViews: ChangePhoneNumberValueViews)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class ChangePhoneNumberValueViews: NSObject {
|
|
|
|
weak var delegate: ChangePhoneNumberValueViewsDelegate?
|
|
|
|
var phoneNumber: RegistrationPhoneNumber
|
|
|
|
enum `Type` {
|
|
case oldNumber
|
|
case newNumber
|
|
}
|
|
|
|
fileprivate let type: `Type`
|
|
|
|
public init(e164: E164?, type: `Type`) {
|
|
if let e164, let number = RegistrationPhoneNumberParser(phoneNumberUtil: Self.phoneNumberUtil).parseE164(e164) {
|
|
self.phoneNumber = number
|
|
} else {
|
|
self.phoneNumber = RegistrationPhoneNumber(countryState: .defaultValue, nationalNumber: "")
|
|
}
|
|
self.type = type
|
|
|
|
super.init()
|
|
|
|
phoneNumberTextField.accessibilityIdentifier = self.accessibilityIdentifier_PhoneNumberTextfield
|
|
phoneNumberTextField.delegate = self
|
|
phoneNumberTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
|
phoneNumberTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingDidBegin)
|
|
phoneNumberTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingDidEnd)
|
|
|
|
phoneNumberString = phoneNumber.nationalNumber
|
|
}
|
|
|
|
var countryState: RegistrationCountryState {
|
|
get {
|
|
return phoneNumber.countryState
|
|
}
|
|
set {
|
|
phoneNumber = RegistrationPhoneNumber(countryState: newValue, nationalNumber: phoneNumber.nationalNumber)
|
|
}
|
|
}
|
|
|
|
var countryName: String { countryState.countryName }
|
|
var callingCode: String { countryState.callingCode }
|
|
var countryCode: String { countryState.countryCode }
|
|
|
|
private enum InlineError {
|
|
case invalidNumber
|
|
case rateLimit(expiration: Date)
|
|
}
|
|
|
|
private var phoneNumberError: InlineError?
|
|
|
|
var phoneNumberString: String? {
|
|
get { phoneNumberTextField.text }
|
|
set {
|
|
phoneNumberTextField.text = newValue
|
|
applyPhoneNumberFormatting()
|
|
}
|
|
}
|
|
|
|
let phoneNumberTextField: UITextField = {
|
|
let field = UITextField()
|
|
field.font = UIFont.dynamicTypeBodyClamped
|
|
field.textColor = Theme.primaryTextColor
|
|
field.textAlignment = (CurrentAppContext().isRTL
|
|
? .left
|
|
: .right)
|
|
field.textContentType = .telephoneNumber
|
|
|
|
// There's a bug in iOS 13 where predictions aren't provided for .numberPad
|
|
// keyboard types. Leaving as number pad for now, but if we want to support
|
|
// autofill at the expense of a less appropriate keyboard, here's where it'd
|
|
// be done. See Wisors comment here:
|
|
// https://developer.apple.com/forums/thread/120703
|
|
field.keyboardType = .numberPad
|
|
|
|
field.placeholder = OWSLocalizedString(
|
|
"ONBOARDING_PHONE_NUMBER_PLACEHOLDER",
|
|
comment: "Placeholder string for phone number field during registration")
|
|
|
|
return field
|
|
}()
|
|
|
|
private func applyPhoneNumberFormatting() {
|
|
AssertIsOnMainThread()
|
|
TextFieldFormatting.reformatPhoneNumberTextField(phoneNumberTextField, callingCode: callingCode)
|
|
}
|
|
|
|
var sectionHeaderTitle: String {
|
|
switch type {
|
|
case .oldNumber:
|
|
return OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_OLD_PHONE_NUMBER_SECTION_TITLE",
|
|
comment: "Title for the 'old phone number' section in the 'change phone number' settings.")
|
|
case .newNumber:
|
|
return OWSLocalizedString("SETTINGS_CHANGE_PHONE_NUMBER_NEW_PHONE_NUMBER_SECTION_TITLE",
|
|
comment: "Title for the 'new phone number' section in the 'change phone number' settings.")
|
|
}
|
|
}
|
|
|
|
var accessibilityIdentifierPrefix: String {
|
|
switch type {
|
|
case .oldNumber:
|
|
return "old"
|
|
case .newNumber:
|
|
return "new"
|
|
}
|
|
}
|
|
|
|
var accessibilityIdentifier_PhoneNumberTextfield: String {
|
|
accessibilityIdentifierPrefix + "_phone_number_textfield"
|
|
}
|
|
|
|
var accessibilityIdentifier_PhoneNumber: String {
|
|
accessibilityIdentifierPrefix + "_phone_number"
|
|
}
|
|
|
|
var accessibilityIdentifier_CountryCode: String {
|
|
accessibilityIdentifierPrefix + "_country_code"
|
|
}
|
|
|
|
enum ParsedValue {
|
|
case noNumber
|
|
case invalidNumber
|
|
case validNumber(e164: E164)
|
|
}
|
|
|
|
func tryToParse() -> ParsedValue {
|
|
guard let phoneNumberWithoutCallingCode = phoneNumberString?.strippedOrNil else {
|
|
return .noNumber
|
|
}
|
|
|
|
guard
|
|
let phoneNumber = phoneNumberUtil.parsePhoneNumber(
|
|
userSpecifiedText: phoneNumberWithoutCallingCode,
|
|
callingCode: callingCode
|
|
),
|
|
let e164String = phoneNumber.toE164().strippedOrNil,
|
|
let e164 = E164(e164String),
|
|
PhoneNumberValidator().isValidForRegistration(phoneNumber: phoneNumber)
|
|
else {
|
|
return .invalidNumber
|
|
}
|
|
|
|
return .validNumber(e164: e164)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ChangePhoneNumberValueViews: UITextFieldDelegate {
|
|
public func textField(
|
|
_ textField: UITextField,
|
|
shouldChangeCharactersIn range: NSRange,
|
|
replacementString string: String) -> Bool {
|
|
|
|
if case .invalidNumber = phoneNumberError {
|
|
phoneNumberError = nil
|
|
}
|
|
|
|
// If ViewControllerUtils applied the edit on our behalf, inform UIKit
|
|
// so the edit isn't applied twice.
|
|
let result = TextFieldFormatting.phoneNumberTextField(
|
|
textField,
|
|
shouldChangeCharactersIn: range,
|
|
replacementString: string,
|
|
callingCode: callingCode)
|
|
|
|
textFieldDidChange(textField)
|
|
|
|
return result
|
|
}
|
|
|
|
@objc
|
|
private func textFieldDidChange(_ textField: UITextField) {
|
|
applyPhoneNumberFormatting()
|
|
delegate?.valueDidChange(valueViews: self)
|
|
}
|
|
|
|
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
delegate?.valueDidPressEnter(valueViews: self)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ChangePhoneNumberValueViews: CountryCodeViewControllerDelegate {
|
|
public func countryCodeViewController(_ vc: CountryCodeViewController,
|
|
didSelectCountry countryState: RegistrationCountryState) {
|
|
self.countryState = countryState
|
|
delegate?.valueDidUpdateCountryState(valueViews: self)
|
|
}
|
|
}
|