diff options
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 116 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountExpiryRefresh.swift | 135 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountVerificationProcedure.swift | 88 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.swift | 23 | ||||
| -rw-r--r-- | ios/MullvadVPN/UserDefaultsInteractor.swift | 54 |
5 files changed, 87 insertions, 329 deletions
diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index 2c006dd8c5..0d98134769 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -6,67 +6,107 @@ // Copyright © 2019 Amagicom AB. All rights reserved. // +import Combine import Foundation -import ProcedureKit -import os.log +import os + +/// A enum describing the errors emitted by `Account` +enum AccountError: Error { + /// A failure to perform the login + case login(AccountLoginError) + + /// A failure to log out + case logout(TunnelManagerError) +} + +/// A enum describing the error emitted during login +enum AccountLoginError: Error { + case invalidAccount + case tunnelConfiguration(TunnelManagerError) +} + +/// A enum holding the `UserDefaults` string keys +private enum UserDefaultsKeys: String { + case accountToken = "accountToken" + case accountExpiry = "accountExpiry" +} /// A class that groups the account related operations class Account { - enum Error: Swift.Error { - case invalidAccount - } + static let shared = Account() + private let apiClient = MullvadAPI() /// Returns the currently used account token - static var token: String? { - return UserDefaultsInteractor.sharedApplicationGroupInteractor.accountToken + var token: String? { + return UserDefaults.standard.string(forKey: UserDefaultsKeys.accountToken.rawValue) } /// Returns the account expiry for the currently used account token - static var expiry: Date? { - return UserDefaultsInteractor.sharedApplicationGroupInteractor.accountExpiry + var expiry: Date? { + return UserDefaults.standard.object(forKey: UserDefaultsKeys.accountExpiry.rawValue) as? Date } - static var isLoggedIn: Bool { + var isLoggedIn: Bool { return token != nil } /// 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.sharedApplicationGroupInteractor - - // 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 + func login(with accountToken: String) -> AnyPublisher<(), AccountError> { + return apiClient.verifyAccount(accountToken: accountToken) + .setFailureType(to: AccountLoginError.self) + .handleEvents(receiveOutput: { (accountVerification) in + if case .deferred(let error) = accountVerification { + os_log(.error, "Failed to verify the account: %{public}s", error.localizedDescription) + } + }) + .flatMap { + self.handleVerification($0).publisher + .flatMap { (expiry) in + TunnelManager.shared.setAccount(accountToken: accountToken) + .mapError { AccountLoginError.tunnelConfiguration($0) } + .map { expiry } + } + }.mapError { AccountError.login($0) } + .receive(on: DispatchQueue.main).map { (expiry) in + self.saveAccountToPreferences(accountToken: accountToken, expiry: expiry) + }.eraseToAnyPublisher() + } - case .deferred(let error): - userDefaultsInteractor.accountToken = accountToken - userDefaultsInteractor.accountExpiry = nil + /// Perform the logout by erasing the account token and expiry from the application preferences. + func logout() -> AnyPublisher<(), AccountError> { + return TunnelManager.shared.unsetAccount() + .receive(on: DispatchQueue.main) + .mapError { AccountError.logout($0) } + .map(self.removeAccountFromPreferences) + .eraseToAnyPublisher() + } - os_log(.info, #"Could not request the account verification "%{private}s": %{public}s"#, - accountToken, error.localizedDescription) + private func handleVerification(_ verification: AccountVerification) -> Result<Date?, AccountLoginError> { + switch verification { + case .deferred: + return .success(nil) + case .verified(let expiry): + return .success(expiry) + case .invalid: + return .failure(.invalidAccount) + } + } - case .invalid: - throw Error.invalidAccount - } - }.injectResult(from: verificationProcedure) + private func saveAccountToPreferences(accountToken: String, expiry: Date?) { + let preferences = UserDefaults.standard - return GroupProcedure(operations: [verificationProcedure, saveAccountDataProcedure]) + preferences.set(accountToken, forKey: UserDefaultsKeys.accountToken.rawValue) + preferences.set(expiry, forKey: UserDefaultsKeys.accountExpiry.rawValue) } - /// Perform the logout by erasing the account token and expiry from the application preferences. - class func logout() { - let userDefaultsInteractor = UserDefaultsInteractor.sharedApplicationGroupInteractor + private func removeAccountFromPreferences() { + let preferences = UserDefaults.standard - userDefaultsInteractor.accountToken = nil - userDefaultsInteractor.accountExpiry = nil - } + preferences.removeObject(forKey: UserDefaultsKeys.accountToken.rawValue) + preferences.removeObject(forKey: UserDefaultsKeys.accountExpiry.rawValue) + } } + diff --git a/ios/MullvadVPN/AccountExpiryRefresh.swift b/ios/MullvadVPN/AccountExpiryRefresh.swift deleted file mode 100644 index c6b1c6278b..0000000000 --- a/ios/MullvadVPN/AccountExpiryRefresh.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// AccountExpiryRefresh.swift -// MullvadVPN -// -// Created by pronebird on 28/05/2019. -// Copyright © 2019 Amagicom AB. All rights reserved. -// - -import Foundation -import os.log -import ProcedureKit - -private let kRefreshIntervalSeconds: TimeInterval = 60 - -/// A class that manages the periodic account expiry updates. -/// All public methods are thread safe. -class AccountExpiryRefresh { - - /// A singleton instance of the AccountExpiryRefresh - static let shared = AccountExpiryRefresh() - - private let procedureQueue: ProcedureQueue = { - let queue = ProcedureQueue() - queue.qualityOfService = .utility - return queue - }() - - /// Recursive lock used to manipulate observers - private let lock = NSRecursiveLock() - private var observers = [WeakBox<Observer>]() - - private init() {} - - /// Adds the observer for periodic account expiry updates. - func startMonitoringUpdates(with block: @escaping (Date) -> Void) -> Observer { - let observer = Observer(with: block) - - addObserver(observer) - - return observer - } - - /// Register observer and start updating the account expiry if hasn't started yet - private func addObserver(_ observer: Observer) { - lock.withCriticalScope { - let wasEmpty = observers.isEmpty - - observers.append(WeakBox(observer)) - - if wasEmpty { - procedureQueue.addOperation(makePeriodicUpdateProcedure()) - } - } - - } - - /// Remove all boxed values whos underlying weak value has been released - private func compactObservers() { - lock.withCriticalScope { - observers.removeAll { $0.unboxed == nil } - } - } - - /// Broadcast the new expiry to the observers - private func notifyObservers(with newExpiry: Date) { - let strongObservers = lock.withCriticalScope { observers.compactMap { $0.unboxed } } - - DispatchQueue.main.async { - strongObservers.forEach { $0.notify(with: newExpiry) } - } - } - - /// Returns true if the repeat procedure should keep running - private func shouldKeepRefreshing() -> Bool { - return lock.withCriticalScope { - compactObservers() - - return !observers.isEmpty - } - } - - /// Create a procedure that will repeat itself with a constant interval until there are no - /// observers left. - private func makePeriodicUpdateProcedure() -> RepeatProcedure<Operation> { - let repeatProcedure = RepeatProcedure(wait: .constant(kRefreshIntervalSeconds)) { [weak self] () -> Operation? in - // Stop repeating the procedure if no-one is listening - guard let self = self, self.shouldKeepRefreshing() else { return nil } - - // Create the procedure to feed the account token saved in preferences into the - // request procedure - let getAccountTokenProcedure = ResultProcedure(block: { Account.token }) - - // Create the API request procedure - let requestProcedure = MullvadAPI.getAccountExpiry() - .injectResult(from: getAccountTokenProcedure) - - // Create the procedure to save the received account expiry and notify the observers - let saveAccountExpiryProcedure = TransformProcedure { [weak self] (response) throws -> Void in - let userDefaultsInteractor = UserDefaultsInteractor.sharedApplicationGroupInteractor - - let newAccountExpiry = try response.result.get() - let oldAccountExpiry = userDefaultsInteractor.accountExpiry - - if oldAccountExpiry != newAccountExpiry { - userDefaultsInteractor.accountExpiry = newAccountExpiry - - self?.notifyObservers(with: newAccountExpiry) - } - }.injectResult(from: requestProcedure) - - // Return the group - return GroupProcedure(operations: [getAccountTokenProcedure, requestProcedure, saveAccountExpiryProcedure]) - } - - // Make sure that only one such operation runs at a time - repeatProcedure.addCondition(MutuallyExclusive<AccountExpiryRefresh>()) - - return repeatProcedure - } - - /// The account expiry observer. - class Observer { - typealias Block = (Date) -> Void - private let block: Block - - fileprivate init(with block: @escaping Block) { - self.block = block - } - - fileprivate func notify(with expiryDate: Date) { - block(expiryDate) - } - } - -} diff --git a/ios/MullvadVPN/AccountVerificationProcedure.swift b/ios/MullvadVPN/AccountVerificationProcedure.swift deleted file mode 100644 index f3b31062b5..0000000000 --- a/ios/MullvadVPN/AccountVerificationProcedure.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// 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/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift index a52cee7e83..c51a8e6977 100644 --- a/ios/MullvadVPN/AccountViewController.swift +++ b/ios/MullvadVPN/AccountViewController.swift @@ -6,6 +6,7 @@ // Copyright © 2019 Amagicom AB. All rights reserved. // +import Combine import UIKit class AccountViewController: UIViewController { @@ -13,13 +14,12 @@ class AccountViewController: UIViewController { @IBOutlet var accountLabel: UILabel! @IBOutlet var expiryLabel: UILabel! - private var accountExpiryObserver: AccountExpiryRefresh.Observer? + private var logoutSubscriber: AnyCancellable? override func viewDidLoad() { super.viewDidLoad() updateView() - startAccountExpiryUpdates() } // MARK: - Actions @@ -29,17 +29,19 @@ class AccountViewController: UIViewController { } @IBAction func doLogout() { - Account.logout() - - performSegue(withIdentifier: SegueIdentifier.Account.logout.rawValue, sender: self) + logoutSubscriber = Account.shared.logout() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (_) in + self.performSegue(withIdentifier: SegueIdentifier.Account.logout.rawValue, sender: self) + }) } // MARK: - Private private func updateView() { - accountLabel.text = Account.token + accountLabel.text = Account.shared.token - if let expiryDate = Account.expiry { + if let expiryDate = Account.shared.expiry { let accountExpiry = AccountExpiry(date: expiryDate) if accountExpiry.isExpired { @@ -51,11 +53,4 @@ class AccountViewController: UIViewController { } } } - - private func startAccountExpiryUpdates() { - accountExpiryObserver = AccountExpiryRefresh.shared - .startMonitoringUpdates(with: { [weak self] (expiryDate) in - self?.updateView() - }) - } } diff --git a/ios/MullvadVPN/UserDefaultsInteractor.swift b/ios/MullvadVPN/UserDefaultsInteractor.swift deleted file mode 100644 index 786611e448..0000000000 --- a/ios/MullvadVPN/UserDefaultsInteractor.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// 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 - - /// The shared instance of UserDefaultsInteractor initialized with the application group - /// preferences - static let sharedApplicationGroupInteractor: 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) - } - } - -} |
