summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2020-04-06 19:48:14 +0200
committerAndrej Mihajlov <and@mullvad.net>2020-04-13 13:59:03 +0200
commit00e29a770b47dbfcb2f9aa97f6046c40a8825047 (patch)
treec78cb21d5e77060fbf0d51e148e628bf667126e4
parentb1ca8d4724018698289d3b8efd9dd2cc6b90c9aa (diff)
downloadmullvadvpn-00e29a770b47dbfcb2f9aa97f6046c40a8825047.tar.xz
mullvadvpn-00e29a770b47dbfcb2f9aa97f6046c40a8825047.zip
Add account token text input formatting
-rw-r--r--ios/CHANGELOG.md4
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj10
-rw-r--r--ios/MullvadVPN/AccountTextField.swift20
-rw-r--r--ios/MullvadVPN/AccountTokenInput.swift222
-rw-r--r--ios/MullvadVPN/Base.lproj/Main.storyboard27
-rw-r--r--ios/MullvadVPN/LoginViewController.swift22
-rw-r--r--ios/MullvadVPNTests/AccountTokenInputTests.swift99
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
+ }
+}