summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorSteffen Ernst <steffen.ernst@mullvad.net>2025-01-29 11:38:13 +0100
committerSteffen Ernst <steffen.ernst@mullvad.net>2025-02-17 09:40:33 +0100
commitaec878740e70fc1d0184a84ef2a7e80a84f2152c (patch)
treea9225215ca27ea9fe2657bf35d5f84a1ee1334e6 /ios
parent083d06db318ebdcb670d5b1ad5662a4c9df417c2 (diff)
downloadmullvadvpn-aec878740e70fc1d0184a84ef2a7e80a84f2152c.tar.xz
mullvadvpn-aec878740e70fc1d0184a84ef2a7e80a84f2152c.zip
Use overlaying spinner for in app purchase
Move purchase logic into single reused view controller
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj24
-rw-r--r--ios/MullvadVPN/Coordinators/AccountCoordinator.swift48
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift5
-rw-r--r--ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift81
-rw-r--r--ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift67
-rw-r--r--ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift42
-rw-r--r--ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift54
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountInteractor.swift34
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountViewController.swift106
-rw-r--r--ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift1
-rw-r--r--ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift37
-rw-r--r--ios/MullvadVPN/View controllers/InAppPurchase/InAppPurchaseViewController.swift163
-rw-r--r--ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift145
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsInteractorFactory.swift3
14 files changed, 281 insertions, 529 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index f79fb41659..e13e1af95a 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -599,7 +599,6 @@
7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */; };
7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */; };
7A9CCCBA2A96302800DD6A34 /* CreateAccountVoucherCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */; };
- 7A9CCCBB2A96302800DD6A34 /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */; };
7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */; };
7A9CCCBD2A96302800DD6A34 /* LoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */; };
7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCAC2A96302800DD6A34 /* AccountDeletionCoordinator.swift */; };
@@ -1065,8 +1064,10 @@
F0F56B092C0E058A009D676B /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
- F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4002D3FF22E002FF3BB /* View+Modifier.swift */; };
+ F910A4312D4A1B41002FF3BB /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */; };
+ F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */; };
F95C1C252D3E5E8E00EBE769 /* UIAlertController+InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */; };
+ F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4002D3FF22E002FF3BB /* View+Modifier.swift */; };
F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; };
F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */; };
/* End PBXBuildFile section */
@@ -2000,7 +2001,6 @@
7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountVoucherCoordinator.swift; sourceTree = "<group>"; };
- 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = "<group>"; };
7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangeLogCoordinator.swift; sourceTree = "<group>"; };
7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginCoordinator.swift; sourceTree = "<group>"; };
7A9CCCAC2A96302800DD6A34 /* AccountDeletionCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountDeletionCoordinator.swift; sourceTree = "<group>"; };
@@ -2335,6 +2335,8 @@
F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; };
F910A4002D3FF22E002FF3BB /* View+Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Modifier.swift"; sourceTree = "<group>"; };
F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+InAppPurchase.swift"; sourceTree = "<group>"; };
+ F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = "<group>"; };
+ F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseViewController.swift; sourceTree = "<group>"; };
F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -2947,6 +2949,7 @@
583FE01629C196E8006E85F9 /* View controllers */ = {
isa = PBXGroup;
children = (
+ F910A4322D4A1BA1002FF3BB /* InAppPurchase */,
583FE02029C1A0B1006E85F9 /* Account */,
F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */,
7A2960F72A964A3500389B82 /* Alert */,
@@ -3183,7 +3186,6 @@
E158B35F285381C60002F069 /* String+AccountFormatting.swift */,
7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */,
5807E2BF2432038B00F5FF30 /* String+Helpers.swift */,
- F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */,
58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */,
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */,
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */,
@@ -3644,6 +3646,7 @@
58CAF9F22983D32200BE19F7 /* Coordinators */ = {
isa = PBXGroup;
children = (
+ F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */,
7A6389D12B7E3BD6008E77E1 /* CustomLists */,
58EFC76F2AFB3FA800E9F4CB /* Settings */,
7A9CCCAF2A96302800DD6A34 /* AccountCoordinator.swift */,
@@ -3653,7 +3656,6 @@
7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */,
7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */,
7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */,
- 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */,
7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */,
7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */,
7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */,
@@ -4604,6 +4606,14 @@
path = ChangeLog;
sourceTree = "<group>";
};
+ F910A4322D4A1BA1002FF3BB /* InAppPurchase */ = {
+ isa = PBXGroup;
+ children = (
+ F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */,
+ );
+ path = InAppPurchase;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -6105,7 +6115,6 @@
7A58699F2B50057100640D27 /* AccessMethodKind.swift in Sources */,
7A33538F2AA9FF1600F0A71C /* SimulatorTunnelProviderManager.swift in Sources */,
7A1A26432A2612AE00B978AA /* PaymentAlertPresenter.swift in Sources */,
- 7A9CCCBB2A96302800DD6A34 /* InAppPurchaseCoordinator.swift in Sources */,
58CCA01822426713004F3011 /* AccountViewController.swift in Sources */,
5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */,
F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */,
@@ -6192,6 +6201,7 @@
5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */,
588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */,
F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */,
+ F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */,
58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */,
7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */,
58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */,
@@ -6213,7 +6223,6 @@
A9E034642ABB302000E59A5A /* UIEdgeInsets+Extensions.swift in Sources */,
58CEB2E92AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift in Sources */,
58DFF7D02B02560400F864E0 /* NSAttributedString+Extensions.swift in Sources */,
- F95C1C252D3E5E8E00EBE769 /* UIAlertController+InAppPurchase.swift in Sources */,
58E0A98827C8F46300FE6BDD /* Tunnel.swift in Sources */,
7A12D0762B062D5C00E9602D /* URLSessionProtocol.swift in Sources */,
58ACF64F26567A7100ACE4B7 /* CustomSwitchContainer.swift in Sources */,
@@ -6294,6 +6303,7 @@
586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */,
581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */,
A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */,
+ F910A4312D4A1B41002FF3BB /* InAppPurchaseCoordinator.swift in Sources */,
58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */,
F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift
index ed72e08593..c898950dd7 100644
--- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift
@@ -23,6 +23,7 @@ enum AddedMoreCreditOption: Equatable, Sendable {
final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked Sendable {
private let interactor: AccountInteractor
+ private let storePaymentManager: StorePaymentManager
private var accountController: AccountViewController?
let navigationController: UINavigationController
@@ -34,10 +35,12 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked
init(
navigationController: UINavigationController,
- interactor: AccountInteractor
+ interactor: AccountInteractor,
+ storePaymentManager: StorePaymentManager
) {
self.navigationController = navigationController
self.interactor = interactor
+ self.storePaymentManager = storePaymentManager
}
func start(animated: Bool) {
@@ -68,24 +71,41 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked
navigateToDeleteAccount()
case .restorePurchasesInfo:
showRestorePurchasesInfo()
- case let .showPurchaseOptions(details):
- showPurchaseOptions(
- products: details.products,
- accountNumber: details.accountNumber,
- didRequestPurchase: details.didRequestPurchase
- )
case .showFailedToLoadProducts:
showFailToFetchProducts()
+ case .showRestorePurchases:
+ showRestorePurchases()
+ case .showPurchaseOptions:
+ showPurchaseOptions()
}
}
- func showPurchaseOptions(
- products: [SKProduct],
- accountNumber: String,
- didRequestPurchase: @escaping (_ product: SKProduct) -> Void
- ) {
- let alert = UIAlertController.showInAppPurchaseAlert(products: products, didRequestPurchase: didRequestPurchase)
- presentationContext.present(alert, animated: true)
+ private func showPurchaseOptions() {
+ guard let accountNumber = interactor.deviceState.accountData?.number else { return }
+ let coordinator = InAppPurchaseCoordinator(
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ paymentAction: .purchase
+ )
+ coordinator.didFinish = { coordinator in
+ coordinator.dismiss(animated: true)
+ }
+ coordinator.start()
+ presentChild(coordinator, animated: true)
+ }
+
+ private func showRestorePurchases() {
+ guard let accountNumber = interactor.deviceState.accountData?.number else { return }
+ let coordinator = InAppPurchaseCoordinator(
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ paymentAction: .restorePurchase
+ )
+ coordinator.didFinish = { coordinator in
+ coordinator.dismiss(animated: true)
+ }
+ coordinator.start()
+ presentChild(coordinator, animated: true)
}
private func navigateToRedeemVoucher() {
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index dbea333624..f8b6e7a555 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -524,7 +524,6 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
private func presentAccount(animated: Bool, completion: @escaping (Coordinator) -> Void) {
let accountInteractor = AccountInteractor(
- storePaymentManager: storePaymentManager,
tunnelManager: tunnelManager,
accountsProxy: accountsProxy,
apiProxy: apiProxy
@@ -532,7 +531,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
let coordinator = AccountCoordinator(
navigationController: CustomNavigationController(),
- interactor: accountInteractor
+ interactor: accountInteractor,
+ storePaymentManager: storePaymentManager
)
coordinator.didFinish = { [weak self] _, reason in
@@ -557,7 +557,6 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
completion: @escaping @Sendable (Coordinator) -> Void
) {
let interactorFactory = SettingsInteractorFactory(
- storePaymentManager: storePaymentManager,
tunnelManager: tunnelManager,
apiProxy: apiProxy,
relayCacheTracker: relayCacheTracker,
diff --git a/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift b/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift
index 97a72a8ce6..ac8c6413f6 100644
--- a/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift
@@ -6,73 +6,44 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
-import Foundation
import Routing
import StoreKit
import UIKit
-class InAppPurchaseCoordinator: Coordinator, Presenting, Presentable {
- private let navigationController: RootContainerViewController
- private let interactor: InAppPurchaseInteractor
+enum PaymentAction {
+ case purchase
+ case restorePurchase
+}
+
+final class InAppPurchaseCoordinator: Coordinator, Presentable, Presenting {
+ private var sheetController: InAppPurchaseViewController?
+ private let storePaymentManager: StorePaymentManager
+ private let accountNumber: String
+ private let paymentAction: PaymentAction
var didFinish: ((InAppPurchaseCoordinator) -> Void)?
- var didCancel: ((InAppPurchaseCoordinator) -> Void)?
var presentedViewController: UIViewController {
- navigationController
+ return sheetController!
}
- init(navigationController: RootContainerViewController, interactor: InAppPurchaseInteractor) {
- self.navigationController = navigationController
- self.interactor = interactor
+ init(storePaymentManager: StorePaymentManager, accountNumber: String, paymentAction: PaymentAction) {
+ self.storePaymentManager = storePaymentManager
+ self.accountNumber = accountNumber
+ self.paymentAction = paymentAction
}
- func start(accountNumber: String, product: SKProduct) {
- interactor.purchase(accountNumber: accountNumber, product: product)
- interactor.didFinishPayment = { [weak self] _, paymentEvent in
- guard let self else { return }
- switch paymentEvent {
- case let .finished(value):
- let coordinator = AddCreditSucceededCoordinator(
- purchaseType: .inAppPurchase,
- timeAdded: Int(value.serverResponse.timeAdded),
- navigationController: navigationController
- )
-
- coordinator.didFinish = { [weak self] coordinator in
- coordinator.removeFromParent()
- guard let self else { return }
- didFinish?(self)
- }
-
- addChild(coordinator)
- coordinator.start()
-
- case let .failure(failure):
- let presentation = AlertPresentation(
- id: "in-app-purchase-error-alert",
- icon: .alert,
- message: failure.error.localizedDescription,
- buttons: [
- AlertAction(
- title: NSLocalizedString(
- "IN_APP_PURCHASE_ERROR_DIALOG_OK_ACTION",
- tableName: "Welcome",
- value: "Got it!",
- comment: ""
- ),
- style: .default,
- handler: { [weak self] in
- guard let self = self else { return }
- self.didCancel?(self)
- }
- ),
- ]
- )
+ func dismiss() {
+ didFinish?(self)
+ }
- let presenter = AlertPresenter(context: self)
- presenter.showAlert(presentation: presentation, animated: true)
- }
- }
+ func start() {
+ sheetController = InAppPurchaseViewController(
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ errorPresenter: PaymentAlertPresenter(alertContext: self),
+ paymentAction: paymentAction
+ )
+ sheetController?.didFinish = dismiss
}
}
diff --git a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift
index 18e042d187..8930cc2242 100644
--- a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift
@@ -74,54 +74,29 @@ class OutOfTimeCoordinator: Coordinator, Presenting, @preconcurrency OutOfTimeVi
// MARK: - OutOfTimeViewControllerDelegate
- func outOfTimeViewControllerDidBeginPayment(_ controller: OutOfTimeViewController) {
- isMakingPayment = true
- }
-
- func outOfTimeViewControllerDidEndPayment(_ controller: OutOfTimeViewController) {
- isMakingPayment = false
-
- didFinishPayment?(self)
- }
-
- func outOfTimeViewControllerDidRequestShowPurchaseOptions(
- _ controller: OutOfTimeViewController,
- products: [SKProduct],
- didRequestPurchase: @escaping (SKProduct) -> Void
- ) {
- let alert = UIAlertController.showInAppPurchaseAlert(products: products, didRequestPurchase: didRequestPurchase)
- presentationContext.present(alert, animated: true)
- }
-
- func outOfTimeViewControllerDidFailToFetchProducts(_ controller: OutOfTimeViewController) {
- let message = NSLocalizedString(
- "WELCOME_FAILED_TO_FETCH_PRODUCTS_DIALOG",
- tableName: "Welcome",
- value:
- """
- Failed to connect to App store, please try again later.
- """,
- comment: ""
+ func didRequestShowPurchaseOptions(accountNumber: String) {
+ let coordinator = InAppPurchaseCoordinator(
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ paymentAction: .purchase
)
+ coordinator.didFinish = { coordinator in
+ coordinator.dismiss(animated: true)
+ }
+ coordinator.start()
+ presentChild(coordinator, animated: true)
+ }
- let presentation = AlertPresentation(
- id: "welcome-failed-to-fetch-products-alert",
- icon: .info,
- message: message,
- buttons: [
- AlertAction(
- title: NSLocalizedString(
- "WELCOME_FAILED_TO_FETCH_PRODUCTS_OK_ACTION",
- tableName: "Welcome",
- value: "Got it!",
- comment: ""
- ),
- style: .default
- ),
- ]
+ func didRequestShowRestorePurchase(accountNumber: String) {
+ let coordinator = InAppPurchaseCoordinator(
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ paymentAction: .restorePurchase
)
-
- let presenter = AlertPresenter(context: self)
- presenter.showAlert(presentation: presentation, animated: true)
+ coordinator.didFinish = { coordinator in
+ coordinator.dismiss(animated: true)
+ }
+ coordinator.start()
+ presentChild(coordinator, animated: true)
}
}
diff --git a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift
index d48825d07c..fec5a6f1d7 100644
--- a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift
@@ -151,46 +151,18 @@ extension WelcomeCoordinator: @preconcurrency WelcomeViewControllerDelegate {
}
func didRequestToViewPurchaseOptions(
- controller: WelcomeViewController,
- products: [SKProduct],
accountNumber: String
) {
- let alert = UIAlertController.showInAppPurchaseAlert(products: products, didRequestPurchase: { product in
- self.didRequestToPurchaseCredit(
- controller: controller,
- accountNumber: accountNumber,
- product: product
- )
- })
-
- presentationContext.present(alert, animated: true)
- }
-
- func didRequestToPurchaseCredit(controller: WelcomeViewController, accountNumber: String, product: SKProduct) {
- navigationController.enableHeaderBarButtons(false)
-
let coordinator = InAppPurchaseCoordinator(
- navigationController: navigationController,
- interactor: inAppPurchaseInteractor
+ storePaymentManager: storePaymentManager,
+ accountNumber: accountNumber,
+ paymentAction: .purchase
)
-
- inAppPurchaseInteractor.viewControllerDelegate = viewController
-
- coordinator.didFinish = { [weak self] coordinator in
- guard let self else { return }
- navigationController.enableHeaderBarButtons(true)
- coordinator.removeFromParent()
- didFinish?()
- }
-
- coordinator.didCancel = { [weak self] coordinator in
- self?.navigationController.enableHeaderBarButtons(true)
- coordinator.removeFromParent()
+ coordinator.didFinish = { coordinator in
+ coordinator.dismiss(animated: true)
}
-
- addChild(coordinator)
-
- coordinator.start(accountNumber: accountNumber, product: product)
+ coordinator.start()
+ presentChild(coordinator, animated: true)
}
func didRequestToRedeemVoucher(controller: WelcomeViewController) {
diff --git a/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift b/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift
deleted file mode 100644
index c7096d91f0..0000000000
--- a/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// UIAlertController+InAppPurchase.swift
-// MullvadVPN
-//
-// Created by Steffen Ernst on 2025-01-20.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import StoreKit
-import UIKit
-
-extension UIAlertController {
- public static func showInAppPurchaseAlert(
- products: [SKProduct],
- didRequestPurchase: @escaping (SKProduct) -> Void
- ) -> UIAlertController {
- let localizedString = NSLocalizedString(
- "ADD_TIME_BUTTON",
- tableName: "Welcome",
- value: "Add Time",
- comment: ""
- )
- let actionSheet = UIAlertController(
- title: localizedString,
- message: nil,
- preferredStyle: UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet
- )
- actionSheet.overrideUserInterfaceStyle = .dark
- actionSheet.view.tintColor = .AlertController.tintColor
- products.sortedByPrice().forEach { product in
- guard let localizedTitle = product.customLocalizedTitle else {
- return
- }
- let action = UIAlertAction(title: localizedTitle, style: .default, handler: { _ in
- actionSheet.dismiss(animated: true, completion: {
- didRequestPurchase(product)
- })
- })
- action
- .accessibilityIdentifier =
- "\(AccessibilityIdentifier.purchaseButton.asString)_\(product.productIdentifier)"
- actionSheet.addAction(action)
- }
- let cancelAction = UIAlertAction(title: NSLocalizedString(
- "PRODUCT_LIST_CANCEL_BUTTON",
- tableName: "Welcome",
- value: "Cancel",
- comment: ""
- ), style: .cancel)
- cancelAction.accessibilityIdentifier = AccessibilityIdentifier.cancelPurchaseListButton.asString
- actionSheet.addAction(cancelAction)
- return actionSheet
- }
-}
diff --git a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
index 85db6f7935..a9e6aec2d1 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
@@ -14,24 +14,20 @@ import Operations
import StoreKit
final class AccountInteractor: Sendable {
- private let storePaymentManager: StorePaymentManager
let tunnelManager: TunnelManager
let accountsProxy: RESTAccountHandling
let apiProxy: APIQuerying
- nonisolated(unsafe) var didReceivePaymentEvent: (@Sendable (StorePaymentEvent) -> Void)?
nonisolated(unsafe) var didReceiveDeviceState: (@Sendable (DeviceState) -> Void)?
nonisolated(unsafe) private var tunnelObserver: TunnelObserver?
nonisolated(unsafe) private var paymentObserver: StorePaymentObserver?
init(
- storePaymentManager: StorePaymentManager,
tunnelManager: TunnelManager,
accountsProxy: RESTAccountHandling,
apiProxy: APIQuerying
) {
- self.storePaymentManager = storePaymentManager
self.tunnelManager = tunnelManager
self.accountsProxy = accountsProxy
self.apiProxy = apiProxy
@@ -41,15 +37,9 @@ final class AccountInteractor: Sendable {
self?.didReceiveDeviceState?(deviceState)
})
- let paymentObserver = StorePaymentBlockObserver { [weak self] _, event in
- self?.didReceivePaymentEvent?(event)
- }
-
tunnelManager.addObserver(tunnelObserver)
- storePaymentManager.addPaymentObserver(paymentObserver)
self.tunnelObserver = tunnelObserver
- self.paymentObserver = paymentObserver
}
var deviceState: DeviceState {
@@ -60,34 +50,10 @@ final class AccountInteractor: Sendable {
await tunnelManager.unsetAccount()
}
- func addPayment(_ payment: SKPayment, for accountNumber: String) {
- storePaymentManager.addPayment(payment, for: accountNumber)
- }
-
func sendStoreKitReceipt(_ transaction: VerificationResult<Transaction>, 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 @Sendable (Result<REST.CreateApplePaymentResponse, Error>) -> Void
- ) -> Cancellable {
- storePaymentManager.restorePurchases(
- for: accountNumber,
- completionHandler: completionHandler
- )
- }
-
- func requestProducts(
- with productIdentifiers: Set<StoreSubscription>,
- completionHandler: @escaping @Sendable (Result<SKProductsResponse, Error>) -> Void
- ) -> Cancellable {
- storePaymentManager.requestProducts(
- with: productIdentifiers,
- completionHandler: completionHandler
- )
- }
}
diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
index 87a450339b..7ad04a083c 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
@@ -14,12 +14,6 @@ import Operations
import StoreKit
import UIKit
-struct PurchaseOptionDetails: Sendable {
- let products: [SKProduct]
- let accountNumber: String
- let didRequestPurchase: @Sendable (SKProduct) -> Void
-}
-
enum AccountViewControllerAction: Sendable {
case deviceInfo
case finish
@@ -27,8 +21,9 @@ enum AccountViewControllerAction: Sendable {
case navigateToVoucher
case navigateToDeleteAccount
case restorePurchasesInfo
- case showPurchaseOptions(PurchaseOptionDetails)
+ case showPurchaseOptions
case showFailedToLoadProducts
+ case showRestorePurchases
}
class AccountViewController: UIViewController, @unchecked Sendable {
@@ -104,11 +99,6 @@ class AccountViewController: UIViewController, @unchecked Sendable {
}
}
- interactor.didReceivePaymentEvent = { [weak self] event in
- Task { @MainActor in
- self?.didReceivePaymentEvent(event)
- }
- }
configUI()
addActions()
updateView(from: interactor.deviceState)
@@ -151,17 +141,6 @@ class AccountViewController: UIViewController, @unchecked Sendable {
contentView.storeKit2Button.addTarget(self, action: #selector(handleStoreKit2Purchase), for: .touchUpInside)
}
- private func doPurchase(product: SKProduct) {
- guard let accountData = interactor.deviceState.accountData else {
- return
- }
-
- let payment = SKPayment(product: product)
- interactor.addPayment(payment, for: accountData.number)
-
- setPaymentState(.makingPayment(payment), animated: true)
- }
-
@MainActor
private func setPaymentState(_ newState: PaymentState, animated: Bool) {
paymentState = newState
@@ -188,15 +167,6 @@ class AccountViewController: UIViewController, @unchecked Sendable {
private func applyViewState(animated: Bool) {
let isInteractionEnabled = paymentState.allowsViewInteraction
let purchaseButton = contentView.purchaseButton
- let activityIndicator = contentView.accountExpiryRowView.activityIndicator
-
- if isFetchingProducts || paymentState != .none {
- activityIndicator.startAnimating()
- } else {
- activityIndicator.stopAnimating()
- }
-
- contentView.purchaseButton.isLoading = isFetchingProducts
purchaseButton.isEnabled = !isFetchingProducts && isInteractionEnabled
contentView.accountDeviceRow.setButtons(enabled: isInteractionEnabled)
@@ -214,27 +184,6 @@ class AccountViewController: UIViewController, @unchecked Sendable {
navigationItem.setHidesBackButton(!isInteractionEnabled, animated: animated)
}
- private func didReceivePaymentEvent(_ event: StorePaymentEvent) {
- guard case let .makingPayment(payment) = paymentState,
- payment == event.payment else { return }
-
- switch event {
- case let .finished(completion):
- errorPresenter.showAlertForResponse(completion.serverResponse, context: .purchase)
-
- case let .failure(paymentFailure):
- switch paymentFailure.error {
- case .storePayment(SKError.paymentCancelled):
- break
-
- default:
- errorPresenter.showAlertForError(paymentFailure.error, context: .purchase)
- }
- }
-
- setPaymentState(.none, animated: true)
- }
-
private func copyAccountToken() {
guard let accountData = interactor.deviceState.accountData else {
return
@@ -262,58 +211,11 @@ class AccountViewController: UIViewController, @unchecked Sendable {
}
@objc private func requestStoreProducts() {
- guard let accountData = interactor.deviceState.accountData else {
- return
- }
- let productIdentifiers = Set(StoreSubscription.allCases)
- setIsFetchingProducts(true)
- _ = interactor.requestProducts(with: productIdentifiers) { [weak self] result in
- guard let self else { return }
- Task { @MainActor in
- switch result {
- case let .success(success):
- let products = success.products
- if !products.isEmpty {
- actionHandler?(.showPurchaseOptions(PurchaseOptionDetails(
- products: products,
- accountNumber: accountData.number,
- didRequestPurchase: { product in Task { @MainActor in self.doPurchase(product: product) }}
- )))
- } else {
- actionHandler?(.showFailedToLoadProducts)
- }
- case .failure:
- actionHandler?(.showFailedToLoadProducts)
- }
- setIsFetchingProducts(false)
- }
- }
+ actionHandler?(.showPurchaseOptions)
}
@objc private func restorePurchases() {
- guard let accountData = interactor.deviceState.accountData else {
- return
- }
-
- setPaymentState(.restoringPurchases, animated: true)
- _ = interactor.restorePurchases(for: accountData.number) { [weak self] completion in
- guard let self else { return }
-
- Task { @MainActor in
- switch completion {
- case let .success(response):
- errorPresenter.showAlertForResponse(response, context: .restoration)
-
- case let .failure(error as StorePaymentManagerError):
- errorPresenter.showAlertForError(error, context: .restoration)
-
- default:
- break
- }
-
- setPaymentState(.none, animated: true)
- }
- }
+ actionHandler?(.showRestorePurchases)
}
@objc private func handleStoreKit2Purchase() {
diff --git a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift
index 13dab539d4..ba05410b67 100644
--- a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift
+++ b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift
@@ -174,7 +174,6 @@ final class WelcomeContentView: UIView, Sendable {
var isPurchasing = false {
didSet {
let alpha = isPurchasing ? 0.7 : 1.0
- purchaseButton.isLoading = isPurchasing
purchaseButton.isEnabled = !isPurchasing
purchaseButton.alpha = alpha
redeemVoucherButton.isEnabled = !isPurchasing
diff --git a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift
index 7d4ca9f6b5..a94c2ab8c0 100644
--- a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift
+++ b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift
@@ -13,11 +13,8 @@ protocol WelcomeViewControllerDelegate: AnyObject {
func didRequestToRedeemVoucher(controller: WelcomeViewController)
func didRequestToShowInfo(controller: WelcomeViewController)
func didRequestToViewPurchaseOptions(
- controller: WelcomeViewController,
- products: [SKProduct],
accountNumber: String
)
- func didRequestToShowFailToFetchProducts(controller: WelcomeViewController)
}
class WelcomeViewController: UIViewController, RootContainment {
@@ -89,42 +86,10 @@ extension WelcomeViewController: @preconcurrency WelcomeContentViewDelegate {
}
func didTapPurchaseButton(welcomeContentView: WelcomeContentView, button: AppButton) {
- let productIdentifiers = Set(StoreSubscription.allCases)
- contentView.isFetchingProducts = true
- _ = interactor.requestProducts(with: productIdentifiers) { [weak self] result in
- guard let self else { return }
- Task { @MainActor in
- switch result {
- case let .success(success):
- let products = success.products
- if !products.isEmpty {
- delegate?.didRequestToViewPurchaseOptions(
- controller: self,
- products: products,
- accountNumber: interactor.accountNumber
- )
- } else {
- delegate?.didRequestToShowFailToFetchProducts(controller: self)
- }
- case .failure:
- delegate?.didRequestToShowFailToFetchProducts(controller: self)
- }
- contentView.isFetchingProducts = false
- }
- }
+ delegate?.didRequestToViewPurchaseOptions(accountNumber: interactor.accountNumber)
}
func didTapRedeemVoucherButton(welcomeContentView: WelcomeContentView, button: AppButton) {
delegate?.didRequestToRedeemVoucher(controller: self)
}
}
-
-extension WelcomeViewController: @preconcurrency InAppPurchaseViewControllerDelegate {
- func didBeginPayment() {
- contentView.isPurchasing = true
- }
-
- func didEndPayment() {
- contentView.isPurchasing = false
- }
-}
diff --git a/ios/MullvadVPN/View controllers/InAppPurchase/InAppPurchaseViewController.swift b/ios/MullvadVPN/View controllers/InAppPurchase/InAppPurchaseViewController.swift
new file mode 100644
index 0000000000..ec52a4111f
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/InAppPurchase/InAppPurchaseViewController.swift
@@ -0,0 +1,163 @@
+//
+// SheetViewController.swift
+// MullvadVPN
+//
+// Created by Steffen Ernst on 2025-01-29.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import StoreKit
+import UIKit
+
+class InAppPurchaseViewController: UIViewController, @preconcurrency StorePaymentObserver {
+ private let storePaymentManager: StorePaymentManager
+ private let accountNumber: String
+ private let paymentAction: PaymentAction
+ private let errorPresenter: PaymentAlertPresenter
+
+ private let spinnerView = {
+ SpinnerActivityIndicatorView(style: .large)
+ }()
+
+ var didFinish: (() -> Void)?
+
+ init(
+ storePaymentManager: StorePaymentManager,
+ accountNumber: String,
+ errorPresenter: PaymentAlertPresenter,
+ paymentAction: PaymentAction
+ ) {
+ self.storePaymentManager = storePaymentManager
+ self.accountNumber = accountNumber
+ self.errorPresenter = errorPresenter
+ self.paymentAction = paymentAction
+ super.init(nibName: nil, bundle: nil)
+ self.storePaymentManager.addPaymentObserver(self)
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ view.addConstrainedSubviews([spinnerView]) {
+ spinnerView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
+ spinnerView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ }
+ view.backgroundColor = .black.withAlphaComponent(0.5)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ spinnerView.startAnimating()
+ let productIdentifiers = Set(StoreSubscription.allCases)
+ switch paymentAction {
+ case .purchase:
+ _ = storePaymentManager.requestProducts(
+ with: productIdentifiers
+ ) { result in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.spinnerView.stopAnimating()
+ switch result {
+ case let .success(success):
+ let products = success.products
+ guard !products.isEmpty else {
+ return
+ }
+ self.showPurchaseOptions(for: products)
+ case let .failure(failure as StorePaymentManagerError):
+ self.errorPresenter.showAlertForError(failure, context: .purchase) {
+ self.didFinish?()
+ }
+ default:
+ self.didFinish?()
+ }
+ }
+ }
+ case .restorePurchase:
+ _ = storePaymentManager.restorePurchases(for: accountNumber) { result in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.spinnerView.stopAnimating()
+ switch result {
+ case let .success(success):
+ self.errorPresenter.showAlertForResponse(success, context: .restoration) {
+ self.didFinish?()
+ }
+ case let .failure(failure as StorePaymentManagerError):
+ self.errorPresenter.showAlertForError(failure, context: .restoration) {
+ self.didFinish?()
+ }
+ default:
+ self.didFinish?()
+ }
+ }
+ }
+ }
+ }
+
+ func purchase(product: SKProduct) {
+ let payment = SKPayment(product: product)
+ storePaymentManager.addPayment(payment, for: accountNumber)
+ }
+
+ func showPurchaseOptions(for products: [SKProduct]) {
+ let localizedString = NSLocalizedString(
+ "ADD_TIME_BUTTON",
+ tableName: "Welcome",
+ value: "Add Time",
+ comment: ""
+ )
+ let sheetController = UIAlertController(
+ title: localizedString,
+ message: nil,
+ preferredStyle: UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet
+ )
+ sheetController.overrideUserInterfaceStyle = .dark
+ sheetController.view.tintColor = .AlertController.tintColor
+ products.sortedByPrice().forEach { product in
+ guard let title = product.customLocalizedTitle else { return }
+ let action = UIAlertAction(title: title, style: .default, handler: { _ in
+ sheetController.dismiss(animated: true, completion: {
+ self.purchase(product: product)
+ self.spinnerView.startAnimating()
+ })
+ })
+ action
+ .accessibilityIdentifier = action.accessibilityIdentifier
+ sheetController.addAction(action)
+ }
+
+ let cancelAction = UIAlertAction(title: NSLocalizedString(
+ "SHEET_CANCEL_BUTTON",
+ tableName: "ActionSheet",
+ value: "Cancel",
+ comment: ""
+ ), style: .cancel) { _ in
+ self.didFinish?()
+ }
+ cancelAction.accessibilityIdentifier = "actoin-sheet-cancel-button"
+ sheetController.addAction(cancelAction)
+ present(sheetController, animated: true)
+ }
+
+ @MainActor
+ func storePaymentManager(_ manager: StorePaymentManager, didReceiveEvent event: StorePaymentEvent) {
+ spinnerView.stopAnimating()
+ switch event {
+ case let .finished(completion):
+ errorPresenter.showAlertForResponse(completion.serverResponse, context: .purchase) {
+ self.didFinish?()
+ }
+
+ case let .failure(paymentFailure):
+ switch paymentFailure.error {
+ case .storePayment(SKError.paymentCancelled):
+ self.didFinish?()
+ default:
+ errorPresenter.showAlertForError(paymentFailure.error, context: .purchase) {
+ self.didFinish?()
+ }
+ }
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift
index 36359599e5..07dc4bda56 100644
--- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift
+++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift
@@ -13,14 +13,8 @@ import Operations
import UIKit
protocol OutOfTimeViewControllerDelegate: AnyObject, Sendable {
- func outOfTimeViewControllerDidBeginPayment(_ controller: OutOfTimeViewController)
- func outOfTimeViewControllerDidEndPayment(_ controller: OutOfTimeViewController)
- func outOfTimeViewControllerDidRequestShowPurchaseOptions(
- _ controller: OutOfTimeViewController,
- products: [SKProduct],
- didRequestPurchase: @escaping (SKProduct) -> Void
- )
- func outOfTimeViewControllerDidFailToFetchProducts(_ controller: OutOfTimeViewController)
+ func didRequestShowPurchaseOptions(accountNumber: String)
+ func didRequestShowRestorePurchase(accountNumber: String)
}
@MainActor
@@ -28,19 +22,9 @@ class OutOfTimeViewController: UIViewController, RootContainment {
weak var delegate: OutOfTimeViewControllerDelegate?
private let interactor: OutOfTimeInteractor
- private let errorPresenter: PaymentAlertPresenter
-
- private var paymentState: PaymentState = .none {
- didSet {
- applyViewState()
- notifyDelegate(oldValue)
- }
- }
private lazy var contentView = OutOfTimeContentView()
- private var isFetchingProducts = false
-
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
@@ -64,7 +48,6 @@ class OutOfTimeViewController: UIViewController, RootContainment {
init(interactor: OutOfTimeInteractor, errorPresenter: PaymentAlertPresenter) {
self.interactor = interactor
- self.errorPresenter = errorPresenter
super.init(nibName: nil, bundle: nil)
}
@@ -101,12 +84,6 @@ class OutOfTimeViewController: UIViewController, RootContainment {
for: .touchUpInside
)
- interactor.didReceivePaymentEvent = { [weak self] event in
- Task { @MainActor in
- self?.didReceivePaymentEvent(event)
- }
- }
-
interactor.didReceiveTunnelStatus = { [weak self] _ in
Task { @MainActor in
self?.setNeedsHeaderBarStyleAppearanceUpdate()
@@ -130,17 +107,9 @@ class OutOfTimeViewController: UIViewController, RootContainment {
private func applyViewState() {
let tunnelState = interactor.tunnelStatus.state
- let isInteractionEnabled = paymentState.allowsViewInteraction
let purchaseButton = contentView.purchaseButton
let isOutOfTime = interactor.deviceState.accountData.map { $0.expiry < Date() } ?? false
-
- contentView.purchaseButton.isLoading = isFetchingProducts
-
- purchaseButton.isEnabled = !isFetchingProducts && isInteractionEnabled && !tunnelState
- .isSecured
- contentView.restoreButton.isEnabled = isInteractionEnabled
-
contentView.enableDisconnectButton(tunnelState.isSecured, animated: true)
if tunnelState.isSecured {
@@ -168,124 +137,22 @@ class OutOfTimeViewController: UIViewController, RootContainment {
)
)
}
-
- if !isInteractionEnabled {
- contentView.statusActivityView.state = .activity
- } else {
- contentView.statusActivityView.state = isOutOfTime ? .failure : .success
- }
-
- view.isUserInteractionEnabled = isInteractionEnabled
- }
-
- private func notifyDelegate(_ oldPaymentState: PaymentState) {
- switch (oldPaymentState, paymentState) {
- case (.none, .makingPayment), (.none, .restoringPurchases):
- delegate?.outOfTimeViewControllerDidBeginPayment(self)
-
- case (.makingPayment, .none), (.restoringPurchases, .none):
- delegate?.outOfTimeViewControllerDidEndPayment(self)
-
- default:
- break
- }
- }
-
- private func didReceivePaymentEvent(_ event: StorePaymentEvent) {
- guard case let .makingPayment(payment) = paymentState,
- payment == event.payment else { return }
-
- switch event {
- case let .finished(completion):
- errorPresenter.showAlertForResponse(completion.serverResponse, context: .purchase)
-
- case let .failure(paymentFailure):
- switch paymentFailure.error {
- case .storePayment(SKError.paymentCancelled):
- break
-
- default:
- errorPresenter.showAlertForError(paymentFailure.error, context: .purchase) {
- self.paymentState = .none
- }
- }
- }
-
- paymentState = .none
- }
-
- private func doPurchase(product: SKProduct) {
- guard let accountData = interactor.deviceState.accountData else {
- return
- }
-
- let payment = SKPayment(product: product)
- interactor.addPayment(payment, for: accountData.number)
-
- paymentState = .makingPayment(payment)
}
// MARK: - Actions
@objc private func requestStoreProducts() {
- guard interactor.deviceState.accountData != nil else {
+ guard let accountNumber = interactor.deviceState.accountData?.number else {
return
}
- let productIdentifiers = Set(StoreSubscription.allCases)
- isFetchingProducts = true
- applyViewState()
- _ = interactor.requestProducts(with: productIdentifiers) { [weak self] result in
- guard let self else { return }
- Task { @MainActor in
- switch result {
- case let .success(success):
- let products = success.products
- if !products.isEmpty {
- delegate?.outOfTimeViewControllerDidRequestShowPurchaseOptions(
- self,
- products: products,
- didRequestPurchase: self.doPurchase
- )
- } else {
- delegate?.outOfTimeViewControllerDidFailToFetchProducts(self)
- }
- case .failure:
- delegate?.outOfTimeViewControllerDidFailToFetchProducts(self)
- }
- isFetchingProducts = false
- applyViewState()
- }
- }
+ delegate?.didRequestShowPurchaseOptions(accountNumber: accountNumber)
}
@objc func restorePurchases() {
- guard let accountData = interactor.deviceState.accountData else {
+ guard let accountNumber = interactor.deviceState.accountData?.number else {
return
}
-
- paymentState = .restoringPurchases
-
- /// Safe to assume `@MainActor` isolation because `SendStoreReceiptOperation` sets both its
- /// `dispatchQueue` and `completionQueue` to `.main`
- _ = interactor.restorePurchases(for: accountData.number) { [weak self] result in
- guard let self else { return }
- MainActor.assumeIsolated {
- switch result {
- case let .success(response):
- errorPresenter.showAlertForResponse(response, context: .restoration) {
- self.paymentState = .none
- }
-
- case let .failure(error as StorePaymentManagerError):
- errorPresenter.showAlertForError(error, context: .restoration) {
- self.paymentState = .none
- }
-
- default:
- paymentState = .none
- }
- }
- }
+ delegate?.didRequestShowRestorePurchase(accountNumber: accountNumber)
}
@objc private func handleDisconnect(_ sender: Any) {
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsInteractorFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsInteractorFactory.swift
index 375629a90e..b0b106ab1c 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsInteractorFactory.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsInteractorFactory.swift
@@ -10,7 +10,6 @@ import MullvadREST
import MullvadSettings
final class SettingsInteractorFactory {
- private let storePaymentManager: StorePaymentManager
private let apiProxy: APIQuerying
private let relayCacheTracker: RelayCacheTracker
private let ipOverrideRepository: IPOverrideRepositoryProtocol
@@ -18,13 +17,11 @@ final class SettingsInteractorFactory {
let tunnelManager: TunnelManager
init(
- storePaymentManager: StorePaymentManager,
tunnelManager: TunnelManager,
apiProxy: APIQuerying,
relayCacheTracker: RelayCacheTracker,
ipOverrideRepository: IPOverrideRepositoryProtocol
) {
- self.storePaymentManager = storePaymentManager
self.tunnelManager = tunnelManager
self.apiProxy = apiProxy
self.relayCacheTracker = relayCacheTracker