From 911692b21f6b43b857ce433b7507f2dd2d861e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Em=C4=ABls?= Date: Mon, 23 Dec 2024 14:51:50 +0100 Subject: [PATCH] Add StoreKit2 button --- .../Classes/AccessbilityIdentifier.swift | 1 + .../Coordinators/ApplicationCoordinator.swift | 3 +- .../Account/AccountContentView.swift | 13 ++++ .../Account/AccountInteractor.swift | 12 +++- .../Account/AccountViewController.swift | 64 +++++++++++++++++++ .../Account/PaymentAlertPresenter.swift | 24 +++++++ .../Account/PaymentState.swift | 3 +- 7 files changed, 117 insertions(+), 3 deletions(-) diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 5aa1e0c86823..27310f7676a4 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -38,6 +38,7 @@ public enum AccessibilityIdentifier: Equatable { case loginTextFieldButton case logoutButton case purchaseButton + case storeKit2Button case redeemVoucherButton case restorePurchasesButton case secureConnectionButton diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index a4ff7e3cd0f5..d7e5dd557b5f 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -521,7 +521,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo let accountInteractor = AccountInteractor( storePaymentManager: storePaymentManager, tunnelManager: tunnelManager, - accountsProxy: accountsProxy + accountsProxy: accountsProxy, + apiProxy: apiProxy ) let coordinator = AccountCoordinator( diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index bb5a3ccf8ebc..36d3b6cd6e30 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -15,6 +15,18 @@ class AccountContentView: UIView { return button }() + let storeKit2Button: AppButton = { + let button = AppButton(style: .success) + button.setTitle(NSLocalizedString( + "BUY_SUBSCRIPTION_STOREKIT_2", + tableName: "Account", + value: "Make a purchase with StoreKit2", + comment: "" + ), for: .normal) + button.setAccessibilityIdentifier(.storeKit2Button) + return button + }() + let redeemVoucherButton: AppButton = { let button = AppButton(style: .success) button.setAccessibilityIdentifier(.redeemVoucherButton) @@ -85,6 +97,7 @@ class AccountContentView: UIView { var arrangedSubviews = [UIView]() #if DEBUG arrangedSubviews.append(redeemVoucherButton) + arrangedSubviews.append(storeKit2Button) #endif arrangedSubviews.append(contentsOf: [ purchaseButton, diff --git a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift index f5cc3a77a52b..b282c48cac3a 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift @@ -17,6 +17,7 @@ final class AccountInteractor { private let storePaymentManager: StorePaymentManager let tunnelManager: TunnelManager let accountsProxy: RESTAccountHandling + let apiProxy: APIQuerying var didReceivePaymentEvent: ((StorePaymentEvent) -> Void)? var didReceiveDeviceState: ((DeviceState) -> Void)? @@ -27,11 +28,13 @@ final class AccountInteractor { init( storePaymentManager: StorePaymentManager, tunnelManager: TunnelManager, - accountsProxy: RESTAccountHandling + accountsProxy: RESTAccountHandling, + apiProxy: APIQuerying ) { self.storePaymentManager = storePaymentManager self.tunnelManager = tunnelManager self.accountsProxy = accountsProxy + self.apiProxy = apiProxy let tunnelObserver = TunnelBlockObserver(didUpdateDeviceState: { [weak self] _, deviceState, _ in @@ -61,6 +64,13 @@ final class AccountInteractor { storePaymentManager.addPayment(payment, for: accountNumber) } + func sendStoreKitReceipt(_ transaction: VerificationResult, for accountNumber: String) async throws { + try await apiProxy.createApplePayment( + accountNumber: accountNumber, + receiptString: transaction.jwsRepresentation.data(using: .utf8)! + ).execute() + } + func restorePurchases( for accountNumber: String, completionHandler: @escaping (Result) -> Void diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index f39a228e6681..60125ca981a1 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -144,6 +144,8 @@ class AccountViewController: UIViewController { contentView.logoutButton.addTarget(self, action: #selector(logOut), for: .touchUpInside) contentView.deleteButton.addTarget(self, action: #selector(deleteAccount), for: .touchUpInside) + + contentView.storeKit2Button.addTarget(self, action: #selector(handleStoreKit2Purchase), for: .touchUpInside) } private func requestStoreProducts() { @@ -202,6 +204,7 @@ class AccountViewController: UIViewController { contentView.logoutButton.isEnabled = isInteractionEnabled contentView.redeemVoucherButton.isEnabled = isInteractionEnabled contentView.deleteButton.isEnabled = isInteractionEnabled + contentView.storeKit2Button.isEnabled = isInteractionEnabled navigationItem.rightBarButtonItem?.isEnabled = isInteractionEnabled view.isUserInteractionEnabled = isInteractionEnabled @@ -293,4 +296,65 @@ class AccountViewController: UIViewController { setPaymentState(.none, animated: true) } } + + @objc private func handleStoreKit2Purchase() { + guard case let .received(oldProduct) = productState, + let accountData = interactor.deviceState.accountData + else { + return + } + + setPaymentState(.makingStoreKit2Purchase, animated: true) + + Task { + do { + let product = try await Product.products(for: [oldProduct.productIdentifier]).first! + let result = try await product.purchase() + + switch result { + case let .success(verification): + let transaction = try checkVerified(verification) + await sendReceiptToAPI(accountNumber: accountData.identifier, receipt: verification) + await transaction.finish() + + case .userCancelled: + print("User cancelled the purchase") + case .pending: + print("Purchase is pending") + @unknown default: + print("Unknown purchase result") + } + } catch { + print("Error: \(error)") + errorPresenter.showAlertForStoreKitError(error, context: .purchase) + } + + setPaymentState(.none, animated: true) + } + } + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: + throw StoreKit2Error.verificationFailed + case let .verified(safe): + return safe + } + } + + private func sendReceiptToAPI(accountNumber: String, receipt: VerificationResult) async { + do { + // Accessing unsafe payload data because n + let receiptData = receipt.unsafePayloadValue + try await interactor.sendStoreKitReceipt(receipt, for: accountNumber) + print("Receipt sent successfully") + } catch { + print("Error sending receipt: \(error)") + errorPresenter.showAlertForStoreKitError(error, context: .purchase) + } + } +} + +private enum StoreKit2Error: Error { + case verificationFailed } diff --git a/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift b/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift index 0192f3fdd306..82820ba3fb2b 100644 --- a/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift +++ b/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift @@ -36,6 +36,30 @@ struct PaymentAlertPresenter { presenter.showAlert(presentation: presentation, animated: true) } + func showAlertForStoreKitError( + _ error: any Error, + context: REST.CreateApplePaymentResponse.Context, + completion: (() -> Void)? = nil + ) { + let presentation = AlertPresentation( + id: "payment-error-alert", + title: context.errorTitle, + message: "\(error)", + buttons: [ + AlertAction( + title: okButtonTextForKey("PAYMENT_ERROR_ALERT_OK_ACTION"), + style: .default, + handler: { + completion?() + } + ), + ] + ) + + let presenter = AlertPresenter(context: alertContext) + presenter.showAlert(presentation: presentation, animated: true) + } + func showAlertForResponse( _ response: REST.CreateApplePaymentResponse, context: REST.CreateApplePaymentResponse.Context, diff --git a/ios/MullvadVPN/View controllers/Account/PaymentState.swift b/ios/MullvadVPN/View controllers/Account/PaymentState.swift index b50263f8315a..fa397d94d55e 100644 --- a/ios/MullvadVPN/View controllers/Account/PaymentState.swift +++ b/ios/MullvadVPN/View controllers/Account/PaymentState.swift @@ -12,13 +12,14 @@ import StoreKit enum PaymentState: Equatable { case none case makingPayment(SKPayment) + case makingStoreKit2Purchase case restoringPurchases var allowsViewInteraction: Bool { switch self { case .none: return true - case .restoringPurchases, .makingPayment: + case .restoringPurchases, .makingPayment, .makingStoreKit2Purchase: return false } }