diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-04-06 19:48:14 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2020-04-13 13:59:03 +0200 |
| commit | 00e29a770b47dbfcb2f9aa97f6046c40a8825047 (patch) | |
| tree | c78cb21d5e77060fbf0d51e148e628bf667126e4 | |
| parent | b1ca8d4724018698289d3b8efd9dd2cc6b90c9aa (diff) | |
| download | mullvadvpn-00e29a770b47dbfcb2f9aa97f6046c40a8825047.tar.xz mullvadvpn-00e29a770b47dbfcb2f9aa97f6046c40a8825047.zip | |
Add account token text input formatting
| -rw-r--r-- | ios/CHANGELOG.md | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 10 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountTextField.swift | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountTokenInput.swift | 222 | ||||
| -rw-r--r-- | ios/MullvadVPN/Base.lproj/Main.storyboard | 27 | ||||
| -rw-r--r-- | ios/MullvadVPN/LoginViewController.swift | 22 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/AccountTokenInputTests.swift | 99 |
7 files changed, 374 insertions, 30 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index f78a20ff4f..dbc731eab3 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -25,6 +25,10 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Fixed - Fix "invalid account" error that was mistakenly reported as "network error" during log in. +- Fix parsing of pre-formatted account numbers when pasting from pasteboard on login screen. + +### Added +- Format account number in groups of 4 digits separated by whitespace on login screen. ## [2020.1] - 2020-04-08 Initial release. Supports... diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index e436c722e6..0546a9edee 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -16,6 +16,9 @@ 581CBCEE229826FD00727D7F /* StaticTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */; }; 582650862384116F00FA7A86 /* ReplaceNilWithError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582650832384102800FA7A86 /* ReplaceNilWithError.swift */; }; 582650872384117900FA7A86 /* ReplaceNilWithError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582650832384102800FA7A86 /* ReplaceNilWithError.swift */; }; + 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */; }; + 582AE3122440CA0D00E6733A /* AccountTokenInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */; }; + 582AE3132440CA2700E6733A /* AccountTokenInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */; }; 582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582BB1AE229566420055B6EF /* SettingsCell.swift */; }; 582BB1B1229569620055B6EF /* CustomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582BB1B0229569620055B6EF /* CustomNavigationBar.swift */; }; 582BB1B3229574F40055B6EF /* SettingsAccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582BB1B2229574F40055B6EF /* SettingsAccountCell.swift */; }; @@ -181,6 +184,8 @@ 581CBCEB2298041B00727D7F /* SettingsAppVersionCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionCell.swift; sourceTree = "<group>"; }; 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTableViewDataSource.swift; sourceTree = "<group>"; }; 582650832384102800FA7A86 /* ReplaceNilWithError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceNilWithError.swift; sourceTree = "<group>"; }; + 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountTokenInput.swift; sourceTree = "<group>"; }; + 582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTokenInputTests.swift; sourceTree = "<group>"; }; 582BB1AE229566420055B6EF /* SettingsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsCell.swift; sourceTree = "<group>"; }; 582BB1B0229569620055B6EF /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = "<group>"; }; 582BB1B2229574F40055B6EF /* SettingsAccountCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAccountCell.swift; sourceTree = "<group>"; }; @@ -328,6 +333,7 @@ 58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = { isa = PBXGroup; children = ( + 582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */, 58B0A2A4238EE67E00BC001D /* Info.plist */, 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, 5807E2C1243203D000F5FF30 /* StringTests.swift */, @@ -367,6 +373,7 @@ 582BB1B42295780F0055B6EF /* AccountExpiry.swift */, 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */, 58CCA01D2242787B004F3011 /* AccountTextField.swift */, + 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */, 58CCA01722426713004F3011 /* AccountViewController.swift */, 5868585424054096000B8131 /* AppButton.swift */, 58CE5E63224146200008646E /* AppDelegate.swift */, @@ -721,6 +728,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 582AE3132440CA2700E6733A /* AccountTokenInput.swift in Sources */, 58B0A2AA238EE6A900BC001D /* RelaySelector.swift in Sources */, 5807E2C3243203E700F5FF30 /* String+Split.swift in Sources */, 58B0A2A8238EE68200BC001D /* RelaySelectorTests.swift in Sources */, @@ -728,6 +736,7 @@ 58B0A2AC238EE6D500BC001D /* IpAddress+Codable.swift in Sources */, 58B0A2AB238EE6BF00BC001D /* RelayList.swift in Sources */, 58B0A2AD238EE6EC00BC001D /* MullvadEndpoint.swift in Sources */, + 582AE3122440CA0D00E6733A /* AccountTokenInputTests.swift in Sources */, 58B0A2A9238EE6A100BC001D /* RelayConstraints.swift in Sources */, 5807E2C2243203D000F5FF30 /* StringTests.swift in Sources */, 58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */, @@ -753,6 +762,7 @@ 582BB1B1229569620055B6EF /* CustomNavigationBar.swift in Sources */, 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */, 58C6B35422BB87C4003C19AD /* WireguardPrivateKey.swift in Sources */, + 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */, 5888AD87227B17950051EB06 /* SelectLocationController.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 584E96BA240D791E00D3334F /* CancellableDelayPublisher.swift in Sources */, diff --git a/ios/MullvadVPN/AccountTextField.swift b/ios/MullvadVPN/AccountTextField.swift index 6c22df60e3..0e51e3f6b2 100644 --- a/ios/MullvadVPN/AccountTextField.swift +++ b/ios/MullvadVPN/AccountTextField.swift @@ -10,6 +10,8 @@ import UIKit @IBDesignable class AccountTextField: UITextField { + private let input = AccountTokenInput() + override init(frame: CGRect) { super.init(frame: frame) setup() @@ -22,6 +24,23 @@ import UIKit private func setup() { backgroundColor = UIColor.clear + + delegate = input + pasteDelegate = input + } + + var autoformattingText: String { + set { + input.replace(with: newValue) + input.updateTextField(self) + } + get { + input.formattedString + } + } + + var parsedToken: String { + return input.parsedString } override func textRect(forBounds bounds: CGRect) -> CGRect { @@ -31,4 +50,5 @@ import UIKit override func editingRect(forBounds bounds: CGRect) -> CGRect { return textRect(forBounds: bounds) } + } diff --git a/ios/MullvadVPN/AccountTokenInput.swift b/ios/MullvadVPN/AccountTokenInput.swift new file mode 100644 index 0000000000..17f5bde55a --- /dev/null +++ b/ios/MullvadVPN/AccountTokenInput.swift @@ -0,0 +1,222 @@ +// +// AccountTokenInput.swift +// MullvadVPN +// +// Created by pronebird on 08/04/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +/// A class describing the account token input and caret management. +/// Suitable to be used with `UITextField`. +class AccountTokenInput: NSObject +{ + /// The group separator character + static let groupSeparator: Character = " " + + /// The character size of each group of digits + static let groupSize = 4 + + /// Parsed account token string + private(set) var parsedString = "" + + /// Formatted string + private(set) var formattedString = "" + + // Computed caret position + private(set) var caretPosition = 0 + + init(string: String = "") { + super.init() + + replace(with: string) + } + + /// Replace the currently held value with the given string + func replace(with replacementString: String) { + let stringRange = formattedString.startIndex ..< formattedString.endIndex + + replaceCharacters( + in: stringRange, + replacementString: replacementString, + emptySelection: false) + } + + /// Replace characters in range maintaining the caret position + /// + /// - Parameter range: a range within a string to replace + /// - Parameter replacementString: a string to replace the characters in the given range + /// - Parameter emptySelection: a hint to indicate if the text field selection is empty. + /// This is normally the default state unless a text range is + /// selected. + /// + func replaceCharacters(in range: Range<String.Index>, + replacementString: String, + emptySelection: Bool) + { + var stringRange = range + + // Since removing separator alone makes no sense, this computation extends the string range + // to include the digit preceding a separator. + if replacementString.isEmpty && emptySelection { + let precedingDigitIndex = formattedString + .prefix(through: stringRange.lowerBound) + .lastIndex { Self.isDigit($0) } ?? formattedString.startIndex + + stringRange = precedingDigitIndex ..< stringRange.upperBound + } + + // Replace the given range within a formatted string + let newString = formattedString.replacingCharacters(in: stringRange, + with: replacementString) + + // Number of digits within a string + var numDigits = 0 + + // Insertion location within the input string + let insertionLocation = formattedString.distance( + from: formattedString.startIndex, + to: stringRange.lowerBound + ) + + // Original caret location based on insertion location + number of characters added + let originalCaretPosition = insertionLocation + replacementString.count + + // Computed caret location that will be modified during the loop + var newCaretPosition = originalCaretPosition + + // New re-parsed and re-formatted strings + var reparsedString = "" + var reformattedString = "" + + for (index, element) in newString.enumerated() { + // Skip disallowed characters + if !Self.isDigit(element) { + // Adjust the caret position for characters removed before the insertion location + if originalCaretPosition > index { + newCaretPosition -= 1 + } + continue + } + + // Add separator between the groups of digits + if numDigits > 0 && numDigits % Self.groupSize == 0 { + reformattedString.append(Self.groupSeparator) + + if originalCaretPosition > index { + // Adjust the caret position to account for separators added before the + // insertion location + newCaretPosition += 1 + } + } + + reformattedString.append(element) + reparsedString.append(element) + numDigits += 1 + } + + caretPosition = newCaretPosition + formattedString = reformattedString + parsedString = reparsedString + } + + private class func isDigit(_ character: Character) -> Bool { + switch character { + case "0"..."9": + return true + default: + return false + } + } + +} + +extension AccountTokenInput: UITextFieldDelegate, UITextPasteDelegate { + + /// Update the text and caret position in the given text field + func updateTextField(_ textField: UITextField) { + updateTextField(textField, notifyDelegate: false) + } + + // MARK: - UITextFieldDelegate + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let emptySelection = textField.selectedTextRange?.isEmpty ?? false + let stringRange = Range(range, in: formattedString)! + + replaceCharacters( + in: stringRange, + replacementString: string, + emptySelection: emptySelection) + + updateTextField(textField, notifyDelegate: true) + + return false + } + + // MARK: - UITextPasteDelegate + + func textPasteConfigurationSupporting( + _ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, + performPasteOf attributedString: NSAttributedString, + to textRange: UITextRange) -> UITextRange + { + guard let textField = textPasteConfigurationSupporting as? UITextField else { + return textRange + } + + let location = textField.offset(from: textField.beginningOfDocument, to: textRange.start) + let length = textField.offset(from: textRange.start, to: textRange.end) + let nsRange = NSRange(location: location, length: length) + + let stringRange = Range(nsRange, in: formattedString)! + + replaceCharacters( + in: stringRange, + replacementString: attributedString.string, + emptySelection: textRange.isEmpty) + updateTextField(textField, notifyDelegate: true) + + return caretTextRange(in: textField)! + } + + // MARK: - Private + + /// A caret position as utf-16 offset compatible for use with `NSString` and `UITextField` + private var caretPositionUtf16: Int { + let startIndex = formattedString.startIndex + let endIndex = formattedString.index(startIndex, offsetBy: caretPosition) + + return formattedString.utf16.distance(from: startIndex, to: endIndex) + } + + /// Convert the computed caret position to an empty `UITextRange` within the given text field + private func caretTextRange(in textField: UITextField) -> UITextRange? { + guard let position = textField.position( + from: textField.beginningOfDocument, + offset: caretPositionUtf16) else { return nil } + + return textField.textRange(from: position, to: position) + } + + /// A helper to update the text and caret in the given text field, and optionally post + /// `UITextField.textDidChange` notification + private func updateTextField(_ textField: UITextField, notifyDelegate: Bool) { + textField.text = formattedString + textField.selectedTextRange = caretTextRange(in: textField) + + if notifyDelegate { + Self.notifyTextDidChange(in: textField) + } + } + + /// Post `UITextField.textDidChange` notification + class private func notifyTextDidChange(in textField: UITextField) { + NotificationCenter.default.post( + name: UITextField.textDidChangeNotification, + object: textField) + } +} + diff --git a/ios/MullvadVPN/Base.lproj/Main.storyboard b/ios/MullvadVPN/Base.lproj/Main.storyboard index db508d818c..b4c7fa178b 100644 --- a/ios/MullvadVPN/Base.lproj/Main.storyboard +++ b/ios/MullvadVPN/Base.lproj/Main.storyboard @@ -40,14 +40,14 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="93"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LogoIcon" translatesAutoresizingMaskIntoConstraints="NO" id="cKg-hE-JsS"> - <rect key="frame" x="12" y="34.5" width="44.000000000000057" height="44.000000000000057"/> + <rect key="frame" x="12" y="34.5" width="44" height="44"/> <constraints> <constraint firstAttribute="width" secondItem="cKg-hE-JsS" secondAttribute="height" multiplier="1:1" id="jln-Ze-Hhl"/> <constraint firstAttribute="width" constant="44" id="u6D-kY-AyW"/> </constraints> </imageView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="uXv-Tf-PET"> - <rect key="frame" x="335" y="44.5" width="24" height="24"/> + <rect key="frame" x="311" y="32.5" width="48" height="48"/> <accessibility key="accessibilityConfiguration" identifier="SettingsButton"/> <state key="normal" image="IconSettings"/> <connections> @@ -117,7 +117,7 @@ </constraints> </view> <imageView clipsSubviews="YES" userInteractionEnabled="NO" alpha="0.0" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconSuccess" translatesAutoresizingMaskIntoConstraints="NO" id="7ux-Tb-Fzq"> - <rect key="frame" x="157.5" y="167" width="60" height="60"/> + <rect key="frame" x="127.5" y="137" width="120" height="120"/> </imageView> <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="V3j-Lb-fSQ" userLabel="Form"> <rect key="frame" x="0.0" y="251" width="375" height="125.5"/> @@ -142,9 +142,6 @@ <accessibility key="accessibilityConfiguration" identifier="LoginTextField"/> <fontDescription key="fontDescription" type="system" pointSize="20"/> <textInputTraits key="textInputTraits" autocorrectionType="no" spellCheckingType="no" keyboardType="numberPad" enablesReturnKeyAutomatically="YES" smartDashesType="no" smartInsertDeleteType="no" smartQuotesType="no" textContentType="username"/> - <connections> - <outlet property="delegate" destination="BYZ-38-t0r" id="jeF-vG-YXi"/> - </connections> </textField> </subviews> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> @@ -975,13 +972,13 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="598"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="N9k-cQ-tlw" userLabel="Content view"> - <rect key="frame" x="0.0" y="0.0" width="375" height="558"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="568"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Wnl-L9-JqG" userLabel="Logo header"> <rect key="frame" x="0.0" y="0.0" width="375" height="100"/> <subviews> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LogoIcon" translatesAutoresizingMaskIntoConstraints="NO" id="WSx-4V-zIk"> - <rect key="frame" x="157.5" y="20" width="60.000000000000057" height="60.000000000000057"/> + <rect key="frame" x="157.5" y="20" width="60" height="60"/> <constraints> <constraint firstAttribute="width" secondItem="WSx-4V-zIk" secondAttribute="height" multiplier="1:1" id="ZtE-hc-rs8"/> <constraint firstAttribute="width" constant="60" id="qGt-Am-MHR"/> @@ -1011,7 +1008,7 @@ <nil key="highlightedColor"/> </label> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Cas-Tk-gcz" customClass="LinkButton" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="20" y="516" width="20" height="22"/> + <rect key="frame" x="20" y="516" width="36" height="32"/> <fontDescription key="fontDescription" name=".AppleSystemUIFont" family=".AppleSystemUIFont" pointSize="18"/> <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <state key="normal" title="Privacy Policy" image="IconExtlink"/> @@ -1139,31 +1136,31 @@ </view> <prototypes> <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" id="aFz-H5-sPu" customClass="SelectLocationCell" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="0.0" y="173" width="375" height="43.5"/> + <rect key="frame" x="0.0" y="173" width="375" height="48.5"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="aFz-H5-sPu" id="6nQ-gT-vzf"> - <rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="48.5"/> <autoresizingMask key="autoresizingMask"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ag-N4-pUg" customClass="RelayStatusIndicatorView" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="16" y="14" width="16" height="16"/> + <rect key="frame" x="16" y="16.5" width="16" height="16"/> <constraints> <constraint firstAttribute="height" constant="16" id="QWj-hh-I3P"/> <constraint firstAttribute="width" constant="16" id="TFV-yi-LXG"/> </constraints> </view> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="y7o-0b-MUV"> - <rect key="frame" x="44" y="11" width="42" height="21.5"/> + <rect key="frame" x="44" y="11" width="42" height="26.5"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconTick" translatesAutoresizingMaskIntoConstraints="NO" id="e1o-Bl-zd5"> - <rect key="frame" x="12" y="10" width="24" height="24"/> + <rect key="frame" x="0.0" y="0.5" width="48" height="48"/> <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </imageView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="KaW-bN-I51"> - <rect key="frame" x="311" y="0.0" width="64" height="43.5"/> + <rect key="frame" x="311" y="0.0" width="64" height="48.5"/> <accessibility key="accessibilityConfiguration" identifier="ExpandButton"/> <constraints> <constraint firstAttribute="width" constant="64" id="UU3-Di-65E"/> diff --git a/ios/MullvadVPN/LoginViewController.swift b/ios/MullvadVPN/LoginViewController.swift index 393e455bb0..99918b1646 100644 --- a/ios/MullvadVPN/LoginViewController.swift +++ b/ios/MullvadVPN/LoginViewController.swift @@ -11,14 +11,13 @@ import UIKit import os private let kMinimumAccountTokenLength = 10 -private let kValidAccountTokenCharacterSet = CharacterSet(charactersIn: "01234567890") -class LoginViewController: UIViewController, UITextFieldDelegate, RootContainment { +class LoginViewController: UIViewController, RootContainment { @IBOutlet var keyboardToolbar: UIToolbar! @IBOutlet var keyboardToolbarLoginButton: UIBarButtonItem! @IBOutlet var accountInputGroup: AccountInputGroupView! - @IBOutlet var accountTextField: UITextField! + @IBOutlet var accountTextField: AccountTextField! @IBOutlet var titleLabel: UILabel! @IBOutlet var messageLabel: UILabel! @IBOutlet var loginForm: UIView! @@ -129,18 +128,11 @@ class LoginViewController: UIViewController, UITextFieldDelegate, RootContainmen updateKeyboardToolbar() } - // MARK: - UITextFieldDelegate - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - // prevent the change if the replacement string contains disallowed characters - return string.unicodeScalars.allSatisfy { kValidAccountTokenCharacterSet.contains($0) } - } - // MARK: - Actions @IBAction func unwindFromAccount(segue: UIStoryboardSegue) { loginState = .default - accountTextField.text = "" + accountTextField.autoformattingText = "" updateKeyboardToolbar() } @@ -149,7 +141,7 @@ class LoginViewController: UIViewController, UITextFieldDelegate, RootContainmen } @IBAction func doLogin() { - let accountToken = accountTextField.text ?? "" + let accountToken = accountTextField.parsedToken beginLogin(method: .existingAccount) @@ -168,7 +160,7 @@ class LoginViewController: UIViewController, UITextFieldDelegate, RootContainmen @IBAction func createNewAccount() { beginLogin(method: .newAccount) - accountTextField.text = "" + accountTextField.autoformattingText = "" updateKeyboardToolbar() loginSubscriber = Account.shared.loginWithNewAccount() @@ -181,7 +173,7 @@ class LoginViewController: UIViewController, UITextFieldDelegate, RootContainmen self.endLogin(.failure(error)) } }, receiveValue: { (newAccountToken) in - self.accountTextField.text = newAccountToken + self.accountTextField.autoformattingText = newAccountToken }) } @@ -268,7 +260,7 @@ class LoginViewController: UIViewController, UITextFieldDelegate, RootContainmen } private func updateKeyboardToolbar() { - let accountTokenLength = accountTextField.text?.count ?? 0 + let accountTokenLength = accountTextField.parsedToken.count let enableButton = accountTokenLength >= kMinimumAccountTokenLength keyboardToolbarLoginButton.isEnabled = enableButton diff --git a/ios/MullvadVPNTests/AccountTokenInputTests.swift b/ios/MullvadVPNTests/AccountTokenInputTests.swift new file mode 100644 index 0000000000..75dd39f4bf --- /dev/null +++ b/ios/MullvadVPNTests/AccountTokenInputTests.swift @@ -0,0 +1,99 @@ +// +// AccountTokenInputTests.swift +// MullvadVPNTests +// +// Created by pronebird on 10/04/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +private let kSampleToken = "12345678" + +class AccountTokenInputTests: XCTestCase { + + func testInitialValue() { + let input = AccountTokenInput(string: kSampleToken) + + XCTAssertEqual(input.formattedString, "1234 5678") + XCTAssertEqual(input.caretPosition, 9) + } + + func testReplacingValue() { + let input = AccountTokenInput() + input.replace(with: "00000000") + + XCTAssertEqual(input.formattedString, "0000 0000") + XCTAssertEqual(input.caretPosition, 9) + } + + func testRemovingSeparator() { + let input = AccountTokenInput(string: kSampleToken) + + input.replaceCharacters( + in: input.formattedString.range(withOffset: 4, length: 1), + replacementString: "", + emptySelection: true) + + XCTAssertEqual(input.formattedString, "1235 678") + XCTAssertEqual(input.caretPosition, 3) + } + + func testRemovingSeparatorRange() { + let input = AccountTokenInput(string: kSampleToken) + + input.replaceCharacters( + in: input.formattedString.range(withOffset: 4, length: 1), + replacementString: "", + emptySelection: false) + + XCTAssertEqual(input.formattedString, "1234 5678") + XCTAssertEqual(input.caretPosition, 4) + } + + func testRemovingRange() { + let input = AccountTokenInput(string: kSampleToken) + + input.replaceCharacters( + in: input.formattedString.range(withOffset: 7, length: 2), + replacementString: "", + emptySelection: false) + + XCTAssertEqual(input.formattedString, "1234 56") + XCTAssertEqual(input.caretPosition, 7) + } + + func testInserting() { + let input = AccountTokenInput(string: kSampleToken) + + input.replaceCharacters( + in: input.formattedString.range(withOffset: 5, length: 0), + replacementString: "0000", + emptySelection: true) + + XCTAssertEqual(input.formattedString, "1234 0000 5678") + XCTAssertEqual(input.caretPosition, 9) + } + + func testReplacingRange() { + let input = AccountTokenInput(string: kSampleToken) + + input.replaceCharacters( + in: input.formattedString.range(withOffset: 5, length: 4), + replacementString: "0000", + emptySelection: false) + + XCTAssertEqual(input.formattedString, "1234 0000") + XCTAssertEqual(input.caretPosition, 9) + } + +} + +private extension String { + func range(withOffset offset: String.IndexDistance, length: Int) -> Range<String.Index> { + let start = index(startIndex, offsetBy: offset) + let end = index(start, offsetBy: length) + + return start ..< end + } +} |
