summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-05-14 16:33:45 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-05-17 19:35:08 +0200
commit87bf4647623d81b8d3f9de38d50cde1bbb10923f (patch)
tree0e82932596dd102b49c28a1dd58dba45a880179c /ios
parenta44c7247d40e9197f8021a467a9669f455e14bc7 (diff)
downloadmullvadvpn-87bf4647623d81b8d3f9de38d50cde1bbb10923f.tar.xz
mullvadvpn-87bf4647623d81b8d3f9de38d50cde1bbb10923f.zip
Implement account verification and login
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj20
-rw-r--r--ios/MullvadVPN/Account.swift58
-rw-r--r--ios/MullvadVPN/AccountVerificationProcedure.swift88
-rw-r--r--ios/MullvadVPN/Base.lproj/Main.storyboard16
-rw-r--r--ios/MullvadVPN/JSONRequestProcedure.swift47
-rw-r--r--ios/MullvadVPN/LoginViewController.swift55
-rw-r--r--ios/MullvadVPN/MullvadAPI.swift47
-rw-r--r--ios/MullvadVPN/SegueIdentifier.swift1
-rw-r--r--ios/MullvadVPN/SelectLocationController.swift18
-rw-r--r--ios/MullvadVPN/SpinnerActivityIndicatorView.swift188
-rw-r--r--ios/MullvadVPN/UserDefaultsInteractor.swift54
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)
+ }
+ }
+
+}