summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2025-01-20 16:02:12 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-01-20 16:02:12 +0100
commitcb538fc23fd8c9f17fdffcd66b108b30fb64e3a6 (patch)
tree90cfc7875ae8b537e079a5835422427883be7f32
parentabefbd407a6191a044fea4cc58e431118587fcd9 (diff)
parent91cb3e5038e635b9a33e7f1db275a2311956b03d (diff)
downloadmullvadvpn-cb538fc23fd8c9f17fdffcd66b108b30fb64e3a6.tar.xz
mullvadvpn-cb538fc23fd8c9f17fdffcd66b108b30fb64e3a6.zip
Merge branch 'add-action-sheet-with-payment-options-for-30-and-90-days-ios-990'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj14
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift2
-rw-r--r--ios/MullvadVPN/Coordinators/AccountCoordinator.swift50
-rw-r--r--ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift42
-rw-r--r--ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift48
-rw-r--r--ios/MullvadVPN/Extensions/SKProduct+Sorting.swift15
-rw-r--r--ios/MullvadVPN/Extensions/UIAlertController+InAppPurchase.swift48
-rw-r--r--ios/MullvadVPN/StorePaymentManager/StoreSubscription.swift18
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountContentView.swift6
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountViewController.swift95
-rw-r--r--ios/MullvadVPN/View controllers/Account/ProductState.swift70
-rw-r--r--ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeContentView.swift11
-rw-r--r--ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeInteractor.swift32
-rw-r--r--ios/MullvadVPN/View controllers/CreationAccount/Welcome/WelcomeViewController.swift40
-rw-r--r--ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift2
-rw-r--r--ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeViewController.swift86
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