diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-01-20 16:02:12 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-01-20 16:02:12 +0100 |
| commit | cb538fc23fd8c9f17fdffcd66b108b30fb64e3a6 (patch) | |
| tree | 90cfc7875ae8b537e079a5835422427883be7f32 | |
| parent | abefbd407a6191a044fea4cc58e431118587fcd9 (diff) | |
| parent | 91cb3e5038e635b9a33e7f1db275a2311956b03d (diff) | |
| download | mullvadvpn-cb538fc23fd8c9f17fdffcd66b108b30fb64e3a6.tar.xz mullvadvpn-cb538fc23fd8c9f17fdffcd66b108b30fb64e3a6.zip | |
Merge branch 'add-action-sheet-with-payment-options-for-30-and-90-days-ios-990'
16 files changed, 377 insertions, 202 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 5af100575d..5025450b41 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -154,7 +154,6 @@ 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5867770F290975E8006F721F /* SettingsInteractorFactory.swift */; }; 58677712290976FB006F721F /* SettingsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58677711290976FB006F721F /* SettingsInteractor.swift */; }; 5867771429097BCD006F721F /* PaymentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5867771329097BCD006F721F /* PaymentState.swift */; }; - 5867771629097C5B006F721F /* ProductState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5867771529097C5B006F721F /* ProductState.swift */; }; 5868585524054096000B8131 /* CustomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5868585424054096000B8131 /* CustomButton.swift */; }; 586A0DCB2A20E359006C731C /* MullvadTypes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58D223D5294C8E5E0029F5F8 /* MullvadTypes.framework */; }; 586A0DD12A20E371006C731C /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 586A0DD02A20E371006C731C /* WireGuardKitTypes */; }; @@ -1055,6 +1054,9 @@ 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 */; }; + F95C1C252D3E5E8E00EBE769 /* UIAlertController+InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.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 */ /* Begin PBXContainerItemProxy section */ @@ -1613,7 +1615,6 @@ 5867770F290975E8006F721F /* SettingsInteractorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInteractorFactory.swift; sourceTree = "<group>"; }; 58677711290976FB006F721F /* SettingsInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInteractor.swift; sourceTree = "<group>"; }; 5867771329097BCD006F721F /* PaymentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentState.swift; sourceTree = "<group>"; }; - 5867771529097C5B006F721F /* ProductState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductState.swift; sourceTree = "<group>"; }; 5868585424054096000B8131 /* CustomButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomButton.swift; sourceTree = "<group>"; }; 58695A9F2A4ADA9200328DB3 /* TunnelObfuscationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationTests.swift; sourceTree = "<group>"; }; 586A95112901321B007BAF2B /* IPv6Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv6Endpoint.swift; sourceTree = "<group>"; }; @@ -2311,6 +2312,8 @@ F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorResult.swift; sourceTree = "<group>"; }; F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoRelaysSatisfyingConstraintsError.swift; sourceTree = "<group>"; }; F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; }; + F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+InAppPurchase.swift"; sourceTree = "<group>"; }; + F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -3106,7 +3109,6 @@ 58CCA01722426713004F3011 /* AccountViewController.swift */, 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */, 5867771329097BCD006F721F /* PaymentState.swift */, - 5867771529097C5B006F721F /* ProductState.swift */, 7A7B3AB52C6DE4DA00D4BCCE /* RestorePurchasesView.swift */, ); path = Account; @@ -3152,10 +3154,12 @@ 58B9EB142489139B00095626 /* RESTError+Display.swift */, 58A8EE592976BFBB009C0F8D /* SKError+Localized.swift */, 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */, + F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */, 58A8EE5D2976DB00009C0F8D /* StorePaymentManagerError+Display.swift */, 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 */, @@ -5658,6 +5662,7 @@ 7AD63A3F2CDA53F600445268 /* ObfuscationMethodSelectorTests.swift in Sources */, 44DD7D292B7113CA0005F67F /* MockTunnel.swift in Sources */, A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */, + F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */, A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, 7AD63A3D2CD9065D00445268 /* ObfuscatorPortSelectorTests.swift in Sources */, @@ -5995,6 +6000,7 @@ 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */, 5864AF0929C78850005B0CD9 /* VPNSettingsCellFactory.swift in Sources */, + F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */, F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */, 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, @@ -6082,7 +6088,6 @@ 5868585524054096000B8131 /* CustomButton.swift in Sources */, 58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */, 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */, - 5867771629097C5B006F721F /* ProductState.swift in Sources */, 7A28826A2BA8336600FD9F20 /* VPNSettingsCoordinator.swift in Sources */, 7A6389DE2B7E3BD6008E77E1 /* CustomListItemIdentifier.swift in Sources */, 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */, @@ -6177,6 +6182,7 @@ 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 */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 728684ff95..a2b2bc25f9 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -60,7 +60,7 @@ public enum AccessibilityIdentifier: Equatable { case selectLocationFilterButton case relayFilterChipCloseButton case openPortSelectorMenuButton - + case cancelPurchaseListButton // Cells case deviceCell case accessMethodProtocolSelectionCell diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift index 50f2a54d46..e8f87401ec 100644 --- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift @@ -7,6 +7,7 @@ // import Routing +import StoreKit import UIKit enum AccountDismissReason: Equatable, Sendable { @@ -67,9 +68,26 @@ 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() } } + 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 navigateToRedeemVoucher() { let coordinator = ProfileVoucherCoordinator( navigationController: CustomNavigationController(), @@ -230,4 +248,36 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked let presenter = AlertPresenter(context: self) presenter.showAlert(presentation: presentation, animated: true) } + + func showFailToFetchProducts() { + let message = NSLocalizedString( + "WELCOME_FAILED_TO_FETCH_PRODUCTS_DIALOG", + tableName: "Welcome", + value: + """ + Failed to connect to App store, please try again later. + """, + comment: "" + ) + + 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 + ), + ] + ) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } } diff --git a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift index 54e928e726..003d88d2cc 100644 --- a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift @@ -7,6 +7,7 @@ // import Routing +import StoreKit import UIKit class OutOfTimeCoordinator: Coordinator, Presenting, @preconcurrency OutOfTimeViewControllerDelegate, Poppable { @@ -82,4 +83,45 @@ class OutOfTimeCoordinator: Coordinator, Presenting, @preconcurrency OutOfTimeVi 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: "" + ) + + 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 + ), + ] + ) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } } diff --git a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift index 0f21f39f1c..b7af509df5 100644 --- a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift @@ -81,6 +81,38 @@ final class WelcomeCoordinator: Coordinator, Poppable, Presenting { } extension WelcomeCoordinator: @preconcurrency WelcomeViewControllerDelegate { + func didRequestToShowFailToFetchProducts(controller: WelcomeViewController) { + let message = NSLocalizedString( + "WELCOME_FAILED_TO_FETCH_PRODUCTS_DIALOG", + tableName: "Welcome", + value: + """ + Failed to connect to App store, please try again later. + """, + comment: "" + ) + + 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 + ), + ] + ) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } + func didRequestToShowInfo(controller: WelcomeViewController) { let message = NSLocalizedString( "WELCOME_DEVICE_CONCEPT_TEXT_DIALOG", @@ -118,6 +150,22 @@ extension WelcomeCoordinator: @preconcurrency WelcomeViewControllerDelegate { presenter.showAlert(presentation: presentation, animated: true) } + 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) diff --git a/ios/MullvadVPN/Extensions/SKProduct+Sorting.swift b/ios/MullvadVPN/Extensions/SKProduct+Sorting.swift new file mode 100644 index 0000000000..b066bb5091 --- /dev/null +++ b/ios/MullvadVPN/Extensions/SKProduct+Sorting.swift @@ -0,0 +1,15 @@ +// +// SKProduct+Sorting.swift +// MullvadVPN +// +// Created by Steffen Ernst on 2025-01-14. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import StoreKit + +extension Array where Element == SKProduct { + func sortedByPrice() -> [SKProduct] { + sorted { ($0.price as Decimal) < ($1.price as Decimal) } + } +} diff --git a/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift b/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift new file mode 100644 index 0000000000..b94b457b5a --- /dev/null +++ b/ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift @@ -0,0 +1,48 @@ +// +// 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 alert = UIAlertController(title: localizedString, message: nil, preferredStyle: .actionSheet) + products.sortedByPrice().forEach { product in + guard let localizedTitle = product.customLocalizedTitle else { + return + } + let action = UIAlertAction(title: localizedTitle, style: .default, handler: { _ in + alert.dismiss(animated: true, completion: { + didRequestPurchase(product) + }) + }) + action + .accessibilityIdentifier = + "\(AccessibilityIdentifier.purchaseButton.asString)_\(product.productIdentifier)" + alert.addAction(action) + } + let cancelAction = UIAlertAction(title: NSLocalizedString( + "PRODUCT_LIST_CANCEL_BUTTON", + tableName: "Welcome", + value: "Cancel", + comment: "" + ), style: .cancel) + cancelAction.accessibilityIdentifier = AccessibilityIdentifier.cancelPurchaseListButton.asString + alert.addAction(cancelAction) + return alert + } +} diff --git a/ios/MullvadVPN/StorePaymentManager/StoreSubscription.swift b/ios/MullvadVPN/StorePaymentManager/StoreSubscription.swift index f20a2d4e2d..0401ea8d9c 100644 --- a/ios/MullvadVPN/StorePaymentManager/StoreSubscription.swift +++ b/ios/MullvadVPN/StorePaymentManager/StoreSubscription.swift @@ -9,9 +9,10 @@ import Foundation import StoreKit -enum StoreSubscription: String { +enum StoreSubscription: String, CaseIterable { /// Thirty days non-renewable subscription case thirtyDays = "net.mullvad.MullvadVPN.subscription.30days" + case ninetyDays = "net.mullvad.MullvadVPN.subscription.90days" var localizedTitle: String { switch self { @@ -19,7 +20,14 @@ enum StoreSubscription: String { return NSLocalizedString( "STORE_SUBSCRIPTION_TITLE_ADD_30_DAYS", tableName: "StoreSubscriptions", - value: "Add 30 days time", + value: "Add 30 days", + comment: "" + ) + case .ninetyDays: + return NSLocalizedString( + "STORE_SUBSCRIPTION_TITLE_ADD_90_DAYS", + tableName: "StoreSubscriptions", + value: "Add 90 days", comment: "" ) } @@ -28,7 +36,11 @@ enum StoreSubscription: String { extension SKProduct { var customLocalizedTitle: String? { - StoreSubscription(rawValue: productIdentifier)?.localizedTitle + guard let localizedTitle = StoreSubscription(rawValue: productIdentifier)?.localizedTitle, + let localizedPrice else { + return nil + } + return "\(localizedTitle) (\(localizedPrice))" } } diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index bedcdbdf04..3d34c3df29 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -12,6 +12,12 @@ class AccountContentView: UIView { let purchaseButton: InAppPurchaseButton = { let button = InAppPurchaseButton() button.setAccessibilityIdentifier(.purchaseButton) + button.setTitle(NSLocalizedString( + "ADD_TIME_BUTTON_TITLE", + tableName: "Account", + value: "Add time", + comment: "" + ), for: .normal) return button }() diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index 19060381ab..41fd3bef25 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -14,6 +14,12 @@ 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 @@ -21,6 +27,8 @@ enum AccountViewControllerAction: Sendable { case navigateToVoucher case navigateToDeleteAccount case restorePurchasesInfo + case showPurchaseOptions(PurchaseOptionDetails) + case showFailedToLoadProducts } class AccountViewController: UIViewController, @unchecked Sendable { @@ -35,7 +43,7 @@ class AccountViewController: UIViewController, @unchecked Sendable { return contentView }() - private var productState: ProductState = .none + private var isFetchingProducts = false private var paymentState: PaymentState = .none var actionHandler: ActionHandler? @@ -105,19 +113,10 @@ class AccountViewController: UIViewController, @unchecked Sendable { addActions() updateView(from: interactor.deviceState) applyViewState(animated: false) - requestStoreProductsIfCan() } // MARK: - Private - private func requestStoreProductsIfCan() { - if StorePaymentManager.canMakePayments { - requestStoreProducts() - } else { - setProductState(.cannotMakePurchases, animated: false) - } - } - private func configUI() { let scrollView = UIScrollView() @@ -141,7 +140,7 @@ class AccountViewController: UIViewController, @unchecked Sendable { contentView.purchaseButton.addTarget( self, - action: #selector(doPurchase), + action: #selector(requestStoreProducts), for: .touchUpInside ) @@ -152,21 +151,15 @@ class AccountViewController: UIViewController, @unchecked Sendable { contentView.storeKit2Button.addTarget(self, action: #selector(handleStoreKit2Purchase), for: .touchUpInside) } - private func requestStoreProducts() { - let productKind = StoreSubscription.thirtyDays - - setProductState(.fetching(productKind), animated: true) + private func doPurchase(product: SKProduct) { + guard let accountData = interactor.deviceState.accountData else { + return + } - _ = interactor.requestProducts(with: [productKind]) { [weak self] completion in - let productState: ProductState = completion.value?.products.first - .map { .received($0) } ?? .failed + let payment = SKPayment(product: product) + interactor.addPayment(payment, for: accountData.number) - /// `@MainActor` isolation is safe here because - /// `ProductsRequestOperation` sets its `completionQueue` to `.main` - MainActor.assumeIsolated { - self?.setProductState(productState, animated: true) - } - } + setPaymentState(.makingPayment(payment), animated: true) } @MainActor @@ -176,8 +169,8 @@ class AccountViewController: UIViewController, @unchecked Sendable { applyViewState(animated: animated) } - private func setProductState(_ newState: ProductState, animated: Bool) { - productState = newState + private func setIsFetchingProducts(_ isFetchingProducts: Bool, animated: Bool = false) { + self.isFetchingProducts = isFetchingProducts applyViewState(animated: animated) } @@ -197,16 +190,15 @@ class AccountViewController: UIViewController, @unchecked Sendable { let purchaseButton = contentView.purchaseButton let activityIndicator = contentView.accountExpiryRowView.activityIndicator - if productState.isFetching || paymentState != .none { + if isFetchingProducts || paymentState != .none { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } - purchaseButton.setTitle(productState.purchaseButtonTitle, for: .normal) - contentView.purchaseButton.isLoading = productState.isFetching + contentView.purchaseButton.isLoading = isFetchingProducts - purchaseButton.isEnabled = productState.isReceived && isInteractionEnabled + purchaseButton.isEnabled = !isFetchingProducts && isInteractionEnabled contentView.accountDeviceRow.setButtons(enabled: isInteractionEnabled) contentView.accountTokenRowView.setButtons(enabled: isInteractionEnabled) contentView.restorePurchasesView.setButtons(enabled: isInteractionEnabled) @@ -269,17 +261,33 @@ class AccountViewController: UIViewController, @unchecked Sendable { actionHandler?(.navigateToDeleteAccount) } - @objc private func doPurchase() { - guard case let .received(product) = productState, - let accountData = interactor.deviceState.accountData - else { + @objc private func requestStoreProducts() { + guard let accountData = interactor.deviceState.accountData else { return } - - let payment = SKPayment(product: product) - interactor.addPayment(payment, for: accountData.number) - - setPaymentState(.makingPayment(payment), animated: true) + 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) + } + } } @objc private func restorePurchases() { @@ -309,17 +317,17 @@ class AccountViewController: UIViewController, @unchecked Sendable { } @objc private func handleStoreKit2Purchase() { - guard case let .received(oldProduct) = productState, - let accountData = interactor.deviceState.accountData - else { + guard let accountData = interactor.deviceState.accountData else { return } + let productIdentifiers = StoreSubscription.allCases.map { $0.rawValue } + setPaymentState(.makingStoreKit2Purchase, animated: true) Task { do { - let product = try await Product.products(for: [oldProduct.productIdentifier]).first! + let product = try await Product.products(for: productIdentifiers).first! let result = try await product.purchase() switch result { @@ -327,7 +335,6 @@ class AccountViewController: UIViewController, @unchecked Sendable { let transaction = try checkVerified(verification) await sendReceiptToAPI(accountNumber: accountData.identifier, receipt: verification) await transaction.finish() - case .userCancelled: print("User cancelled the purchase") case .pending: diff --git a/ios/MullvadVPN/View controllers/Account/ProductState.swift b/ios/MullvadVPN/View controllers/Account/ProductState.swift deleted file mode 100644 index 8188a9b3c8..0000000000 --- a/ios/MullvadVPN/View controllers/Account/ProductState.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ProductState.swift -// MullvadVPN -// -// Created by pronebird on 26/10/2022. -// Copyright © 2022 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import StoreKit - -enum ProductState { - case none - case fetching(StoreSubscription) - case received(SKProduct) - case failed - case cannotMakePurchases - - var isFetching: Bool { - if case .fetching = self { - return true - } - return false - } - - var isReceived: Bool { - if case .received = self { - return true - } - return false - } - - var purchaseButtonTitle: String? { - switch self { - case .none: - return nil - - case let .fetching(subscription): - return subscription.localizedTitle - - case let .received(product): - let localizedTitle = product.customLocalizedTitle ?? "" - let localizedPrice = product.localizedPrice ?? "" - - let format = NSLocalizedString( - "PURCHASE_BUTTON_TITLE_FORMAT", - tableName: "Account", - value: "%1$@ (%2$@)", - comment: "" - ) - return String(format: format, localizedTitle, localizedPrice) - - case .failed: - return NSLocalizedString( - "PURCHASE_BUTTON_CANNOT_CONNECT_TO_APPSTORE_LABEL", - tableName: "Account", - value: "Cannot connect to AppStore", - comment: "" - ) - - case .cannotMakePurchases: - return NSLocalizedString( - "PURCHASE_BUTTON_PAYMENTS_RESTRICTED_LABEL", - tableName: "Account", - value: "Payments restricted", - comment: "" - ) - } - } -} diff --git a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift index 17c9de2d2b..3ded47c9a2 100644 --- a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift +++ b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift @@ -110,9 +110,9 @@ final class WelcomeContentView: UIView, Sendable { let button = InAppPurchaseButton() button.setAccessibilityIdentifier(.purchaseButton) let localizedString = NSLocalizedString( - "BUY_CREDIT_BUTTON", + "ADD_TIME_BUTTON", tableName: "Welcome", - value: "Buy credit", + value: "Add time", comment: "" ) button.setTitle(localizedString, for: .normal) @@ -182,11 +182,10 @@ final class WelcomeContentView: UIView, Sendable { } } - var productState: ProductState = .none { + var isFetchingProducts = false { didSet { - purchaseButton.setTitle(productState.purchaseButtonTitle, for: .normal) - purchaseButton.isLoading = productState.isFetching - purchaseButton.isEnabled = productState.isReceived + purchaseButton.isLoading = isFetchingProducts + purchaseButton.isEnabled = !isFetchingProducts } } diff --git a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeInteractor.swift b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeInteractor.swift index 11bddcf5aa..9294498be9 100644 --- a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeInteractor.swift +++ b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeInteractor.swift @@ -21,18 +21,10 @@ final class WelcomeInteractor: @unchecked Sendable { private let logger = Logger(label: "\(WelcomeInteractor.self)") private var tunnelObserver: TunnelObserver? - private(set) var product: SKProduct? + private(set) var products: [SKProduct]? - var didChangeInAppPurchaseState: ((ProductState) -> Void)? var didAddMoreCredit: (() -> Void)? - var viewDidLoad = false { - didSet { - guard viewDidLoad else { return } - requestAccessToStore() - } - } - var viewWillAppear = false { didSet { guard viewWillAppear else { return } @@ -77,20 +69,14 @@ final class WelcomeInteractor: @unchecked Sendable { self.tunnelObserver = tunnelObserver } - private func requestAccessToStore() { - if !StorePaymentManager.canMakePayments { - didChangeInAppPurchaseState?(.cannotMakePurchases) - } else { - let product = StoreSubscription.thirtyDays - didChangeInAppPurchaseState?(.fetching(product)) - _ = storePaymentManager.requestProducts(with: [product]) { [weak self] result in - guard let self else { return } - let product = result.value?.products.first - let productState: ProductState = product.map { .received($0) } ?? .failed - didChangeInAppPurchaseState?(productState) - self.product = product - } - } + func requestProducts( + with productIdentifiers: Set<StoreSubscription>, + completionHandler: @Sendable @escaping (Result<SKProductsResponse, Error>) -> Void + ) -> Cancellable { + storePaymentManager.requestProducts( + with: productIdentifiers, + completionHandler: completionHandler + ) } private func startAccountUpdateTimer() { diff --git a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift index 909c22b824..70911810e0 100644 --- a/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift +++ b/ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift @@ -12,7 +12,12 @@ import UIKit protocol WelcomeViewControllerDelegate: AnyObject { func didRequestToRedeemVoucher(controller: WelcomeViewController) func didRequestToShowInfo(controller: WelcomeViewController) - func didRequestToPurchaseCredit(controller: WelcomeViewController, accountNumber: String, product: SKProduct) + func didRequestToViewPurchaseOptions( + controller: WelcomeViewController, + products: [SKProduct], + accountNumber: String + ) + func didRequestToShowFailToFetchProducts(controller: WelcomeViewController) } class WelcomeViewController: UIViewController, RootContainment { @@ -59,11 +64,6 @@ class WelcomeViewController: UIViewController, RootContainment { super.viewDidLoad() configureUI() contentView.viewModel = interactor.viewModel - interactor.didChangeInAppPurchaseState = { [weak self] productState in - guard let self else { return } - self.contentView.productState = productState - } - interactor.viewDidLoad = true } override func viewWillAppear(_ animated: Bool) { @@ -89,12 +89,28 @@ extension WelcomeViewController: @preconcurrency WelcomeContentViewDelegate { } func didTapPurchaseButton(welcomeContentView: WelcomeContentView, button: AppButton) { - interactor.product.flatMap { - delegate?.didRequestToPurchaseCredit( - controller: self, - accountNumber: interactor.accountNumber, - product: $0 - ) + 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 + } } } diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift index 4955741292..71b71a418c 100644 --- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift +++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift @@ -56,7 +56,7 @@ class OutOfTimeContentView: UIView { let localizedString = NSLocalizedString( "OUT_OF_TIME_PURCHASE_BUTTON", tableName: "OutOfTime", - value: "Add 30 days time", + value: "Add time", comment: "" ) button.setTitle(localizedString, for: .normal) diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift index 5f26cd4643..8c70610f9c 100644 --- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift +++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift @@ -15,6 +15,12 @@ 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) } @MainActor @@ -24,12 +30,6 @@ class OutOfTimeViewController: UIViewController, RootContainment { private let interactor: OutOfTimeInteractor private let errorPresenter: PaymentAlertPresenter - private var productState: ProductState = .none { - didSet { - applyViewState() - } - } - private var paymentState: PaymentState = .none { didSet { applyViewState() @@ -39,6 +39,8 @@ class OutOfTimeViewController: UIViewController, RootContainment { private lazy var contentView = OutOfTimeContentView() + private var isFetchingProducts = false + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } @@ -86,7 +88,7 @@ class OutOfTimeViewController: UIViewController, RootContainment { ) contentView.purchaseButton.addTarget( self, - action: #selector(doPurchase), + action: #selector(requestStoreProducts), for: .touchUpInside ) contentView.restoreButton.addTarget( @@ -107,12 +109,7 @@ class OutOfTimeViewController: UIViewController, RootContainment { self?.applyViewState() } } - - if StorePaymentManager.canMakePayments { - requestStoreProducts() - } else { - productState = .cannotMakePurchases - } + applyViewState() } override func viewDidAppear(_ animated: Bool) { @@ -127,21 +124,6 @@ class OutOfTimeViewController: UIViewController, RootContainment { // MARK: - Private - private func requestStoreProducts() { - let productKind = StoreSubscription.thirtyDays - - productState = .fetching(productKind) - - _ = interactor.requestProducts(with: [productKind]) { [weak self] completion in - let productState: ProductState = completion.value?.products.first - .map { .received($0) } ?? .failed - - Task { @MainActor in - self?.productState = productState - } - } - } - private func applyViewState() { let tunnelState = interactor.tunnelStatus.state let isInteractionEnabled = paymentState.allowsViewInteraction @@ -149,10 +131,9 @@ class OutOfTimeViewController: UIViewController, RootContainment { let isOutOfTime = interactor.deviceState.accountData.map { $0.expiry < Date() } ?? false - purchaseButton.setTitle(productState.purchaseButtonTitle, for: .normal) - contentView.purchaseButton.isLoading = productState.isFetching + contentView.purchaseButton.isLoading = isFetchingProducts - purchaseButton.isEnabled = productState.isReceived && isInteractionEnabled && !tunnelState + purchaseButton.isEnabled = !isFetchingProducts && isInteractionEnabled && !tunnelState .isSecured contentView.restoreButton.isEnabled = isInteractionEnabled @@ -177,7 +158,7 @@ class OutOfTimeViewController: UIViewController, RootContainment { tableName: "OutOfTime", value: """ You have no more VPN time left on this account. Either buy credit on our website \ - or make an in-app purchase via the **Add 30 days time** button below. + or make an in-app purchase via the **Add time** button below. """, comment: "" ) @@ -229,12 +210,8 @@ class OutOfTimeViewController: UIViewController, RootContainment { paymentState = .none } - // MARK: - Actions - - @objc private func doPurchase() { - guard case let .received(product) = productState, - let accountData = interactor.deviceState.accountData - else { + private func doPurchase(product: SKProduct) { + guard let accountData = interactor.deviceState.accountData else { return } @@ -244,6 +221,39 @@ class OutOfTimeViewController: UIViewController, RootContainment { paymentState = .makingPayment(payment) } + // MARK: - Actions + + @objc private func requestStoreProducts() { + guard interactor.deviceState.accountData != nil 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() + } + } + } + @objc func restorePurchases() { guard let accountData = interactor.deviceState.accountData else { return |
