Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/Views/TextFieldFormatting.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit

public class TextFieldFormatting {

    private init() {}

    // Performs cursory validation and change handling for phone number text field edits
    // Allows UIKit to apply the majority of edits (unlike +phoneNumberTextField:changeCharacters...")
    // which applies the edit manually.
    // Useful when +phoneNumberTextField:changeCharactersInRange:... can't be used
    // because it applies changes manually and requires failing any change request from UIKit.
    public static func phoneNumberTextField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString insertionText: String,
        plusPrefixedCallingCode: String,
    ) -> Bool {

        let isDeletion = insertionText.isEmpty
        guard !isDeletion else { return true }

        // If we're deleting text, we're going to want to ignore
        // parens and spaces when finding a character to delete.

        // Let's tell UIKit to not apply the edit and just apply it ourselves.
        phoneNumberTextField(textField, changeCharactersIn: range, replacementString: insertionText, plusPrefixedCallingCode: plusPrefixedCallingCode)
        return false
    }

    // Reformats the text in a UITextField to apply phone number formatting
    public static func reformatPhoneNumberTextField(_ textField: UITextField, plusPrefixedCallingCode: String) {

        let originalCursorOffset: Int
        if let selectedTextRange = textField.selectedTextRange {
            originalCursorOffset = textField.offset(from: textField.beginningOfDocument, to: selectedTextRange.start)
        } else {
            originalCursorOffset = 0
        }

        let originalText = textField.text ?? ""
        let trimmedText = originalText.digitsOnly().phoneNumberTrimmedToMaxLength
        let updatedText = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(trimmedText, plusPrefixedCallingCode: plusPrefixedCallingCode)

        let updatedCursorOffset = PhoneNumberUtil.translateCursorPosition(
            UInt(originalCursorOffset),
            from: originalText,
            to: updatedText,
            stickingRightward: false,
        )
        textField.text = updatedText
        if let position = textField.position(from: textField.beginningOfDocument, offset: Int(updatedCursorOffset)) {
            textField.selectedTextRange = textField.textRange(from: position, to: position)
        }
    }

    // This convenience function can be used to reformat the contents of
    // a phone number text field as the user modifies its text by typing,
    // pasting, etc. Applies the incoming edit directly. The text field delegate
    // should return NO from -textField:shouldChangeCharactersInRange:...
    //
    // "callingCode" should be of the form: "+1".
    public static func phoneNumberTextField(
        _ textField: UITextField,
        changeCharactersIn range: NSRange,
        replacementString insertionText: String,
        plusPrefixedCallingCode: String,
    ) {
        // Phone numbers takes many forms.
        //
        // * We only want to let the user enter decimal digits.
        // * The user shouldn't have to enter hyphen, parentheses or whitespace;
        //   the phone number should be formatted automatically.
        // * The user should be able to copy and paste freely.
        // * Invalid input should be simply ignored.
        //
        // We accomplish this by being permissive and trying to "take as much of the user
        // input as possible".
        //
        // * Always accept deletes.
        // * Ignore invalid input.
        // * Take partial input if possible.

        let oldText = textField.text ?? ""

        // Construct the new contents of the text field by:
        // 1. Determining the "left" substring: the contents of the old text _before_ the deletion range.
        //    Filtering will remove non-decimal digit characters like hyphen "-".
        var left = (oldText as NSString).substring(to: range.location).digitsOnly()
        // 2. Determining the "right" substring: the contents of the old text _after_ the deletion range.
        let right = (oldText as NSString).substring(from: range.location + range.length).digitsOnly()
        // 3. Determining the "center" substring: the contents of the new insertion text.
        let center = insertionText.digitsOnly()

        // 3a. If user hits backspace, they should always delete a _digit_ to the
        //     left of the cursor, even if the text _immediately_ to the left of
        //     cursor is "formatting text" (e.g. whitespace, a hyphen or a
        //     parentheses).
        let isJustDeletion = insertionText.isEmpty
        if isJustDeletion {
            let deletedText = (oldText as NSString).substring(with: range)
            let didDeleteFormatting = deletedText.count == 1 && deletedText.digitsOnly().isEmpty
            if didDeleteFormatting, !left.isEmpty {
                left = String(left.dropLast())
            }
        }

        // 4. Construct the "raw" new text by concatenating left, center and right.
        //    Ensure we don't exceed the maximum length for a e164 phone number
        let textAfterChange = left.appending(center).appending(right).phoneNumberTrimmedToMaxLength

        // 5. Construct the "formatted" new text by inserting a hyphen if necessary.
        // reformat the phone number, trying to keep the cursor beside the inserted or deleted digit
        let cursorPositionAfterChange = min(left.utf16.count + center.utf16.count, textAfterChange.utf16.count)

        let formattedText = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(textAfterChange, plusPrefixedCallingCode: plusPrefixedCallingCode)
        let cursorPositionAfterReformat = PhoneNumberUtil.translateCursorPosition(
            UInt(cursorPositionAfterChange),
            from: textAfterChange,
            to: formattedText,
            stickingRightward: isJustDeletion,
        )

        textField.text = formattedText

        if let position = textField.position(from: textField.beginningOfDocument, offset: Int(cursorPositionAfterReformat)) {
            textField.selectedTextRange = textField.textRange(from: position, to: position)
        }
    }

    public static func ows2FAPINTextField(
        _ textField: UITextField,
        changeCharactersIn range: NSRange,
        replacementString insertionText: String,
    ) {
        // * We only want to let the user enter decimal digits.
        // * The user should be able to copy and paste freely.
        // * Invalid input should be simply ignored.
        //
        // We accomplish this by being permissive and trying to "take as much of the user
        // input as possible".
        //
        // * Always accept deletes.
        // * Ignore invalid input.
        // * Take partial input if possible.

        let oldText = textField.text ?? ""
        // Construct the new contents of the text field by:
        // 1. Determining the "left" substring: the contents of the old text _before_ the deletion range.
        //    Filtering will remove non-decimal digit characters.
        let left = (oldText as NSString).substring(to: range.location).digitsOnly()
        // 2. Determining the "right" substring: the contents of the old text _after_ the deletion range.
        let right = (oldText as NSString).substring(from: range.location + range.length).digitsOnly()
        // 3. Determining the "center" substring: the contents of the new insertion text.
        let center = insertionText.digitsOnly()
        // 4. Construct the "raw" new text by concatenating left, center and right.
        let textAfterChange = left.appending(center).appending(right)
        // 5. Ensure we don't exceed the maximum length for a PIN.
        // We explicitly no longer do this here. We don't want to truncate passwords.
        // Instead, we rely on the view to notify when the user's pin is too long.
        // 6. Construct the final text.
        textField.text = textAfterChange

        let cursorPositionAfterChange = min(left.utf16.count + center.utf16.count, textAfterChange.utf16.count)
        if let position = textField.position(from: textField.beginningOfDocument, offset: cursorPositionAfterChange) {
            textField.selectedTextRange = textField.textRange(from: position, to: position)
        }
    }

    // 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.
    public static func exampleNationalNumber(forCountryCode countryCode: String, includeExampleLabel: Bool) -> String? {
        owsAssertDebug(!countryCode.isEmpty)

        let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
        let countryCodeForParsing = phoneNumberUtil.countryCodeForParsing(fromCountryCode: countryCode)
        guard let nationalNumber = phoneNumberUtil.exampleNationalNumber(forCountryCode: countryCodeForParsing) else {
            owsFailDebug("examplePhoneNumber == nil")
            return nil
        }

        guard includeExampleLabel else {
            return nationalNumber
        }

        let formatString = OWSLocalizedString(
            "PHONE_NUMBER_EXAMPLE_FORMAT",
            comment: "A format for a label showing an example phone number. Embeds {{the example phone number}}.",
        )
        return String.nonPluralLocalizedStringWithFormat(formatString, nationalNumber)
    }
}

private extension String {

    private static let kMaxPhoneNumberLength: Int = 18

    var phoneNumberTrimmedToMaxLength: String {
        // Ensure we don't exceed the maximum length for a e164 phone number,
        // 15 digits, per: https://en.wikipedia.org/wiki/E.164
        //
        // NOTE: The actual limit is 18, not 15, because of certain invalid phone numbers in Germany.
        //       https://github.com/googlei18n/libphonenumber/blob/master/FALSEHOODS.md
        if self.count > Self.kMaxPhoneNumberLength {
            return String(self.prefix(Self.kMaxPhoneNumberLength))
        }
        return self
    }
}