diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-03-27 15:59:44 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2020-03-27 15:59:44 +0100 |
| commit | fd23cf6797be5677320bb2ddd6666704d92af613 (patch) | |
| tree | 7d54aa80aa4a89f920c9f80d70ee4e4697c54f57 | |
| parent | 84f4ba7b4a34be14cc164f0321389c71f407cfb1 (diff) | |
| parent | 3e0530491c8e8be2ed03b7af8bd233bef37c07b9 (diff) | |
| download | mullvadvpn-fd23cf6797be5677320bb2ddd6666704d92af613.tar.xz mullvadvpn-fd23cf6797be5677320bb2ddd6666704d92af613.zip | |
Merge branch 'add-payments'
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 12 | ||||
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 27 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppDelegate.swift | 54 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppStorePaymentManager.swift | 352 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppStorePaymentPublisher.swift | 87 | ||||
| -rw-r--r-- | ios/MullvadVPN/SKPaymentQueuePublisher.swift | 71 |
6 files changed, 580 insertions, 23 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 205cd82a43..b6565b3dd6 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -120,10 +120,13 @@ 58CE5E81224146470008646E /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 58CE5E79224146470008646E /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 58D0C79E23F1CEBA00FE9BA7 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */; }; 58D0C7A223F1CECF00FE9BA7 /* MullvadVPNScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C7A023F1CECF00FE9BA7 /* MullvadVPNScreenshots.swift */; }; + 58DF28A52417CB4B00E836B0 /* AppStorePaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */; }; 58EC4E6C23915325003F5C5B /* Bundle+MullvadVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */; }; 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */; }; 58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */; }; 58FD5BE92419406000112C88 /* SKRequestPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */; }; + 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */; }; + 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -255,6 +258,7 @@ 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = "<group>"; }; 58D0C79F23F1CECF00FE9BA7 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 58D0C7A023F1CECF00FE9BA7 /* MullvadVPNScreenshots.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MullvadVPNScreenshots.swift; sourceTree = "<group>"; }; + 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentManager.swift; sourceTree = "<group>"; }; 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+MullvadVersion.swift"; sourceTree = "<group>"; }; 58ECD29123F178FD004298B6 /* Screenshots.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Screenshots.xcconfig; sourceTree = "<group>"; }; 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; @@ -262,6 +266,8 @@ 58FBDAAA22A52DC500EB69A3 /* MullvadVPN-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MullvadVPN-Bridging-Header.h"; sourceTree = "<group>"; }; 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.swift; sourceTree = "<group>"; }; 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKRequestPublisher.swift; sourceTree = "<group>"; }; + 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKPaymentQueuePublisher.swift; sourceTree = "<group>"; }; + 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentPublisher.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -354,6 +360,8 @@ 58CE5E63224146200008646E /* AppDelegate.swift */, 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */, 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, + 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */, + 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, 5845F839236C6A7200B2D93C /* AutoDisposableSink.swift */, 589AB4F6227B64450039131E /* BasicTableViewCell.swift */, @@ -402,6 +410,7 @@ 58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */, 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */, 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */, + 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, @@ -737,6 +746,7 @@ 581CBCEC2298041B00727D7F /* SettingsAppVersionCell.swift in Sources */, 5845F83A236C6A7200B2D93C /* AutoDisposableSink.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, + 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */, 58CCA01822426713004F3011 /* AccountViewController.swift in Sources */, 5868585524054096000B8131 /* AppButton.swift in Sources */, 5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */, @@ -746,6 +756,7 @@ 58B8743222B25A7600015324 /* WireguardAssociatedAddresses.swift in Sources */, 587B08E0229433EB000E6F17 /* LoginState.swift in Sources */, 58C6B34F22BB7AC0003C19AD /* IPAddressRange.swift in Sources */, + 58DF28A52417CB4B00E836B0 /* AppStorePaymentManager.swift in Sources */, 582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */, 5873884D239E6D7E00E96C4E /* EmbeddedViewContainerView.swift in Sources */, 582650862384116F00FA7A86 /* ReplaceNilWithError.swift in Sources */, @@ -766,6 +777,7 @@ 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */, 5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, + 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */, 587AD7CA2342283900E93A53 /* Account.swift in Sources */, 58A8BE8323A0F362006B74AC /* UIAlertController+Error.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index 7ee61f5a76..d05dea3cbd 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -9,6 +9,7 @@ import Combine import Foundation import NetworkExtension +import StoreKit import os /// A enum describing the errors emitted by `Account` @@ -77,6 +78,12 @@ private enum UserDefaultsKeys: String { /// A class that groups the account related operations class Account { + /// A notification name used to broadcast the changes to account expiry + static let didUpdateAccountExpiryNotification = Notification.Name("didUpdateAccountExpiry") + + /// A notification userInfo key that holds the `Date` with the new account expiry + static let newAccountExpiryUserInfoKey = "newAccountExpiry" + static let shared = Account() private let apiClient = MullvadAPI() @@ -163,3 +170,23 @@ class Account { } } +extension Account: AppStorePaymentObserver { + + func startPaymentMonitoring(with paymentManager: AppStorePaymentManager) { + paymentManager.addPaymentObserver(self) + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, didFailWithError error: AppStorePaymentManager.Error) { + // no-op + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, didFinishWithResponse response: SendAppStoreReceiptResponse) { + UserDefaults.standard.set(response.newExpiry, + forKey: UserDefaultsKeys.accountExpiry.rawValue) + + NotificationCenter.default.post( + name: Self.didUpdateAccountExpiryNotification, + object: self, userInfo: [Self.newAccountExpiryUserInfoKey: response.newExpiry] + ) + } +} diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 663911f877..1e33422f85 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -8,6 +8,7 @@ import Combine import UIKit +import StoreKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -39,13 +40,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let rootViewController = self.mainStoryboard.instantiateViewController(identifier: ViewControllerIdentifier.root.rawValue) as! RootContainerViewController + let showMainController = { (_ animated: Bool) in + self.showMainController(in: rootViewController, animated: animated) { + self.didPresentTheMainController() + } + } + if Account.shared.isAgreedToTermsOfService { - self.showMainController(in: rootViewController, animated: false) + showMainController(false) } else { self.showTermsOfService(in: rootViewController) { Account.shared.agreeToTermsOfService() - self.showMainController(in: rootViewController, animated: true) + showMainController(true) } } @@ -55,6 +62,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private func didPresentTheMainController() { + let paymentManager = AppStorePaymentManager.shared + paymentManager.delegate = self + + paymentManager.startPaymentQueueMonitoring() + Account.shared.startPaymentMonitoring(with: paymentManager) + } + private func showTermsOfService(in rootViewController: RootContainerViewController, completionHandler: @escaping () -> Void) { let consentViewController = self.mainStoryboard.instantiateViewController(withIdentifier: ViewControllerIdentifier.consent.rawValue) as! ConsentViewController @@ -63,7 +78,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { rootViewController.setViewControllers([consentViewController], animated: false) } - private func showMainController(in rootViewController: RootContainerViewController, animated: Bool) { + private func showMainController( + in rootViewController: RootContainerViewController, + animated: Bool, + completionHandler: @escaping () -> Void) + { let loginViewController = self.mainStoryboard.instantiateViewController(withIdentifier: ViewControllerIdentifier.login.rawValue) var viewControllers = [loginViewController] @@ -74,29 +93,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { viewControllers.append(mainViewController) } - rootViewController.setViewControllers(viewControllers, animated: animated) - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + rootViewController.setViewControllers(viewControllers, animated: animated, completion: completionHandler) } - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } +} - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } +extension AppDelegate: AppStorePaymentManagerDelegate { - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + func appStorePaymentManager(_ manager: AppStorePaymentManager, + didRequestAccountTokenFor payment: SKPayment) -> String? + { + // Since we do not persist the relation between the payment and account token between the + // app launches, we assume that all successful purchases belong to the active account token. + return Account.shared.token } - } diff --git a/ios/MullvadVPN/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager.swift new file mode 100644 index 0000000000..7fada5ca67 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager.swift @@ -0,0 +1,352 @@ +// +// AppStorePaymentManager.swift +// MullvadVPN +// +// Created by pronebird on 10/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import StoreKit +import os + +enum InAppPurchase: String { + /// Thirty days worth of credit + case thirtyDays = "net.mullvad.MullvadVPN.iap.30days" +} + +extension Set where Element == InAppPurchase { + var productIdentifiersSet: Set<String> { + Set<String>(self.map { $0.rawValue }) + } +} + +protocol AppStorePaymentObserver: class { + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFailWithError error: AppStorePaymentManager.Error) + + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFinishWithResponse response: SendAppStoreReceiptResponse) +} + +/// A type-erasing weak container for `AppStorePaymentObserver` +private class WeakAnyAppStorePaymentObserver: AppStorePaymentObserver { + private(set) weak var inner: AppStorePaymentObserver? + + init(_ inner: AppStorePaymentObserver) { + self.inner = inner + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFailWithError error: AppStorePaymentManager.Error) + { + inner?.appStorePaymentManager(manager, transaction: transaction, didFailWithError: error) + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFinishWithResponse response: SendAppStoreReceiptResponse) + { + inner?.appStorePaymentManager(manager, + transaction: transaction, + didFinishWithResponse: response) + } + +} + +protocol AppStorePaymentManagerDelegate: class { + + /// Return the account token associated with the payment. + /// Usually called for unfinished transactions coming back after the app was restarted. + func appStorePaymentManager(_ manager: AppStorePaymentManager, + didRequestAccountTokenFor payment: SKPayment) -> String? +} + +class AppStorePaymentManager { + + enum SendAppStoreReceiptError: Swift.Error { + case read(AppStoreReceipt.Error) + case network(MullvadAPI.Error) + case server(MullvadAPI.ResponseError) + } + + enum Error: Swift.Error { + case noAccountSet + case storePayment(Swift.Error) + case sendReceipt(SendAppStoreReceiptError) + } + + /// A shared instance of `AppStorePaymentManager` + static let shared = AppStorePaymentManager(queue: SKPaymentQueue.default()) + + private let queue: SKPaymentQueue + private let apiClient = MullvadAPI() + + private var paymentQueueSubscriber: AnyCancellable? + private var sendReceiptSubscriber: AnyCancellable? + + private var observers = [WeakAnyAppStorePaymentObserver]() + private let lock = NSRecursiveLock() + + private weak var classDelegate: AppStorePaymentManagerDelegate? + weak var delegate: AppStorePaymentManagerDelegate? { + get { + lock.withCriticalBlock { + return classDelegate + } + } + set { + lock.withCriticalBlock { + classDelegate = newValue + } + } + } + + /// A private hash map that maps each payment to account token + private var paymentToAccountToken = [SKPayment: String]() + + init(queue: SKPaymentQueue) { + self.queue = queue + } + + func startPaymentQueueMonitoring() { + paymentQueueSubscriber = queue.publisher.sink { [weak self] (transaction) in + self?.handleTransaction(transaction) + } + } + + // MARK: - Payment observation + + func addPaymentObserver(_ observer: AppStorePaymentObserver) { + lock.withCriticalBlock { + let isAlreadyObserving = self.observers.contains(where: { $0.inner === observer }) + + if !isAlreadyObserving { + self.observers.append(WeakAnyAppStorePaymentObserver(observer)) + self.compactObservers() + } + } + } + + func removePaymentObserver(_ observer: AppStorePaymentObserver) { + lock.withCriticalBlock { + let index = self.observers.firstIndex(where: { $0.inner === observer }) + if let index = index { + self.observers.remove(at: index) + } + } + } + + private func compactObservers() { + lock.withCriticalBlock { + observers.removeAll(where: { $0.inner == nil }) + } + } + + private func enumerateObservers(_ body: (AppStorePaymentObserver) -> Void) { + lock.withCriticalBlock { + observers.forEach { (observer) in + body(observer) + } + } + } + + // MARK: - Account token and payment mapping + + private func associateAccountToken(_ token: String, and payment: SKPayment) { + lock.withCriticalBlock { + paymentToAccountToken[payment] = token + } + } + + private func deassociateAccountToken(_ payment: SKPayment) -> String? { + return lock.withCriticalBlock { + if let accountToken = paymentToAccountToken[payment] { + paymentToAccountToken.removeValue(forKey: payment) + return accountToken + } else { + return self.classDelegate? + .appStorePaymentManager(self, didRequestAccountTokenFor: payment) + } + } + } + + // MARK: - Products and payments + + func requestProducts(with productIdentifiers: Set<InAppPurchase>) + -> SKRequestPublisher<SKProductsRequestSubscription> + { + let productIdentifiers = productIdentifiers.productIdentifiersSet + + return SKProductsRequest(productIdentifiers: productIdentifiers).publisher + } + + func addPayment(_ payment: SKPayment, for accountToken: String) -> AppStorePaymentPublisher { + associateAccountToken(accountToken, and: payment) + + return AppStorePaymentPublisher(paymentManager: self, queue: queue, payment: payment) + } + + func restorePurchases(for accountToken: String) -> AnyPublisher<SendAppStoreReceiptResponse, AppStorePaymentManager.Error> { + return sendAppStoreReceipt(accountToken: accountToken, forceRefresh: true) + } + + // MARK: - Private methods + + private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool) -> + AnyPublisher<SendAppStoreReceiptResponse, AppStorePaymentManager.Error> + { + return AppStoreReceipt.fetch(forceRefresh: forceRefresh) + .mapError { SendAppStoreReceiptError.read($0) } + .flatMap { (receiptData) in + self.apiClient.sendAppStoreReceipt(accountToken: accountToken, receiptData: receiptData) + .mapError { SendAppStoreReceiptError.network($0) } + .flatMap({ (response) in + response.result.mapError { SendAppStoreReceiptError.server($0) }.publisher + }) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveOutput: { (response) in + os_log( + .info, + "AppStore Receipt was processed. Time added: %{public}.2f, New expiry: %{private}s", + response.timeAdded, "\(response.newExpiry)") + }) + .mapError { AppStorePaymentManager.Error.sendReceipt($0) } + .eraseToAnyPublisher() + } + + private func handleTransaction(_ transaction: SKPaymentTransaction) { + switch transaction.transactionState { + case .deferred: + os_log(.debug, "Deferred %{public}s", transaction.payment.productIdentifier) + + case .failed: + os_log(.debug, "Failed to purchase %{public}s: %{public}s", + transaction.payment.productIdentifier, + transaction.error?.localizedDescription ?? "No error") + + didFailPurchase(transaction: transaction) + + case .purchased: + os_log(.debug, "Purchased %{public}s", transaction.payment.productIdentifier) + + didFinishOrRestorePurchase(transaction: transaction) + + case .purchasing: + os_log(.debug, "Purchasing %{public}s", transaction.payment.productIdentifier) + + case .restored: + os_log(.debug, "Restored %{public}s", transaction.payment.productIdentifier) + + didFinishOrRestorePurchase(transaction: transaction) + + @unknown default: + os_log(.debug, "Unknown transactionState = %{public}d", + transaction.transactionState.rawValue) + } + } + + private func didFailPurchase(transaction: SKPaymentTransaction) { + queue.finishTransaction(transaction) + + enumerateObservers { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + didFailWithError: .storePayment(transaction.error!)) + } + + _ = deassociateAccountToken(transaction.payment) + } + + private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { + let accountToken = deassociateAccountToken(transaction.payment) + + sendReceiptSubscriber = Just(accountToken) + .setFailureType(to: AppStorePaymentManager.Error.self) + .replaceNil(with: .noAccountSet) + .flatMap({ (accountToken) in + self.sendAppStoreReceipt(accountToken: accountToken, forceRefresh: false) + .retry(1) + }) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] (completion) in + guard let self = self else { return } + + switch completion { + case .finished: + self.queue.finishTransaction(transaction) + + case .failure(let error): + os_log(.error, "Failed to upload the AppStore receipt: %{public}s", + error.localizedDescription) + + self.enumerateObservers { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + didFailWithError: error) + } + } + }, receiveValue: { [weak self] (response) in + guard let self = self else { return } + + self.enumerateObservers { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + didFinishWithResponse: response) + } + }) + } + +} + + +extension AppStorePaymentManager.Error: LocalizedError { + + var errorDescription: String? { + switch self { + case .noAccountSet: + return nil + case .storePayment: + return NSLocalizedString("AppStore payment", comment: "") + case .sendReceipt: + return NSLocalizedString("Communication error", comment: "") + } + } + + var failureReason: String? { + switch self { + case .storePayment(let storeError): + return storeError.localizedDescription + case .sendReceipt(.network(let urlError)): + return urlError.localizedDescription + case .sendReceipt(.server(let serverError)): + return serverError.errorDescription + case .sendReceipt(.read(.refresh(let storeError))): + return storeError.localizedDescription + default: + return NSLocalizedString("Internal error", comment: "") + } + } + + var recoverySuggestion: String? { + switch self { + case .noAccountSet: + return nil + case .storePayment: + return nil + case .sendReceipt: + return NSLocalizedString( + #"Please retry by using the "Restore purchases" button"#, comment: "") + } + } +} diff --git a/ios/MullvadVPN/AppStorePaymentPublisher.swift b/ios/MullvadVPN/AppStorePaymentPublisher.swift new file mode 100644 index 0000000000..31e6b9c878 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentPublisher.swift @@ -0,0 +1,87 @@ +// +// AppStorePaymentPublisher.swift +// MullvadVPN +// +// Created by pronebird on 23/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import StoreKit + +class AppStorePaymentPublisher: Publisher { + typealias Output = SendAppStoreReceiptResponse + typealias Failure = AppStorePaymentManager.Error + + private let paymentManager: AppStorePaymentManager + private let payment: SKPayment + private let queue: SKPaymentQueue + + init(paymentManager: AppStorePaymentManager, queue: SKPaymentQueue, payment: SKPayment) { + self.paymentManager = paymentManager + self.payment = payment + self.queue = queue + } + + func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + let subscription = AppStorePaymentSubscription( + paymentManager: paymentManager, + queue: queue, + payment: payment, + subscriber: subscriber) + + subscriber.receive(subscription: subscription) + } +} + +private class AppStorePaymentSubscription: Subscription, AppStorePaymentObserver { + + typealias Output = SendAppStoreReceiptResponse + typealias Failure = AppStorePaymentManager.Error + + private let paymentManager: AppStorePaymentManager + private let payment: SKPayment + private let queue: SKPaymentQueue + private let subscriber: AnySubscriber<Output, Failure> + + init<S>(paymentManager: AppStorePaymentManager, queue: SKPaymentQueue, payment: SKPayment, subscriber: S) + where S: Subscriber, S.Input == Output, S.Failure == Failure + { + self.paymentManager = paymentManager + self.payment = payment + self.queue = queue + self.subscriber = AnySubscriber(subscriber) + + paymentManager.addPaymentObserver(self) + } + + func request(_ demand: Subscribers.Demand) { + queue.add(payment) + } + + func cancel() { + paymentManager.removePaymentObserver(self) + } + + // MARK: - AppStorePaymentObserver + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFinishWithResponse response: SendAppStoreReceiptResponse) + { + if transaction.payment == payment { + _ = subscriber.receive(response) + subscriber.receive(completion: .finished) + } + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + didFailWithError error: AppStorePaymentManager.Error) { + if transaction.payment == payment { + subscriber.receive(completion: .failure(error)) + } + } + +} diff --git a/ios/MullvadVPN/SKPaymentQueuePublisher.swift b/ios/MullvadVPN/SKPaymentQueuePublisher.swift new file mode 100644 index 0000000000..04c1cf64ee --- /dev/null +++ b/ios/MullvadVPN/SKPaymentQueuePublisher.swift @@ -0,0 +1,71 @@ +// +// SKPaymentQueuePublisher.swift +// MullvadVPN +// +// Created by pronebird on 17/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import StoreKit + +/// A publisher that indefinitely emits the incoming transactions on the given `SKPaymentQueue`, +/// and never completes. +struct SKPaymentQueuePublisher: Publisher { + typealias Output = SKPaymentTransaction + typealias Failure = Never + + private let queue: SKPaymentQueue + + init(queue: SKPaymentQueue) { + self.queue = queue + } + + func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + let subscription = SKPaymentQueueSubscription( + queue: queue, subscriber: subscriber) + subscriber.receive(subscription: subscription) + } + +} + +extension SKPaymentQueue { + var publisher: SKPaymentQueuePublisher { + return .init(queue: self) + } +} + +/// A subscription implementation for the given `SKPaymentQueue` +private class SKPaymentQueueSubscription: NSObject, Subscription, SKPaymentTransactionObserver { + private let queue: SKPaymentQueue + private let subscriber: AnySubscriber<SKPaymentTransaction, Never> + + init<S>(queue: SKPaymentQueue, subscriber: S) + where S: Subscriber, S.Failure == Never, S.Input == SKPaymentTransaction + { + self.queue = queue + self.subscriber = AnySubscriber(subscriber) + + super.init() + + queue.add(self) + } + + func request(_ demand: Subscribers.Demand) { + // no-op + } + + func cancel() { + queue.remove(self) + } + + // MARK: - SKPaymentTransactionObserver + + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for transaction in transactions { + _ = subscriber.receive(transaction) + } + } + +} |
