// // 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) } }