diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-05-14 16:33:45 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-05-17 19:35:08 +0200 |
| commit | 87bf4647623d81b8d3f9de38d50cde1bbb10923f (patch) | |
| tree | 0e82932596dd102b49c28a1dd58dba45a880179c | |
| parent | a44c7247d40e9197f8021a467a9669f455e14bc7 (diff) | |
| download | mullvadvpn-87bf4647623d81b8d3f9de38d50cde1bbb10923f.tar.xz mullvadvpn-87bf4647623d81b8d3f9de38d50cde1bbb10923f.zip | |
Implement account verification and login
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 58 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountVerificationProcedure.swift | 88 | ||||
| -rw-r--r-- | ios/MullvadVPN/Base.lproj/Main.storyboard | 16 | ||||
| -rw-r--r-- | ios/MullvadVPN/JSONRequestProcedure.swift | 47 | ||||
| -rw-r--r-- | ios/MullvadVPN/LoginViewController.swift | 55 | ||||
| -rw-r--r-- | ios/MullvadVPN/MullvadAPI.swift | 47 | ||||
| -rw-r--r-- | ios/MullvadVPN/SegueIdentifier.swift | 1 | ||||
| -rw-r--r-- | ios/MullvadVPN/SelectLocationController.swift | 18 | ||||
| -rw-r--r-- | ios/MullvadVPN/SpinnerActivityIndicatorView.swift | 188 | ||||
| -rw-r--r-- | ios/MullvadVPN/UserDefaultsInteractor.swift | 54 |
11 files changed, 551 insertions, 41 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b629bc642d..3773323af9 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 08A905C56DDB0A0A887BB5F5 /* Pods_MullvadVPN.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F506AB938C45AEB812886B4 /* Pods_MullvadVPN.framework */; }; 543203592C79FAD36BC1E700 /* Pods_PacketTunnel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A931E0F6F380B4B3FD45D987 /* Pods_PacketTunnel.framework */; }; + 58461AD3228D622E00B72ECB /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58461AD2228D622E00B72ECB /* Account.swift */; }; 5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */; }; 5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5867A51B2248F26A005513C0 /* SegueIdentifier.swift */; }; 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; }; @@ -34,7 +35,11 @@ 58CE5E6E224146210008646E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 58CE5E6C224146210008646E /* LaunchScreen.storyboard */; }; 58CE5E7C224146470008646E /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CE5E7B224146470008646E /* PacketTunnelProvider.swift */; }; 58CE5E81224146470008646E /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 58CE5E79224146470008646E /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 58F19E31228B2AEB00C7710B /* JSONRequestProcedure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E30228B2AEB00C7710B /* JSONRequestProcedure.swift */; }; + 58F19E33228B383300C7710B /* AccountVerificationProcedure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E32228B383300C7710B /* AccountVerificationProcedure.swift */; }; + 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */; }; 58F37E7D2243ECCB00C75C97 /* HeaderBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F37E7C2243ECCB00C75C97 /* HeaderBarViewController.swift */; }; + 58FFE444228C82A00036F391 /* UserDefaultsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FFE443228C82A00036F391 /* UserDefaultsInteractor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,6 +69,7 @@ /* Begin PBXFileReference section */ 3F506AB938C45AEB812886B4 /* Pods_MullvadVPN.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MullvadVPN.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 40D54D8A8B75B67DC37C5CCE /* Pods-PacketTunnel.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PacketTunnel.release.xcconfig"; path = "Target Support Files/Pods-PacketTunnel/Pods-PacketTunnel.release.xcconfig"; sourceTree = "<group>"; }; + 58461AD2228D622E00B72ECB /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; }; 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslucentButtonBlurView.swift; sourceTree = "<group>"; }; 5866F39B2243B82D00168AE5 /* MullvadVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MullvadVPN.entitlements; sourceTree = "<group>"; }; 5867A51B2248F26A005513C0 /* SegueIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegueIdentifier.swift; sourceTree = "<group>"; }; @@ -94,7 +100,11 @@ 58CE5E7B224146470008646E /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; }; 58CE5E7D224146470008646E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 58CE5E7E224146470008646E /* PacketTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PacketTunnel.entitlements; sourceTree = "<group>"; }; + 58F19E30228B2AEB00C7710B /* JSONRequestProcedure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONRequestProcedure.swift; sourceTree = "<group>"; }; + 58F19E32228B383300C7710B /* AccountVerificationProcedure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountVerificationProcedure.swift; sourceTree = "<group>"; }; + 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; 58F37E7C2243ECCB00C75C97 /* HeaderBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarViewController.swift; sourceTree = "<group>"; }; + 58FFE443228C82A00036F391 /* UserDefaultsInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsInteractor.swift; sourceTree = "<group>"; }; 627D4CE562B85202FCFA0EB1 /* Pods-MullvadVPN.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MullvadVPN.debug.xcconfig"; path = "Target Support Files/Pods-MullvadVPN/Pods-MullvadVPN.debug.xcconfig"; sourceTree = "<group>"; }; 9F1362F46063B1D06EB0C685 /* Pods-PacketTunnel.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PacketTunnel.debug.xcconfig"; path = "Target Support Files/Pods-PacketTunnel/Pods-PacketTunnel.debug.xcconfig"; sourceTree = "<group>"; }; A931E0F6F380B4B3FD45D987 /* Pods_PacketTunnel.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PacketTunnel.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -153,8 +163,10 @@ 58CE5E62224146200008646E /* MullvadVPN */ = { isa = PBXGroup; children = ( + 58461AD2228D622E00B72ECB /* Account.swift */, 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */, 58CCA01D2242787B004F3011 /* AccountTextField.swift */, + 58F19E32228B383300C7710B /* AccountVerificationProcedure.swift */, 58CCA01722426713004F3011 /* AccountViewController.swift */, 58CE5E63224146200008646E /* AppDelegate.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, @@ -163,6 +175,7 @@ 589AB4F8227C50D80039131E /* CustomNavigationBar.swift */, 58F37E7C2243ECCB00C75C97 /* HeaderBarViewController.swift */, 58CE5E6F224146210008646E /* Info.plist */, + 58F19E30228B2AEB00C7710B /* JSONRequestProcedure.swift */, 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */, 58CE5E6C224146210008646E /* LaunchScreen.storyboard */, 58CE5E65224146200008646E /* LoginViewController.swift */, @@ -176,9 +189,11 @@ 5888AD82227B11080051EB06 /* SelectLocationCell.swift */, 5888AD86227B17950051EB06 /* SelectLocationController.swift */, 58CCA01122424D11004F3011 /* SettingsViewController.swift */, + 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, 58CCA0152242560B004F3011 /* UIColor+Palette.swift */, + 58FFE443228C82A00036F391 /* UserDefaultsInteractor.swift */, ); path = MullvadVPN; sourceTree = "<group>"; @@ -385,7 +400,9 @@ buildActionMask = 2147483647; files = ( 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */, + 58461AD3228D622E00B72ECB /* Account.swift in Sources */, 5888AD87227B17950051EB06 /* SelectLocationController.swift in Sources */, + 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 58ADDB40227B1E7100FAFEA7 /* Optional+Unwrap.swift in Sources */, 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, @@ -398,11 +415,14 @@ 58ADDB3C227B1BD200FAFEA7 /* JsonRpc.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, + 58F19E33228B383300C7710B /* AccountVerificationProcedure.swift in Sources */, 58F37E7D2243ECCB00C75C97 /* HeaderBarViewController.swift in Sources */, 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */, 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */, 5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */, + 58F19E31228B2AEB00C7710B /* JSONRequestProcedure.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, + 58FFE444228C82A00036F391 /* UserDefaultsInteractor.swift in Sources */, 5888AD89227B18C40051EB06 /* RelayList.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, ); diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift new file mode 100644 index 0000000000..ef340d9339 --- /dev/null +++ b/ios/MullvadVPN/Account.swift @@ -0,0 +1,58 @@ +// +// Account.swift +// MullvadVPN +// +// Created by pronebird on 16/05/2019. +// Copyright © 2019 Amagicom AB. All rights reserved. +// + +import Foundation +import ProcedureKit +import os.log + +/// A class that groups the account related operations +class Account { + + enum Error: Swift.Error { + case invalidAccount + } + + /// Perform the login and save the account token along with expiry (if available) to the + /// application preferences. + class func login(with accountToken: String) -> Procedure { + let userDefaultsInteractor = UserDefaultsInteractor.withApplicationGroupUserDefaults() + + // Request account token verification + let verificationProcedure = AccountVerificationProcedure(accountToken: accountToken) + + // Update the application preferences based on the AccountVerification result. + let saveAccountDataProcedure = TransformProcedure { (verification) in + switch verification { + case .verified(let expiry): + userDefaultsInteractor.accountToken = accountToken + userDefaultsInteractor.accountExpiry = expiry + + case .deferred(let error): + userDefaultsInteractor.accountToken = accountToken + userDefaultsInteractor.accountExpiry = nil + + os_log(.info, #"Could not request the account verification "%{private}s": %{public}s"#, + accountToken, error.localizedDescription) + + case .invalid: + throw Error.invalidAccount + } + }.injectResult(from: verificationProcedure) + + return GroupProcedure(operations: [verificationProcedure, saveAccountDataProcedure]) + } + + /// Perform the logout by erasing the account token and expiry from the application preferences. + class func logout() { + let userDefaultsInteractor = UserDefaultsInteractor.withApplicationGroupUserDefaults() + + userDefaultsInteractor.accountToken = nil + userDefaultsInteractor.accountExpiry = nil + } + +} diff --git a/ios/MullvadVPN/AccountVerificationProcedure.swift b/ios/MullvadVPN/AccountVerificationProcedure.swift new file mode 100644 index 0000000000..f3b31062b5 --- /dev/null +++ b/ios/MullvadVPN/AccountVerificationProcedure.swift @@ -0,0 +1,88 @@ +// +// AccountVerificationProcedure.swift +// MullvadVPN +// +// Created by pronebird on 14/05/2019. +// Copyright © 2019 Amagicom AB. All rights reserved. +// + +import Foundation +import ProcedureKit + +/// Account verification result +enum AccountVerification { + /// The app should attempt to verify the account token at some point later because the network + /// may not be available at this time. + case deferred(Error) + + /// The app successfully verified the account token with the server + case verified(Date) + + // Invalid token + case invalid +} + +/// The error code returned by the API when it cannot find the given account token +private let kAccountDoesNotExistErrorCode = -200 + +/// The procedure that implements account verification by sending the account expiry request to the +/// Mullvad API. This procedure is non-fallable so even in the case of network issues it will set +/// the output and return no errors. +class AccountVerificationProcedure: GroupProcedure, InputProcedure, OutputProcedure { + var input: Pending<String> + var output: Pending<ProcedureResult<AccountVerification>> = .pending + + init(dispatchQueue underlyingQueue: DispatchQueue? = nil, accountToken: String? = nil) { + self.input = accountToken.flatMap { .ready($0) } ?? .pending + + // Request account data from the API + let networkRequest = MullvadAPI.getAccountExpiry(accountToken: accountToken) + + super.init(dispatchQueue: underlyingQueue, operations: [ + // Wrap the network request into the ignoreErrorsProcedure to make sure that any network + // or JSON decoding errors do not get propagates. These errors will be returned along + // with the AccountVerification via the output. + IgnoreErrorsProcedure(dispatchQueue: underlyingQueue, operation: networkRequest) + ]) + + // Copy the input of the group procedure to the input of the starting procedure + addWillExecuteBlockObserver { [weak networkRequest] (groupProcedure, _) in + networkRequest?.input = groupProcedure.input + } + + networkRequest.addWillFinishBlockObserver { [weak self] (networkRequest, error, _) in + guard let self = self else { return } + + // Obtain the network error or the procedure result + guard let procedureResult = error.flatMap({ .failure($0) }) + ?? networkRequest.output.value else { return } + + // Do not set the output if the network request was cancelled + if !networkRequest.isCancelled { + self.output = .ready(.success(self.mapResult(procedureResult))) + } + } + } + + private func mapResult(_ procedureResult: ProcedureResult<JsonRpcResponse<Date>>) -> Output { + // Unwrap the result of the network request procedure + switch procedureResult { + case .success(let response): + // Unwrap the JSON RPC response + switch response.result { + case .success(let expiryDate): + return .verified(expiryDate) + + case .failure(let serverError): + if serverError.code == kAccountDoesNotExistErrorCode { + return .invalid + } else { + return .deferred(serverError) + } + } + case .failure(let networkError): + // Check back later in case of network issues + return .deferred(networkError) + } + } +} diff --git a/ios/MullvadVPN/Base.lproj/Main.storyboard b/ios/MullvadVPN/Base.lproj/Main.storyboard index 1fb6bc311e..a79cd3d02c 100644 --- a/ios/MullvadVPN/Base.lproj/Main.storyboard +++ b/ios/MullvadVPN/Base.lproj/Main.storyboard @@ -4,7 +4,6 @@ <adaptation id="fullscreen"/> </device> <dependencies> - <deployment identifier="iOS"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> @@ -30,6 +29,14 @@ <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="0ZY-Kh-JiM" userLabel="Container"> <rect key="frame" x="0.0" y="94" width="375" height="573"/> <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pID-oa-Rrg" customClass="SpinnerActivityIndicatorView" customModule="MullvadVPN" customModuleProvider="target"> + <rect key="frame" x="163.5" y="125.5" width="48" height="48"/> + <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="48" id="2J4-Qc-ctc"/> + <constraint firstAttribute="width" constant="48" id="ohE-fk-mg9"/> + </constraints> + </view> <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="V3j-Lb-fSQ" userLabel="Form"> <rect key="frame" x="0.0" y="203.5" width="375" height="126"/> <subviews> @@ -85,8 +92,10 @@ </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> + <constraint firstItem="V3j-Lb-fSQ" firstAttribute="top" secondItem="pID-oa-Rrg" secondAttribute="bottom" constant="30" id="2Sy-bS-AZZ"/> <constraint firstItem="V3j-Lb-fSQ" firstAttribute="centerY" secondItem="0ZY-Kh-JiM" secondAttribute="centerY" constant="-20" id="3Uk-YZ-4C3"/> <constraint firstAttribute="trailing" secondItem="V3j-Lb-fSQ" secondAttribute="trailing" id="EHy-Cx-cGj"/> + <constraint firstItem="pID-oa-Rrg" firstAttribute="centerX" secondItem="0ZY-Kh-JiM" secondAttribute="centerX" id="Ojm-D5-HnO"/> <constraint firstItem="V3j-Lb-fSQ" firstAttribute="leading" secondItem="0ZY-Kh-JiM" secondAttribute="leading" id="alr-G1-L4w"/> </constraints> </view> @@ -139,6 +148,7 @@ </view> <connections> <outlet property="accountTextField" destination="XOB-ct-yLU" id="mXd-SV-E16"/> + <outlet property="activityIndicator" destination="pID-oa-Rrg" id="GG2-Hv-FWl"/> <outlet property="keyboardToolbar" destination="waX-JF-VTG" id="kav-5t-mkA"/> <outlet property="loginForm" destination="V3j-Lb-fSQ" id="tYu-S8-ylm"/> <outlet property="loginFormWrapperBottomConstraint" destination="09L-EV-qfI" id="fYF-OK-trh"/> @@ -529,7 +539,7 @@ <image name="TranslucentNeutralButton" width="9" height="9"/> </resources> <inferredMetricsTieBreakers> - <segue reference="qKR-6L-kfz"/> - <segue reference="fxZ-Uq-nxv"/> + <segue reference="tVd-Lw-FVU"/> + <segue reference="RjC-Wk-Enk"/> </inferredMetricsTieBreakers> </document> diff --git a/ios/MullvadVPN/JSONRequestProcedure.swift b/ios/MullvadVPN/JSONRequestProcedure.swift new file mode 100644 index 0000000000..6b6527b6f1 --- /dev/null +++ b/ios/MullvadVPN/JSONRequestProcedure.swift @@ -0,0 +1,47 @@ +// +// JSONRequestProcedure.swift +// MullvadVPN +// +// Created by pronebird on 14/05/2019. +// Copyright © 2019 Amagicom AB. All rights reserved. +// + +import Foundation +import ProcedureKit + +final class JSONRequestProcedure<Input, Output: Decodable>: GroupProcedure, InputProcedure, OutputProcedure { + + typealias URLRequestBuilder = (Input) throws -> URLRequest + + var input: Pending<Input> + var output: Pending<ProcedureResult<Output>> = .pending + + init(dispatchQueue underlyingQueue: DispatchQueue? = nil, input: Input? = nil, requestBuilder: @escaping URLRequestBuilder) { + self.input = input.flatMap { .ready($0) } ?? .pending + + let createRequest = TransformProcedure { try requestBuilder($0) } + + let networkRequest = NetworkProcedure { + NetworkDataProcedure(session: URLSession.shared) + }.injectResult(from: createRequest) + + let payloadParsing = DecodeJSONProcedure<Output>( + dateDecodingStrategy: .iso8601, + keyDecodingStrategy: .convertFromSnakeCase + ).injectPayload(fromNetwork: networkRequest) + + super.init(dispatchQueue: underlyingQueue, operations: [createRequest, networkRequest, payloadParsing]) + + bind(from: payloadParsing) + + addWillExecuteBlockObserver { (procedure, _) in + createRequest.input = procedure.input + } + } +} + +extension JSONRequestProcedure where Input == Void { + convenience init(requestBuilder: @escaping URLRequestBuilder) { + self.init(input: (), requestBuilder: requestBuilder) + } +} diff --git a/ios/MullvadVPN/LoginViewController.swift b/ios/MullvadVPN/LoginViewController.swift index 4a7e364b09..424a42e558 100644 --- a/ios/MullvadVPN/LoginViewController.swift +++ b/ios/MullvadVPN/LoginViewController.swift @@ -7,6 +7,8 @@ // import UIKit +import ProcedureKit +import os.log class LoginViewController: UIViewController, HeaderBarViewControllerDelegate { @@ -14,6 +16,9 @@ class LoginViewController: UIViewController, HeaderBarViewControllerDelegate { @IBOutlet var accountTextField: UITextField! @IBOutlet var loginForm: UIView! @IBOutlet var loginFormWrapperBottomConstraint: NSLayoutConstraint! + @IBOutlet var activityIndicator: SpinnerActivityIndicatorView! + + private let procedureQueue = ProcedureQueue() override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent @@ -68,14 +73,58 @@ class LoginViewController: UIViewController, HeaderBarViewControllerDelegate { } @IBAction func doLogin() { - view.endEditing(true) + let accountToken = accountTextField.text ?? "" + + beginLoginAnimations() + + verifyAccount(accountToken: accountToken) { [weak self] (result) in + guard let self = self else { return } + + switch result { + case .success: + self.performSegue(withIdentifier: SegueIdentifier.Login.showConnect.rawValue, + sender: self) + + case .failure(let error as Account.Error): + // TODO: Handle account errors + break + + case .failure(let error): + // TODO: Handle any other errors + break + } + + self.endLoginAnimations() + } - // TODO: Add the code to initiate the log in - performSegue(withIdentifier: "ShowConnect", sender: self) } // MARK: - Private + private func verifyAccount(accountToken: String, completion: @escaping (Result<(), Error>) -> Void) { + let delayProcedure = DelayProcedure(by: 1) + let loginProcedure = Account.login(with: accountToken) + + loginProcedure.addDependency(delayProcedure) + loginProcedure.addDidFinishBlockObserver(synchronizedWith: DispatchQueue.main) { (_, error) in + completion(error.flatMap({ .failure($0) }) ?? .success(())) + } + + procedureQueue.addOperations([delayProcedure, loginProcedure]) + } + + private func beginLoginAnimations() { + activityIndicator.isAnimating = true + accountTextField.isEnabled = false + + view.endEditing(true) + } + + private func endLoginAnimations() { + activityIndicator.isAnimating = false + accountTextField.isEnabled = true + } + private func makeLoginFormVisible(keyboardFrame: CGRect) { let convertedKeyboardFrame = view.convert(keyboardFrame, from: nil) let (_, remainder) = view.frame.divided(atDistance: convertedKeyboardFrame.minY, from: CGRectEdge.minYEdge) diff --git a/ios/MullvadVPN/MullvadAPI.swift b/ios/MullvadVPN/MullvadAPI.swift index b5b49be39c..28bc9f9894 100644 --- a/ios/MullvadVPN/MullvadAPI.swift +++ b/ios/MullvadVPN/MullvadAPI.swift @@ -7,31 +7,35 @@ // import Foundation +import ProcedureKit private let kMullvadAPIURL = URL(string: "https://api.mullvad.net/rpc/")! class MullvadAPI { - class func getRelayList(completion: @escaping (_ result: Result<JsonRpcResponse<RelayList>, Error>) -> Void) -> URLSessionDataTask { - let urlRequest = try! makeURLRequest(method: "POST", rpcRequest: JsonRpcRequest(method: "relay_list_v2")) - - return URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in - DispatchQueue.main.async { - completion(error.flatMap({ Result.failure($0) }) ?? Result(catching: { - try self.decodeResponse(data: try data.unwrap()) - })) - } - } + class func getRelayList() -> JSONRequestProcedure<Void, JsonRpcResponse<RelayList>> { + return JSONRequestProcedure(requestBuilder: { + try makeURLRequest(method: "POST", rpcRequest: JsonRpcRequest(method: "relay_list_v2")) + }) } - private class func decodeResponse<T: Decodable>(data: Data) throws -> JsonRpcResponse<T> { - let decoder = defaultJsonDecoder() + class func getAccountExpiry(accountToken: String? = nil) -> JSONRequestProcedure<String, JsonRpcResponse<Date>> { + return JSONRequestProcedure(input: accountToken, requestBuilder: { + try makeURLRequest( + method: "POST", + rpcRequest: JsonRpcRequest(method: "get_expiry", params: [$0]) + ) + }) + } - return try decoder.decode(JsonRpcResponse<T>.self, from: data) + class func verifyAccountToken(_ accountToken: String? = nil) -> AccountVerificationProcedure { + return AccountVerificationProcedure(accountToken: accountToken) } private class func makeURLRequest<T: Encodable>(method: String, rpcRequest: JsonRpcRequest<T>) throws -> URLRequest { - let encoder = defaultJsonEncoder() + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 var urlRequest = URLRequest(url: kMullvadAPIURL) urlRequest.httpMethod = method @@ -42,18 +46,3 @@ class MullvadAPI { } } - -private func defaultJsonEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .iso8601 - return encoder -} - -private func defaultJsonDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder -} - diff --git a/ios/MullvadVPN/SegueIdentifier.swift b/ios/MullvadVPN/SegueIdentifier.swift index 9558ef68fd..536ab74717 100644 --- a/ios/MullvadVPN/SegueIdentifier.swift +++ b/ios/MullvadVPN/SegueIdentifier.swift @@ -19,6 +19,7 @@ struct SegueIdentifier { enum Login: String, SegueConvertible { case embedHeader = "EmbedHeaderBar" case showSettings = "ShowSettings" + case showConnect = "ShowConnect" } enum SelectLocation: String, SegueConvertible { diff --git a/ios/MullvadVPN/SelectLocationController.swift b/ios/MullvadVPN/SelectLocationController.swift index 46ab41a23a..abb3f085f7 100644 --- a/ios/MullvadVPN/SelectLocationController.swift +++ b/ios/MullvadVPN/SelectLocationController.swift @@ -7,6 +7,7 @@ // import UIKit +import ProcedureKit import os.log private let cellIdentifier = "Cell" @@ -17,6 +18,8 @@ class SelectLocationController: UITableViewController { private var expandedItems = [RelayListDataSourceItem]() private var displayedItems = [RelayListDataSourceItem]() + private let procedureQueue = ProcedureQueue() + var selectedItem: RelayListDataSourceItem? // MARK: - View lifecycle @@ -85,15 +88,18 @@ class SelectLocationController: UITableViewController { // MARK: - Relay list handling private func loadRelayList() { - let task = MullvadAPI.getRelayList { [weak self] (result) in - do { - self?.didReceiveRelayList(try result.get()) - } catch { - os_log(.error, "Relay list network error: %{public}s", error.localizedDescription) + let procedure = MullvadAPI.getRelayList() + + procedure.addDidFinishBlockObserver(synchronizedWith: DispatchQueue.main) { [weak self] (procedure, error) in + guard let response = procedure.output.success else { + os_log(.error, "Relay list network error: %{public}s", error?.localizedDescription ?? "(null)") + return } + + self?.didReceiveRelayList(response) } - task.resume() + procedureQueue.addOperation(procedure) } private func didReceiveRelayList(_ response: JsonRpcResponse<RelayList>) { diff --git a/ios/MullvadVPN/SpinnerActivityIndicatorView.swift b/ios/MullvadVPN/SpinnerActivityIndicatorView.swift new file mode 100644 index 0000000000..ea33c64024 --- /dev/null +++ b/ios/MullvadVPN/SpinnerActivityIndicatorView.swift @@ -0,0 +1,188 @@ +// +// SpinnerActivityIndicatorView.swift +// MullvadVPN +// +// Created by pronebird on 15/05/2019. +// Copyright © 2019 Amagicom AB. All rights reserved. +// + +import UIKit + +private let kRotationAnimationKey = "rotation" +private let kAnimationDuration = 0.6 + +@IBDesignable class SpinnerActivityIndicatorView: UIView { + + /// Thickness of the front and back circles + var thickness: CGFloat = 6 { + didSet { + setLayersThickness() + } + } + + /// The back circle color + var backCircleColor = UIColor.white.withAlphaComponent(0.2) { + didSet { + setBackCircleLayerColor() + } + } + + /// The front circle color + var frontCircleColor: UIColor? { + didSet { + setFrontCircleLayerColor() + } + } + + @IBInspectable var isAnimating: Bool = false { + didSet { + guard oldValue != isAnimating else { return } + + if isAnimating { + startAnimating() + } else { + stopAnimating() + } + } + } + + fileprivate let frontCircle = CAShapeLayer() + fileprivate let backCircle = CAShapeLayer() + fileprivate var startTime = CFTimeInterval(0) + fileprivate var stopTime = CFTimeInterval(0) + + override var intrinsicContentSize: CGSize { + return CGSize(width: 48, height: 48) + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + deinit { + unregisterFromAppStateNotifications() + } + + override func layoutSublayers(of layer: CALayer) { + super.layoutSublayers(of: layer) + setupBezierPaths() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + + if window != nil { + restartAnimationIfNeeded() + } + } + + override func tintColorDidChange() { + super.tintColorDidChange() + + setFrontCircleLayerColor() + } + + // MARK: - Private + + private func startAnimating() { + isHidden = false + addAnimation() + } + + private func stopAnimating() { + isHidden = true + removeAnimation() + } + + private func commonInit() { + registerForAppStateNotifications() + + isHidden = true + backgroundColor = UIColor.clear + + backCircle.fillColor = UIColor.clear.cgColor + frontCircle.fillColor = UIColor.clear.cgColor + frontCircle.lineCap = .round + + setBackCircleLayerColor() + setFrontCircleLayerColor() + setLayersThickness() + + layer.addSublayer(backCircle) + layer.addSublayer(frontCircle) + } + + private func setLayersThickness() { + backCircle.lineWidth = thickness + frontCircle.lineWidth = thickness + } + + private func setBackCircleLayerColor() { + backCircle.strokeColor = backCircleColor.cgColor + } + + private func setFrontCircleLayerColor() { + frontCircle.strokeColor = frontCircleColor?.cgColor ?? tintColor.cgColor + } + + private func addAnimation() { + let timeOffset = stopTime - startTime + + let anim = animation() + anim.timeOffset = timeOffset + + layer.add(anim, forKey: kRotationAnimationKey) + + startTime = layer.convertTime(CACurrentMediaTime(), from: nil) - timeOffset + } + + private func removeAnimation() { + layer.removeAnimation(forKey: kRotationAnimationKey) + + stopTime = layer.convertTime(CACurrentMediaTime(), from: nil) + } + + @objc private func restartAnimationIfNeeded() { + let anim = layer.animation(forKey: kRotationAnimationKey) + + if isAnimating && anim == nil { + removeAnimation() + addAnimation() + } + } + + private func registerForAppStateNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(restartAnimationIfNeeded), name: UIApplication.willEnterForegroundNotification, object: nil) + } + + private func unregisterFromAppStateNotifications() { + NotificationCenter.default.removeObserver(self) + } + + private func animation() -> CABasicAnimation { + let animation = CABasicAnimation(keyPath: "transform.rotation") + animation.toValue = NSNumber(value: Double.pi * 2) + animation.duration = kAnimationDuration + animation.repeatCount = Float.infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + + return animation + } + + private func setupBezierPaths() { + let center = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) + let radius = bounds.size.width * 0.5 - thickness + let closedRingPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2, clockwise: true) + let openRingPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 1.5, clockwise: true) + + backCircle.path = closedRingPath.cgPath + frontCircle.path = openRingPath.cgPath + } + +} diff --git a/ios/MullvadVPN/UserDefaultsInteractor.swift b/ios/MullvadVPN/UserDefaultsInteractor.swift new file mode 100644 index 0000000000..7156e96381 --- /dev/null +++ b/ios/MullvadVPN/UserDefaultsInteractor.swift @@ -0,0 +1,54 @@ +// +// UserDefaultsInteractor.swift +// MullvadVPN +// +// Created by pronebird on 15/05/2019. +// Copyright © 2019 Amagicom AB. All rights reserved. +// + +import Foundation + +/// The application group identifier used for sharing application preferences between processes +private let kApplicationGroupIdentifier = "group.net.mullvad.MullvadVPN" + +/// The UserDefaults keys used to store the application preferences +private enum UserDefaultsKeys: String { + case accountToken, accountExpiry +} + +/// The interactor class that provides a convenient interface for accessing the Mullvad VPN +/// preferences stored in the UserDefaults store. +class UserDefaultsInteractor { + let userDefaults: UserDefaults + + /// Returns the instance of UserDefaultsInteractor initialized with the application preferences + /// scoped to the application group. + class func withApplicationGroupUserDefaults() -> UserDefaultsInteractor { + let userDefaults = UserDefaults(suiteName: kApplicationGroupIdentifier)! + + return UserDefaultsInteractor(userDefaults: userDefaults) + } + + init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + var accountToken: String? { + get { + return userDefaults.string(forKey: UserDefaultsKeys.accountToken.rawValue) + } + set { + userDefaults.set(newValue, forKey: UserDefaultsKeys.accountToken.rawValue) + } + } + + var accountExpiry: Date? { + get { + return userDefaults.object(forKey: UserDefaultsKeys.accountExpiry.rawValue) as? Date + } + set { + userDefaults.set(newValue, forKey: UserDefaultsKeys.accountExpiry.rawValue) + } + } + +} |
