diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb22a517..bb27508b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 3.10.0 + +### Enhancements + +- Adds `purchase(_:)` to initiate a purchase of an `SKProduct` via Superwall regardless of whether you are using paywalls or not. +- Adds `restorePurchases()` to restore purchases via Superwall. +- Adds an optional `paywall(_:loadingStateDidChange)` function to the `PaywallViewControllerDelegate`. This is called when the loading state of the presented `PaywallViewController` did change. +- Makes `loadingState` on the `PaywallViewController` a public published property. + +### Fixes + +- Tweaks AdServices token logic to prevent getting the token twice. + ## 3.9.1 ### Fixes diff --git a/Examples/UIKit+RevenueCat/Superwall-UIKit+RevenueCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/UIKit+RevenueCat/Superwall-UIKit+RevenueCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 643eb1470..8201e6a64 100644 --- a/Examples/UIKit+RevenueCat/Superwall-UIKit+RevenueCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/UIKit+RevenueCat/Superwall-UIKit+RevenueCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "0d2f1fa050acad0de99b9b0ce45d53c741c20629", - "version": "5.2.1" + "revision": "c7058bfd80d7f42ca6aa392bf1fab769d1158bf1", + "version": "5.5.0" } } ] diff --git a/Sources/SuperwallKit/Analytics/Ad Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Ad Attribution/AttributionPoster.swift index de6620dc9..fea0b2001 100644 --- a/Sources/SuperwallKit/Analytics/Ad Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Ad Attribution/AttributionPoster.swift @@ -10,6 +10,8 @@ import Foundation final class AttributionPoster { private let attributionFetcher = AttributionFetcher() private let collectAdServicesAttribution: Bool + private var isCollecting = false + private unowned let storage: Storage private var adServicesTokenToPostIfNeeded: String? { @@ -52,6 +54,9 @@ final class AttributionPoster { guard Superwall.isInitialized else { return } + if isCollecting { + return + } Task(priority: .background) { if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { await getAdServicesTokenIfNeeded() @@ -65,7 +70,11 @@ final class AttributionPoster { @available(tvOS, unavailable) @available(watchOS, unavailable) func getAdServicesTokenIfNeeded() async { + defer { + isCollecting = false + } do { + isCollecting = true guard collectAdServicesAttribution else { return } diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 8fb054448..bbc6d779c 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -241,7 +241,10 @@ class ConfigManager { } } - func fetchWithTimeout(_ task: @escaping () async throws -> T, timeout: TimeInterval) async throws -> T { + private func fetchWithTimeout( + _ task: @escaping () async throws -> T, + timeout: TimeInterval + ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await task() diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index f3e21e20a..ea99fdf2d 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -188,6 +188,7 @@ public final class SuperwallOptions: NSObject, Encodable { case isExternalDataCollectionEnabled case localeIdentifier case isGameControllerEnabled + case collectAdServicesAttribution } public func encode(to encoder: Encoder) throws { @@ -200,6 +201,7 @@ public final class SuperwallOptions: NSObject, Encodable { try container.encode(isExternalDataCollectionEnabled, forKey: .isExternalDataCollectionEnabled) try container.encode(localeIdentifier, forKey: .localeIdentifier) try container.encode(isGameControllerEnabled, forKey: .isGameControllerEnabled) + try container.encode(collectAdServicesAttribution, forKey: .collectAdServicesAttribution) } func toDictionary() -> [String: Any] { diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 608d65837..086abda5d 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -139,6 +139,7 @@ final class DependencyContainer { receiptManager: receiptManager, sessionEventsManager: sessionEventsManager, identityManager: identityManager, + storage: storage, factory: self ) } @@ -468,12 +469,18 @@ extension DependencyContainer: PurchasedTransactionsFactory { return productPurchaser.coordinator } - func purchase(product: SKProduct) async -> PurchaseResult { - return await productPurchaser.purchase(product: product) + func purchase( + product: SKProduct, + isExternal: Bool + ) async -> PurchaseResult { + return await productPurchaser.purchase( + product: product, + isExternal: isExternal + ) } - func restorePurchases() async -> RestorationResult { - return await productPurchaser.restorePurchases() + func restorePurchases(isExternal: Bool) async -> RestorationResult { + return await productPurchaser.restorePurchases(isExternal: isExternal) } } diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 31ec7b1ff..13981fbe8 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -140,8 +140,11 @@ protocol TriggerFactory: AnyObject { protocol PurchasedTransactionsFactory { func makePurchasingCoordinator() -> PurchasingCoordinator - func purchase(product: SKProduct) async -> PurchaseResult - func restorePurchases() async -> RestorationResult + func purchase( + product: SKProduct, + isExternal: Bool + ) async -> PurchaseResult + func restorePurchases(isExternal: Bool) async -> RestorationResult } protocol UserAttributesEventFactory { diff --git a/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md b/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md index 7b0b51f34..b73137b71 100644 --- a/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md +++ b/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md @@ -55,6 +55,18 @@ The ``Superwall`` class is used to access all the features of the SDK. Before us - ``SuperwallEventObjc`` - ``PaywallSkippedReason`` - ``PaywallSkippedReasonObjc`` +- ``PaywallViewController`` +- ``PaywallViewControllerDelegate`` +- ``PaywallViewControllerDelegateObjc`` + +### Handling Purchases + +- ``purchase(_:)`` +- ``purchase(_:completion:)-6oyxm`` +- ``purchase(_:completion:)-4rj6r`` +- ``restorePurchases()`` +- ``restorePurchases(completion:)-4fx45`` +- ``restorePurchases(completion:)-4cxt5`` ### In-App Previews diff --git a/Sources/SuperwallKit/Logger/LogScope.swift b/Sources/SuperwallKit/Logger/LogScope.swift index c4b718d06..aadb96ddb 100644 --- a/Sources/SuperwallKit/Logger/LogScope.swift +++ b/Sources/SuperwallKit/Logger/LogScope.swift @@ -29,6 +29,8 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { case receipts case superwallCore case paywallPresentation + + // TODO: In v4 rename to transactions case paywallTransactions case paywallViewController case cache diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 26443b75f..8925a8c89 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -3.9.1 +3.10.0 """ diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Loading State/PaywallLoadingState.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Loading State/PaywallLoadingState.swift new file mode 100644 index 000000000..393cb19b5 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Loading State/PaywallLoadingState.swift @@ -0,0 +1,27 @@ +// +// PaywallLoadingState.swift +// SuperwallKit +// +// Created by Thomas LE GRAVIER on 03/10/2024. +// + +import Foundation + +/// Contains the possible loading states of a paywall. +@objc(SWKPaywallLoadingState) +public enum PaywallLoadingState: Int, Sendable { + /// The initial state of the paywall + case unknown + + /// When a purchase is loading + case loadingPurchase + + /// When the paywall URL is loading + case loadingURL + + /// When the user has manually shown the spinner + case manualLoading + + /// When everything has loaded. + case ready +} diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallCloseReason.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallCloseReason.swift index 7e9a0c7d1..7362ea356 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallCloseReason.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallCloseReason.swift @@ -42,7 +42,7 @@ public enum PaywallCloseReason: Int, Codable, Equatable, Sendable, CustomStringC case .webViewFailedToLoad: return "webViewFailedToLoad" case .manualClose: - return "manualClsoe" + return "manualClose" case .none: return "none" } diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index 12904b68d..de3cbd33b 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -4,6 +4,7 @@ // // Created by Yusuf Tör on 28/02/2022. // +// swiftlint:disable file_length import Foundation import StoreKit @@ -332,12 +333,12 @@ public final class PaywallInfo: NSObject { extension PaywallInfo: Stubbable { static func stub() -> PaywallInfo { return PaywallInfo( - databaseId: "abc", - identifier: "1", - name: "Test", - cacheKey: "cacheKey", - buildId: "buildId", - url: URL(string: "https://www.google.com")!, + databaseId: "test", + identifier: "test", + name: "test", + cacheKey: "test", + buildId: "test", + url: URL(string: "https://superwall.com")!, products: [], productItems: [], productIds: [], @@ -361,7 +362,54 @@ extension PaywallInfo: Stubbable { computedPropertyRequests: [], surveys: [], presentation: .init( - style: .fullscreen, + style: .none, + condition: .checkUserSubscription, + delay: 0 + ) + ) + } + + /// Used when purchasing internally. + static func empty() -> PaywallInfo { + return PaywallInfo( + databaseId: "", + identifier: "", + name: "", + cacheKey: "", + buildId: "", + url: URL(string: "https://superwall.com")!, + products: [], + productItems: [], + productIds: [], + fromEventData: nil, + responseLoadStartTime: nil, + responseLoadCompleteTime: nil, + responseLoadFailTime: nil, + webViewLoadStartTime: nil, + webViewLoadCompleteTime: nil, + webViewLoadFailTime: nil, + productsLoadStartTime: nil, + productsLoadFailTime: nil, + productsLoadCompleteTime: nil, + experiment: .init( + id: "0", + groupId: "0", + variant: .init( + id: "0", + type: .holdout, + paywallId: "0" + ) + ), + paywalljsVersion: nil, + isFreeTrialAvailable: false, + presentationSourceType: "", + featureGatingBehavior: .nonGated, + closeReason: .none, + localNotifications: [], + computedPropertyRequests: [], + surveys: [], + presentation: .init( + style: .none, condition: .checkUserSubscription, delay: 0 ) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegate.swift b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegate.swift index 336e1a9a0..0ac6bfafd 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegate.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegate.swift @@ -28,6 +28,25 @@ public protocol PaywallViewControllerDelegate: AnyObject { didFinishWith result: PaywallResult, shouldDismiss: Bool ) + + /// Tells the delegate that the loading state of the paywall did change. + /// + /// - Parameters: + /// - paywall: The ``PaywallViewController`` that the user is interacting with. + /// - loadingState: A ``PaywallLoadingState`` enum that contains the loading state of + /// the ``PaywallViewController``. + @MainActor + func paywall( + _ paywall: PaywallViewController, + loadingStateDidChange loadingState: PaywallLoadingState + ) +} + +extension PaywallViewControllerDelegate { + public func paywall( + _ paywall: PaywallViewController, + loadingStateDidChange loadingState: PaywallLoadingState + ) {} } /// Objective-C-only interface for responding to user interactions with a ``PaywallViewController`` that @@ -52,6 +71,25 @@ public protocol PaywallViewControllerDelegateObjc: AnyObject { didFinishWithResult result: PaywallResultObjc, shouldDismiss: Bool ) + + /// Tells the delegate that the loading state of the paywall did change. + /// + /// - Parameters: + /// - paywall: The ``PaywallViewController`` that the user is interacting with. + /// - loadingState: A ``PaywallLoadingState`` enum that contains the loading state of + /// the ``PaywallViewController``. + @MainActor + func paywall( + _ paywall: PaywallViewController, + loadingStateDidChange loadingState: PaywallLoadingState + ) +} + +extension PaywallViewControllerDelegateObjc { + public func paywall( + _ paywall: PaywallViewController, + loadingStateDidChange loadingState: PaywallLoadingState + ) {} } protocol PaywallViewControllerEventDelegate: AnyObject { @@ -60,20 +98,3 @@ protocol PaywallViewControllerEventDelegate: AnyObject { on paywallViewController: PaywallViewController ) async } - -enum PaywallLoadingState { - /// The initial state of the paywall - case unknown - - /// When a purchase is loading - case loadingPurchase - - /// When the paywall URL is loading - case loadingURL - - /// When the user has manually shown the spinner - case manualLoading - - /// When everything has loaded. - case ready -} diff --git a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift index de31b913e..1a995ead0 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift @@ -33,6 +33,15 @@ final class PaywallViewControllerDelegateAdapter { swiftDelegate?.paywall(paywall, didFinishWith: result, shouldDismiss: shouldDismiss) objcDelegate?.paywall(paywall, didFinishWithResult: result.convertForObjc(), shouldDismiss: shouldDismiss) } + + @MainActor + func loadingStateDidChange( + paywall: PaywallViewController, + loadingState: PaywallLoadingState + ) { + swiftDelegate?.paywall(paywall, loadingStateDidChange: loadingState) + objcDelegate?.paywall(paywall, loadingStateDidChange: loadingState) + } } // MARK: - Stubbable diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 66d274d22..96d5dd05d 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -55,11 +55,17 @@ public class PaywallViewController: UIViewController, LoadingDelegate { ) } - /// The loading state of the paywall. - var loadingState: PaywallLoadingState = .unknown { + /// A published property that indicates the loading state of the paywall. + /// + /// This is a published value + @Published public internal(set) var loadingState: PaywallLoadingState = .unknown { didSet { if loadingState != oldValue { loadingStateDidChange(from: oldValue) + delegate?.loadingStateDidChange( + paywall: self, + loadingState: loadingState + ) } } } @@ -85,10 +91,10 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private var paywallResult: PaywallResult? /// A timer that shows the refresh buttons/modal when it fires. - private var showRefreshTimer: Timer? + private var showRefreshTimer: Timer? /// Defines when Safari is presenting in app. - private var isSafariVCPresented = false + private var isSafariVCPresented = false /// The presentation style for the paywall. private var presentationStyle: PaywallPresentationStyle @@ -199,7 +205,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { public override func viewDidLoad() { super.viewDidLoad() - configureUI() + configureUI() loadWebView() } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 45de353da..ea89f41c3 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -176,3 +176,11 @@ enum AdServicesTokenStorage: Storable { static var directory: SearchPathDirectory = .userSpecificDocuments typealias Value = String } + +enum SavedTransactions: Storable { + static var key: String { + "store.savedTransactions" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Set +} diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index ebce674ae..1f2a15e99 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -32,12 +32,15 @@ final class AutomaticPurchaseController { extension AutomaticPurchaseController: PurchaseController { @MainActor func purchase(product: SKProduct) async -> PurchaseResult { - return await factory.purchase(product: product) + return await factory.purchase( + product: product, + isExternal: false + ) } @MainActor func restorePurchases() async -> RestorationResult { - let result = await factory.restorePurchases() + let result = await factory.restorePurchases(isExternal: false) let hasRestored = result == .restored await factory.refreshReceipt() diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/PurchaseResult.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/PurchaseResult.swift index 222e12d8c..2b67b0b2a 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/PurchaseResult.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/PurchaseResult.swift @@ -59,6 +59,16 @@ public enum PurchaseResult: Sendable, Equatable { return false } } + + func toObjc() -> PurchaseResultObjc { + switch self { + case .cancelled: return .cancelled + case .purchased: return .purchased + case .restored: return .restored + case .pending: return .pending + case .failed: return .failed + } + } } // MARK: - Objective-C Only diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/RestorationResult.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/RestorationResult.swift index c27189b79..dfde6b6b4 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/RestorationResult.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/RestorationResult.swift @@ -14,9 +14,11 @@ import StoreKit /// When implementing the ``PurchaseController/restorePurchases()`` delegate /// method, all cases should be considered. public enum RestorationResult: Sendable, Equatable { - /// The restore was successful – this does not mean the user is subscribed, it just means your restore - /// logic did not fail due to some error. User will see an alert if `Superwall.shared.subscriptionStatus` is - /// not `.active` after returning this value. + /// The restore was successful + /// + /// - Warning: This does not mean the user is subscribed, it just means your restore + /// logic did not fail due to some error. User will see an alert if ``Superwall/subscriptionStatus`` is + /// not ``SubscriptionStatus/active`` after returning this value. case restored /// The restore failed for some reason (i.e. you were not able to determine if the user has an active subscription. @@ -31,6 +33,15 @@ public enum RestorationResult: Sendable, Equatable { return false } } + + func toObjc() -> RestorationResultObjc { + switch self { + case .restored: + return .restored + case .failed: + return .failed + } + } } /// An enum that defines the possible outcomes of attempting to restore a product. diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift index 676c91f40..11492f7be 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift @@ -16,6 +16,7 @@ final class ProductPurchaserSK1: NSObject { // MARK: Restoration final class Restoration { var completion: ((Error?) -> Void)? + var isExternal = false var dispatchGroup = DispatchGroup() /// Used to serialise the async `SKPaymentQueue` calls when restoring. var queue = DispatchQueue( @@ -30,6 +31,7 @@ final class ProductPurchaserSK1: NSObject { private let receiptManager: ReceiptManager private let sessionEventsManager: SessionEventsManager private let identityManager: IdentityManager + private let storage: Storage private let factory: HasExternalPurchaseControllerFactory & StoreTransactionFactory deinit { @@ -41,6 +43,7 @@ final class ProductPurchaserSK1: NSObject { receiptManager: ReceiptManager, sessionEventsManager: SessionEventsManager, identityManager: IdentityManager, + storage: Storage, factory: HasExternalPurchaseControllerFactory & StoreTransactionFactory ) { self.storeKitManager = storeKitManager @@ -48,6 +51,7 @@ final class ProductPurchaserSK1: NSObject { self.sessionEventsManager = sessionEventsManager self.identityManager = identityManager self.factory = factory + self.storage = storage super.init() SKPaymentQueue.default().add(self) @@ -55,7 +59,10 @@ final class ProductPurchaserSK1: NSObject { /// Purchases a product, waiting for the completion block to be fired and /// returning a purchase result. - func purchase(product: SKProduct) async -> PurchaseResult { + func purchase( + product: SKProduct, + isExternal: Bool + ) async -> PurchaseResult { let task = Task { return await withCheckedContinuation { continuation in Task { @@ -72,7 +79,7 @@ final class ProductPurchaserSK1: NSObject { return await task.value } - func restorePurchases() async -> RestorationResult { + func restorePurchases(isExternal: Bool) async -> RestorationResult { let result = await withCheckedContinuation { continuation in // Using restoreCompletedTransactions instead of just refreshing // the receipt so that RC can pick up on the restored products, @@ -80,6 +87,7 @@ final class ProductPurchaserSK1: NSObject { restoration.completion = { completed in return continuation.resume(returning: completed) } + restoration.isExternal = isExternal SKPaymentQueue.default().restoreCompletedTransactions() } restoration.completion = nil @@ -124,12 +132,10 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { let paywallViewController = Superwall.shared.paywallViewController let purchaseDate = await coordinator.purchaseDate for transaction in transactions { + await savePurchasedAndRestoredTransaction(transaction) await coordinator.storeIfPurchased(transaction) await checkForTimeout(of: transaction, in: paywallViewController) await updatePurchaseCompletionBlock(for: transaction, purchaseDate: purchaseDate) - Task(priority: .background) { - await record(transaction) - } } await loadPurchasedProductsIfPossible(from: transactions) restoration.dispatchGroup.leave() @@ -145,9 +151,6 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { guard #available(iOS 14, *) else { return } - guard let paywallViewController = paywallViewController else { - return - } switch transaction.transactionState { case .failed: if let error = transaction.error { @@ -156,12 +159,12 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { case .overlayTimeout: let trackedEvent = await InternalSuperwallEvent.Transaction( state: .timeout, - paywallInfo: paywallViewController.info, + paywallInfo: paywallViewController?.info ?? .empty(), product: nil, model: nil ) await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionTimeout) + await paywallViewController?.webView.messageHandler.handle(.transactionTimeout) default: break } @@ -172,16 +175,84 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { } } + private func savePurchasedAndRestoredTransaction( + _ transaction: SKPaymentTransaction + ) async { + guard let id = transaction.transactionIdentifier else { + return + } + let state = SavedTransactionState.from(transaction.transactionState) + var savedTransactions = storage.get(SavedTransactions.self) ?? [] + guard savedTransactions.filter({ $0.id == id && $0.state == state }).isEmpty else { + return + } + let isExternal: Bool + switch transaction.transactionState { + case .purchased: + isExternal = await coordinator.isExternal + case .restored: + isExternal = restoration.isExternal + default: + return + } + let transaction = SavedTransaction( + id: id, + state: state, + date: Date(), + hasExternalPurchaseController: factory.makeHasExternalPurchaseController(), + isExternal: isExternal + ) + savedTransactions.insert(transaction) + storage.save(savedTransactions, forType: SavedTransactions.self) + } + + func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { + // Retrieve saved transactions from storage + var savedPurchasedTransactions = storage.get(SavedTransactions.self) ?? [] + + // Filter out the transactions that have been removed + let removedTransactionIDs = Set(transactions.compactMap { $0.transactionIdentifier }) + + savedPurchasedTransactions = savedPurchasedTransactions.filter { savedTransaction in + // Keep only transactions that are not in the removedTransactionIDs + !removedTransactionIDs.contains(savedTransaction.id) + } + + // Save the updated savedTransactions back to storage + storage.save(savedPurchasedTransactions, forType: SavedTransactions.self) + } + /// Sends a `PurchaseResult` to the completion block and stores the latest purchased transaction. private func updatePurchaseCompletionBlock( for skTransaction: SKPaymentTransaction, purchaseDate: Date? ) async { - // Only continue if using internal purchase controller. The transaction may be + // Doesn't continue if the purchase/restore was initiated internally and an + // external purchase controller is being used. The transaction may be // readded to the queue if finishing fails so we need to make sure // we can re-finish the transaction. - if factory.makeHasExternalPurchaseController() { - return + + let savedTransactions = storage.get(SavedTransactions.self) ?? [] + if let savedTransactions = savedTransactions.first( + where: { $0.id == skTransaction.transactionIdentifier } + ) { + // If an unfinished, purchased or restored transaction gets readded + // to the queue, we want to check how it was purchased originally and act accordingly. + if !savedTransactions.isExternal, + savedTransactions.hasExternalPurchaseController { + return + } + } else { + // Otherwise, we rely on what the current purchase coordinator says to determine whether + // the purchase was made internally or not. + // Restored transactions will be caught above so that relying on the purchase coordinator + // is fine. + let isInternal = await !coordinator.isExternal + + if isInternal, + factory.makeHasExternalPurchaseController() { + return + } } switch skTransaction.transactionState { @@ -270,12 +341,6 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { return false } - /// Sends the transaction to the backend. - private func record(_ transaction: SKPaymentTransaction) async { - let storeTransaction = await factory.makeStoreTransaction(from: transaction) - await sessionEventsManager.enqueue(storeTransaction) - } - /// Loads purchased products in the StoreKitManager if a purchase or restore has occurred. private func loadPurchasedProductsIfPossible(from transactions: [SKPaymentTransaction]) async { if transactions.first( diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseSource.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseSource.swift new file mode 100644 index 000000000..2af5c9a55 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseSource.swift @@ -0,0 +1,37 @@ +// +// PurchaseSource.swift +// SuperwallKit +// +// Created by Yusuf Tör on 01/10/2024. +// + +/// The source of the purchase initiation. +enum PurchaseSource { + /// The purchase was initiated internally by the SDK. + case `internal`(String, PaywallViewController) + + /// The purchase was initiated externally by the user calling ``Superwall/purchase(_:)-7gwwe``. + case external(StoreProduct) + + func toRestoreSource() -> RestoreSource { + switch self { + case .internal(_, let paywallViewController): return .internal(paywallViewController) + case .external: return .external + } + } + + func toGenericSource() -> GenericSource { + return self.toRestoreSource() + } +} + +/// The source of the purchase initiation. +enum RestoreSource { + /// Initiated internally by the SDK. + case `internal`(PaywallViewController) + + /// Initiated externally by the user. + case external +} + +typealias GenericSource = RestoreSource diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift index 04e62c245..34c339f40 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift @@ -12,6 +12,7 @@ import StoreKit actor PurchasingCoordinator { private var completion: ((PurchaseResult) -> Void)? var productId: String? + var isExternal = false var lastInternalTransaction: SKPaymentTransaction? var purchaseDate: Date? var transactions: [String: SKPaymentTransaction] = [:] @@ -31,9 +32,13 @@ actor PurchasingCoordinator { self.completion = completion } - func beginPurchase(of productId: String) { + func beginPurchase( + of productId: String, + isExternal: Bool + ) { self.purchaseDate = Date() self.productId = productId + self.isExternal = isExternal } /// Gets the latest transaction of a specified product ID. Used with purchases, including when a purchase has diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/SavedTransaction.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/SavedTransaction.swift new file mode 100644 index 000000000..d1f918c43 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/SavedTransaction.swift @@ -0,0 +1,44 @@ +// +// SavedTransaction.swift +// SuperwallKit +// +// Created by Yusuf Tör on 01/10/2024. +// + +import Foundation +import StoreKit + +enum SavedTransactionState: Codable { + case purchased + case restored + + static func from(_ transactionState: SKPaymentTransactionState) -> Self { + switch transactionState { + case .purchased: + return .purchased + case .restored: + return .restored + default: + return .purchased + } + } +} + +/// The purchased transaction to save to storage. +struct SavedTransaction: Codable, Hashable { + /// The transaction id. + let id: String + + /// The state of the transaction. + let state: SavedTransactionState + + /// The date the transaction was created. + let date: Date + + /// Whether or not the developer is using a purchase controller. + let hasExternalPurchaseController: Bool + + /// Indicates whether the purchase was initiated externally via the + /// developer rather than internally via the SDK. + let isExternal: Bool +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 130db59b0..61b129336 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -4,7 +4,7 @@ // // Created by Yusuf Tör on 20/10/2022. // -// swiftlint:disable type_body_length file_length line_length +// swiftlint:disable type_body_length file_length line_length function_body_length import StoreKit import UIKit @@ -22,6 +22,7 @@ final class TransactionManager { & PurchasedTransactionsFactory & StoreTransactionFactory & DeviceHelperFactory + & HasExternalPurchaseControllerFactory init( storeKitManager: StoreKitManager, @@ -45,33 +46,41 @@ final class TransactionManager { /// - productId: The ID of the product to purchase. /// - paywallViewController: The `PaywallViewController` that the product is being /// purchased from. - func purchase( - _ productId: String, - from paywallViewController: PaywallViewController - ) async { - guard let product = await storeKitManager.productsById[productId] else { - Logger.debug( - logLevel: .error, - scope: .paywallTransactions, - message: "Trying to purchase \(productId) but the product has failed to load. Visit https://superwall.com/l/missing-products to diagnose." - ) - return + @discardableResult + func purchase(_ purchaseSource: PurchaseSource) async -> PurchaseResult { + let product: StoreProduct + + switch purchaseSource { + case .internal(let productId, _): + guard let storeProduct = await storeKitManager.productsById[productId] else { + Logger.debug( + logLevel: .error, + scope: .paywallTransactions, + message: "Trying to purchase \(productId) but the product has failed to load. Visit https://superwall.com/l/missing-products to diagnose." + ) + return .failed(PurchaseError.productUnavailable) + } + product = storeProduct + case .external(let storeProduct): + product = storeProduct } + let isEligibleForFreeTrial = await receiptManager.isFreeTrialAvailable(for: product) - await prepareToStartTransaction(of: product, from: paywallViewController) + await prepareToPurchase(product: product, purchaseSource: purchaseSource) - let result = await purchase(product) + let result = await purchase(product, purchaseSource: purchaseSource) switch result { case .purchased: await didPurchase( - product, - from: paywallViewController + product: product, + purchaseSource: purchaseSource, + didStartFreeTrial: isEligibleForFreeTrial ) case .restored: await didRestore( product: product, - paywallViewController: paywallViewController + restoreSource: purchaseSource.toRestoreSource() ) case .failed(let error): let superwallOptions = factory.makeSuperwallOptions() @@ -83,108 +92,164 @@ final class TransactionManager { await trackFailure( error: error, product: product, - paywallViewController: paywallViewController + purchaseSource: purchaseSource ) - return await paywallViewController.togglePaywallSpinner(isHidden: true) + if case let .internal(_, paywallViewController) = purchaseSource { + await paywallViewController.togglePaywallSpinner(isHidden: true) + } + return result } switch outcome { case .cancelled: await trackCancelled( product: product, - from: paywallViewController + purchaseSource: purchaseSource ) case .presentAlert: await trackFailure( error: error, product: product, - paywallViewController: paywallViewController + purchaseSource: purchaseSource ) await presentAlert( - forError: error, - product: product, - paywallViewController: paywallViewController + title: "An error occurred", + message: error.safeLocalizedDescription, + source: purchaseSource.toGenericSource() ) } case .pending: - await handlePendingTransaction(from: paywallViewController) + await handlePendingTransaction(purchaseSource: purchaseSource) case .cancelled: - await trackCancelled(product: product, from: paywallViewController) + await trackCancelled(product: product, purchaseSource: purchaseSource) } + + return result } @MainActor - func tryToRestore(from paywallViewController: PaywallViewController) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Attempting Restore" - ) - - paywallViewController.loadingState = .loadingPurchase - - let trackedEvent = InternalSuperwallEvent.Restore( - state: .start, - paywallInfo: paywallViewController.info - ) - await Superwall.shared.track(trackedEvent) - paywallViewController.webView.messageHandler.handle(.restoreStart) - - let restorationResult = await purchaseController.restorePurchases() - - let hasRestored = restorationResult == .restored - let isUserSubscribed = Superwall.shared.subscriptionStatus == .active - - if hasRestored && isUserSubscribed { + @discardableResult + func tryToRestore(_ restoreSource: RestoreSource) async -> RestorationResult { + func logAndTrack( + state: InternalSuperwallEvent.Restore.State, + message: String, + paywallInfo: PaywallInfo + ) async { Logger.debug( logLevel: .debug, scope: .paywallTransactions, - message: "Transactions Restored" + message: message ) - await didRestore( - paywallViewController: paywallViewController + let trackedEvent = InternalSuperwallEvent.Restore( + state: state, + paywallInfo: paywallInfo ) + await Superwall.shared.track(trackedEvent) + } - let trackedEvent = InternalSuperwallEvent.Restore( - state: .complete, + func handleRestoreResult( + _ restorationResult: RestorationResult, + paywallInfo: PaywallInfo, + paywallViewController: PaywallViewController? + ) async -> Bool { + let hasRestored = restorationResult == .restored + let isUserSubscribed = Superwall.shared.subscriptionStatus == .active + + if hasRestored && isUserSubscribed { + await logAndTrack( + state: .complete, + message: "Transactions Restored", + paywallInfo: paywallInfo + ) + await didRestore(restoreSource: restoreSource) + return true + } else { + var message = "Transactions Failed to Restore." + if !isUserSubscribed && hasRestored { + message += " The user's subscription status is \"inactive\", but the restoration result is \"restored\". Ensure the subscription status is active before confirming successful restoration." + } + if case .failed(let error) = restorationResult, + let error = error { + message += " Original restoration error message: \(error.safeLocalizedDescription)" + } + await logAndTrack( + state: .fail(message), + message: message, + paywallInfo: paywallInfo + ) + if let paywallViewController = paywallViewController { + paywallViewController.webView.messageHandler.handle(.restoreFail(message)) + } + return false + } + } + + switch restoreSource { + case .internal(let paywallViewController): + paywallViewController.loadingState = .loadingPurchase + + await logAndTrack( + state: .start, + message: "Attempting Restore", paywallInfo: paywallViewController.info ) - await Superwall.shared.track(trackedEvent) - paywallViewController.webView.messageHandler.handle(.restoreComplete) - } else { - var message = "Transactions Failed to Restore." + paywallViewController.webView.messageHandler.handle(.restoreStart) - if !isUserSubscribed && hasRestored { - message += " The user's subscription status is \"inactive\", but the restoration result is \"restored\". Ensure the subscription status is active before confirming successful restoration." + let restorationResult = await purchaseController.restorePurchases() + let success = await handleRestoreResult( + restorationResult, + paywallInfo: paywallViewController.info, + paywallViewController: paywallViewController + ) + + if success { + paywallViewController.webView.messageHandler.handle(.restoreComplete) + } else { + paywallViewController.presentAlert( + title: Superwall.shared.options.paywalls.restoreFailed.title, + message: Superwall.shared.options.paywalls.restoreFailed.message, + closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle + ) } - if case .failed(let error) = restorationResult, - let error = error { - message += " Original restoration error message: \(error.safeLocalizedDescription)" + return restorationResult + case .external: + let hasExternalPurchaseController = factory.makeHasExternalPurchaseController() + + // If there's an external purchase controller, it means they'll be restoring inside the + // restore function. So, when that function returns, it will hit the internal case first, + // then here only to do the actual restore, before returning. + if hasExternalPurchaseController { + return await factory.restorePurchases(isExternal: true) } - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: message + await logAndTrack( + state: .start, + message: "Attempting Restore", + paywallInfo: .empty() ) - let trackedEvent = InternalSuperwallEvent.Restore( - state: .fail(message), - paywallInfo: paywallViewController.info + let restorationResult = await factory.restorePurchases(isExternal: true) + let success = await handleRestoreResult( + restorationResult, + paywallInfo: .empty(), + paywallViewController: nil ) - await Superwall.shared.track(trackedEvent) - paywallViewController.webView.messageHandler.handle(.restoreFail(message)) - paywallViewController.presentAlert( - title: Superwall.shared.options.paywalls.restoreFailed.title, - message: Superwall.shared.options.paywalls.restoreFailed.message, - closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle - ) + if !success { + await presentAlert( + title: Superwall.shared.options.paywalls.restoreFailed.title, + message: Superwall.shared.options.paywalls.restoreFailed.message, + closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle, + source: restoreSource + ) + } + + return restorationResult } } private func didRestore( product: StoreProduct? = nil, - paywallViewController: PaywallViewController + restoreSource: RestoreSource ) async { let purchasingCoordinator = factory.makePurchasingCoordinator() var transaction: StoreTransaction? @@ -202,31 +267,59 @@ final class TransactionManager { restoreType = .viaRestore } - let paywallInfo = await paywallViewController.info + switch restoreSource { + case .internal(let paywallViewController): + let paywallInfo = await paywallViewController.info - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .restore(restoreType), - paywallInfo: paywallInfo, - product: product, - model: transaction - ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionRestore) + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .restore(restoreType), + paywallInfo: paywallInfo, + product: product, + model: transaction + ) + await Superwall.shared.track(trackedEvent) + await paywallViewController.webView.messageHandler.handle(.transactionRestore) - let superwallOptions = factory.makeSuperwallOptions() - if superwallOptions.paywalls.automaticallyDismiss { - await Superwall.shared.dismiss(paywallViewController, result: .restored) + let superwallOptions = factory.makeSuperwallOptions() + if superwallOptions.paywalls.automaticallyDismiss { + await Superwall.shared.dismiss(paywallViewController, result: .restored) + } + case .external: + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .restore(restoreType), + paywallInfo: .empty(), + product: product, + model: transaction + ) + await Superwall.shared.track(trackedEvent) } } - private func purchase(_ product: StoreProduct) async -> PurchaseResult { + private func purchase( + _ product: StoreProduct, + purchaseSource: PurchaseSource + ) async -> PurchaseResult { guard let sk1Product = product.sk1Product else { return .failed(PurchaseError.productUnavailable) } - await factory.makePurchasingCoordinator().beginPurchase( - of: product.productIdentifier - ) - return await purchaseController.purchase(product: sk1Product) + + switch purchaseSource { + case .internal: + await factory.makePurchasingCoordinator().beginPurchase( + of: product.productIdentifier, + isExternal: false + ) + return await purchaseController.purchase(product: sk1Product) + case .external: + await factory.makePurchasingCoordinator().beginPurchase( + of: product.productIdentifier, + isExternal: true + ) + return await factory.purchase( + product: sk1Product, + isExternal: true + ) + } } /// Cancels the transaction timeout when the application resigns active. @@ -238,100 +331,166 @@ final class TransactionManager { private func trackFailure( error: Error, product: StoreProduct, - paywallViewController: PaywallViewController + purchaseSource: PurchaseSource ) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transaction Error", - info: [ - "product_id": product.productIdentifier, - "paywall_vc": paywallViewController - ], - error: error - ) + switch purchaseSource { + case .internal(_, let paywallViewController): + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Error", + info: [ + "product_id": product.productIdentifier, + "paywall_vc": paywallViewController + ], + error: error + ) - let paywallInfo = await paywallViewController.info - Task { - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .fail(.failure(error.safeLocalizedDescription, product)), - paywallInfo: paywallInfo, - product: product, - model: nil + let paywallInfo = await paywallViewController.info + Task { + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .fail(.failure(error.safeLocalizedDescription, product)), + paywallInfo: paywallInfo, + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) + await paywallViewController.webView.messageHandler.handle(.transactionFail) + } + case .external: + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Error", + info: [ + "product_id": product.productIdentifier + ], + error: error ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionFail) + + Task { + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .fail(.failure(error.safeLocalizedDescription, product)), + paywallInfo: .empty(), + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) + } } } /// Tracks the analytics and logs the start of the transaction. - private func prepareToStartTransaction( - of product: StoreProduct, - from paywallViewController: PaywallViewController + private func prepareToPurchase( + product: StoreProduct, + purchaseSource: PurchaseSource ) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transaction Purchasing", - info: ["paywall_vc": paywallViewController], - error: nil - ) + switch purchaseSource { + case .internal(_, let paywallViewController): + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Purchasing", + info: ["paywall_vc": paywallViewController], + error: nil + ) - let paywallInfo = await paywallViewController.info + let paywallInfo = await paywallViewController.info - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .start(product), - paywallInfo: paywallInfo, - product: product, - model: nil - ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionStart) + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .start(product), + paywallInfo: paywallInfo, + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) + await paywallViewController.webView.messageHandler.handle(.transactionStart) - await MainActor.run { - paywallViewController.loadingState = .loadingPurchase + await MainActor.run { + paywallViewController.loadingState = .loadingPurchase + } + case .external: + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "External Transaction Purchasing" + ) + + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .start(product), + paywallInfo: .empty(), + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) } } /// Dismisses the view controller, if the developer hasn't disabled the option. private func didPurchase( - _ product: StoreProduct, - from paywallViewController: PaywallViewController + product: StoreProduct, + purchaseSource: PurchaseSource, + didStartFreeTrial: Bool ) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transaction Succeeded", - info: [ - "product_id": product.productIdentifier, - "paywall_vc": paywallViewController - ], - error: nil - ) + switch purchaseSource { + case .internal(_, let paywallViewController): + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Succeeded", + info: [ + "product_id": product.productIdentifier, + "paywall_vc": paywallViewController + ], + error: nil + ) - let purchasingCoordinator = factory.makePurchasingCoordinator() - let transaction = await purchasingCoordinator.getLatestTransaction( - forProductId: product.productIdentifier, - factory: factory - ) + let purchasingCoordinator = factory.makePurchasingCoordinator() + let transaction = await purchasingCoordinator.getLatestTransaction( + forProductId: product.productIdentifier, + factory: factory + ) - if let transaction = transaction { - await self.sessionEventsManager.enqueue(transaction) - } + await receiptManager.loadPurchasedProducts() - await receiptManager.loadPurchasedProducts() + await trackTransactionDidSucceed( + transaction, + product: product, + purchaseSource: purchaseSource, + didStartFreeTrial: didStartFreeTrial + ) - await trackTransactionDidSucceed( - transaction, - product: product, - paywallViewController: paywallViewController - ) + let superwallOptions = factory.makeSuperwallOptions() + if superwallOptions.paywalls.automaticallyDismiss { + await Superwall.shared.dismiss( + paywallViewController, + result: .purchased(productId: product.productIdentifier) + ) + } + case .external: + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Succeeded", + info: [ + "product_id": product.productIdentifier + ], + error: nil + ) + + let purchasingCoordinator = factory.makePurchasingCoordinator() + let transaction = await purchasingCoordinator.getLatestTransaction( + forProductId: product.productIdentifier, + factory: factory + ) + + await receiptManager.loadPurchasedProducts() - let superwallOptions = factory.makeSuperwallOptions() - if superwallOptions.paywalls.automaticallyDismiss { - await Superwall.shared.dismiss( - paywallViewController, - result: .purchased(productId: product.productIdentifier) + await trackTransactionDidSucceed( + transaction, + product: product, + purchaseSource: purchaseSource, + didStartFreeTrial: didStartFreeTrial ) } } @@ -339,115 +498,211 @@ final class TransactionManager { /// Track the cancelled private func trackCancelled( product: StoreProduct, - from paywallViewController: PaywallViewController + purchaseSource: PurchaseSource ) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transaction Abandoned", - info: ["product_id": product.productIdentifier, "paywall_vc": paywallViewController], - error: nil - ) + switch purchaseSource { + case .internal(_, let paywallViewController): + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Abandoned", + info: ["product_id": product.productIdentifier, "paywall_vc": paywallViewController], + error: nil + ) - let paywallInfo = await paywallViewController.info - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .abandon(product), - paywallInfo: paywallInfo, - product: product, - model: nil - ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionAbandon) + let paywallInfo = await paywallViewController.info + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: paywallInfo, + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) + await paywallViewController.webView.messageHandler.handle(.transactionAbandon) + + await MainActor.run { + paywallViewController.loadingState = .ready + } + case .external: + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Abandoned", + info: ["product_id": product.productIdentifier], + error: nil + ) - await MainActor.run { - paywallViewController.loadingState = .ready + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: .empty(), + product: product, + model: nil + ) + await Superwall.shared.track(trackedEvent) } } - private func handlePendingTransaction(from paywallViewController: PaywallViewController) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transaction Pending", - info: ["paywall_vc": paywallViewController], - error: nil - ) + private func handlePendingTransaction(purchaseSource: PurchaseSource) async { + switch purchaseSource { + case .internal(_, let paywallViewController): + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Pending", + info: ["paywall_vc": paywallViewController], + error: nil + ) - let paywallInfo = await paywallViewController.info + let paywallInfo = await paywallViewController.info - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .fail(.pending("Needs parental approval")), - paywallInfo: paywallInfo, - product: nil, - model: nil - ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionFail) + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .fail(.pending("Needs parental approval")), + paywallInfo: paywallInfo, + product: nil, + model: nil + ) + await Superwall.shared.track(trackedEvent) + await paywallViewController.webView.messageHandler.handle(.transactionFail) + case .external: + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transaction Pending", + error: nil + ) + + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .fail(.pending("Needs parental approval")), + paywallInfo: .empty(), + product: nil, + model: nil + ) + await Superwall.shared.track(trackedEvent) + } - await paywallViewController.presentAlert( + await presentAlert( title: "Waiting for Approval", - message: "Thank you! This purchase is pending approval from your parent. Please try again once it is approved." + message: "Thank you! This purchase is pending approval from your parent. Please try again once it is approved.", + source: purchaseSource.toGenericSource() ) } private func presentAlert( - forError error: Error, - product: StoreProduct, - paywallViewController: PaywallViewController + title: String, + message: String, + closeActionTitle: String? = nil, + source: GenericSource ) async { - await paywallViewController.presentAlert( - title: "An error occurred", - message: error.safeLocalizedDescription - ) + switch source { + case .internal(let paywallViewController): + await paywallViewController.presentAlert( + title: title, + message: message, + closeActionTitle: closeActionTitle ?? "Done" + ) + case .external: + guard let topMostViewController = await UIViewController.topMostViewController else { + Logger.debug( + logLevel: .error, + scope: .paywallTransactions, + message: "Could not find the top-most view controller to present a transaction alert from." + ) + return + } + let alertController = await AlertControllerFactory.make( + title: title, + message: message, + closeActionTitle: closeActionTitle ?? "Done", + sourceView: topMostViewController.view + ) + await topMostViewController.present(alertController, animated: true) + } } func trackTransactionDidSucceed( _ transaction: StoreTransaction?, product: StoreProduct, - paywallViewController: PaywallViewController + purchaseSource: PurchaseSource, + didStartFreeTrial: Bool ) async { - let paywallShowingFreeTrial = await paywallViewController.paywall.isFreeTrialAvailable == true - let didStartFreeTrial = product.hasFreeTrial && paywallShowingFreeTrial - - let paywallInfo = await paywallViewController.info - - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .complete(product, transaction), - paywallInfo: paywallInfo, - product: product, - model: transaction - ) - await Superwall.shared.track(trackedEvent) - await paywallViewController.webView.messageHandler.handle(.transactionComplete) + switch purchaseSource { + case .internal(_, let paywallViewController): + let paywallShowingFreeTrial = await paywallViewController.paywall.isFreeTrialAvailable == true + let didStartFreeTrial = product.hasFreeTrial && paywallShowingFreeTrial - // Immediately flush the events queue on transaction complete. - await eventsQueue.flushInternal() + let paywallInfo = await paywallViewController.info - if product.subscriptionPeriod == nil { - let trackedEvent = InternalSuperwallEvent.NonRecurringProductPurchase( + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .complete(product, transaction), paywallInfo: paywallInfo, - product: product + product: product, + model: transaction ) await Superwall.shared.track(trackedEvent) - } else { - if didStartFreeTrial { - let trackedEvent = InternalSuperwallEvent.FreeTrialStart( + await paywallViewController.webView.messageHandler.handle(.transactionComplete) + + // Immediately flush the events queue on transaction complete. + await eventsQueue.flushInternal() + + if product.subscriptionPeriod == nil { + let trackedEvent = InternalSuperwallEvent.NonRecurringProductPurchase( paywallInfo: paywallInfo, product: product ) await Superwall.shared.track(trackedEvent) - - let notifications = paywallInfo.localNotifications.filter { - $0.type == .trialStarted + } else { + if didStartFreeTrial { + let trackedEvent = InternalSuperwallEvent.FreeTrialStart( + paywallInfo: paywallInfo, + product: product + ) + await Superwall.shared.track(trackedEvent) + + let notifications = paywallInfo.localNotifications.filter { + $0.type == .trialStarted + } + + await NotificationScheduler.scheduleNotifications(notifications, factory: factory) + } else { + let trackedEvent = InternalSuperwallEvent.SubscriptionStart( + paywallInfo: paywallInfo, + product: product + ) + await Superwall.shared.track(trackedEvent) } + } + case .external: + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .complete(product, transaction), + paywallInfo: .empty(), + product: product, + model: transaction + ) + await Superwall.shared.track(trackedEvent) - await NotificationScheduler.scheduleNotifications(notifications, factory: factory) - } else { - let trackedEvent = InternalSuperwallEvent.SubscriptionStart( - paywallInfo: paywallInfo, + // Immediately flush the events queue on transaction complete. + await eventsQueue.flushInternal() + + if product.subscriptionPeriod == nil { + let trackedEvent = InternalSuperwallEvent.NonRecurringProductPurchase( + paywallInfo: .empty(), product: product ) await Superwall.shared.track(trackedEvent) + } else { + if didStartFreeTrial { + let trackedEvent = InternalSuperwallEvent.FreeTrialStart( + paywallInfo: .empty(), + product: product + ) + await Superwall.shared.track(trackedEvent) + } else { + let trackedEvent = InternalSuperwallEvent.SubscriptionStart( + paywallInfo: .empty(), + product: product + ) + await Superwall.shared.track(trackedEvent) + } } } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 2149413a7..a8d369ea9 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -436,7 +436,7 @@ public final class Superwall: NSObject, ObservableObject { /// Note that the assignments may be different when a placement is registered due to changes /// in user, placement, or device parameters used in audience filters. /// - /// - Returns: And array of ``ConfirmedAssignment`` objects. + /// - Returns: An array of ``ConfirmedAssignment`` objects. public func confirmAllAssignments() async -> [ConfirmedAssignment] { let confirmAllAssignments = InternalSuperwallEvent.ConfirmAllAssignments() await track(confirmAllAssignments) @@ -478,7 +478,7 @@ public final class Superwall: NSObject, ObservableObject { /// Note that the assignments may be different when a placement is registered due to changes /// in user, placement, or device parameters used in audience filters. /// - /// - Returns: And array of ``ConfirmedAssignment`` objects. + /// - Parameter completion: A completion block that accepts an array of ``ConfirmedAssignment`` objects. public func confirmAllAssignments(completion: (([ConfirmedAssignment]) -> Void)? = nil) { Task { let result = await confirmAllAssignments() @@ -641,6 +641,124 @@ public final class Superwall: NSObject, ObservableObject { #endif } } + + // MARK: - External Purchasing + + /// Initiates a purchase of a `SKProduct`. + /// + /// Use this function to purchase any `SKProduct`, regardless of whether you + /// have a paywall or not. Superwall will handle the purchase with `StoreKit` + /// and return the ``PurchaseResult``. You'll see the data associated with the + /// purchase on the Superwall dashboard. + /// + /// - Parameter product: The `SKProduct` you wish to purchase. + /// - Returns: A ``PurchaseResult``. + /// - Note: You only need to finish the transaction after this if you're providing a ``PurchaseController`` + /// when configuring the SDK. Otherwise ``Superwall`` will handle this for you. + public func purchase(_ product: SKProduct) async -> PurchaseResult { + let storeProduct = StoreProduct(sk1Product: product) + return await dependencyContainer.transactionManager.purchase(.external(storeProduct)) + } + + /// Initiates a purchase of a `SKProduct`. + /// + /// Use this function to purchase any `SKProduct`, regardless of whether you + /// have a paywall or not. Superwall will handle the purchase with `StoreKit` + /// and return the ``PurchaseResult``. You'll see the data associated with the + /// purchase on the Superwall dashboard. + /// + /// - Parameters: + /// - product: The `SKProduct` you wish to purchase. + /// - completion: A completion block that is called when the purchase completes. + /// This accepts a ``PurchaseResult``. + /// - Note: You only need to finish the transaction after this if you're providing a ``PurchaseController`` + /// when configuring the SDK. Otherwise ``Superwall`` will handle this for you. + public func purchase( + _ product: SKProduct, + completion: @escaping (PurchaseResult) -> Void + ) { + Task { + let storeProduct = StoreProduct(sk1Product: product) + let result = await dependencyContainer.transactionManager.purchase(.external(storeProduct)) + await MainActor.run { + completion(result) + } + } + } + + /// Objective-C-only method. Initiates a purchase of a `SKProduct`. + /// + /// Use this function to purchase any `SKProduct`, regardless of whether you + /// have a paywall or not. Superwall will handle the purchase with `StoreKit` + /// and return the ``PurchaseResult``. You'll see the data associated with the + /// purchase on the Superwall dashboard. + /// + /// - Parameters: + /// - product: The `SKProduct` you wish to purchase. + /// - completion: A completion block that is called when the purchase completes. + /// This accepts a ``PurchaseResult``. + /// - Note: You only need to finish the transaction after this if you're providing a ``PurchaseController`` + /// when configuring the SDK. Otherwise ``Superwall`` will handle this for you. + @available(swift, obsoleted: 1.0) + public func purchase( + _ product: SKProduct, + completion: @escaping (PurchaseResultObjc) -> Void + ) { + purchase(product) { result in + let objcResult = result.toObjc() + completion(objcResult) + } + } + + /// Restores purchases. + /// + /// - Note: This could prompt the user to log in to their App Store account, so should only be performed + /// on request of the user. Typically with a button in settings or near your purchase UI. + /// - Returns: A ``RestorationResult`` object that defines if the restoration was successful or not. + /// - Warning: A successful restoration does not mean that the user is subscribed, only that + /// the restore did not fail due to some error. If you aren't using a ``PurchaseController``, the user will + /// see an alert if ``Superwall/subscriptionStatus`` is not ``SubscriptionStatus/active`` + /// after returning this value. + public func restorePurchases() async -> RestorationResult { + let result = await dependencyContainer.transactionManager.tryToRestore(.external) + return result + } + + /// Restores purchases. + /// + /// - Note: This could prompt the user to log in to their App Store account, so should only be performed + /// on request of the user. Typically with a button in settings or near your purchase UI. + /// - Parameter completion: A completion block that is called when the restoration completes. + /// This accepts a ``RestorationResult``. + /// - Warning: A successful restoration does not mean that the user is subscribed, only that + /// the restore did not fail due to some error. If you aren't using a ``PurchaseController``, the user will + /// see an alert if ``Superwall/subscriptionStatus`` is not ``SubscriptionStatus/active`` + /// after returning this value. + public func restorePurchases(completion: @escaping (RestorationResult) -> Void) { + Task { + let result = await restorePurchases() + await MainActor.run { + completion(result) + } + } + } + + /// Objective-C-only method. Restores purchases. + /// + /// - Note: This could prompt the user to log in to their App Store account, so should only be performed + /// on request of the user. Typically with a button in settings or near your purchase UI. + /// - Parameter completion: A completion block that is called when the restoration completes. + /// This accepts a ``RestorationResultObjc``. + /// - Warning: A successful restoration does not mean that the user is subscribed, only that + /// the restore did not fail due to some error. If you aren't using a ``PurchaseController``, the user will + /// see an alert if ``Superwall/subscriptionStatus`` is not ``SubscriptionStatus/active`` + /// after returning this value. + @available(swift, obsoleted: 1.0) + public func restorePurchases(completion: @escaping (RestorationResultObjc) -> Void) { + restorePurchases { result in + completion(result.toObjc()) + } + } } // MARK: - PaywallViewControllerDelegate @@ -670,14 +788,13 @@ extension Superwall: PaywallViewControllerEventDelegate { } purchaseTask = Task { await dependencyContainer.transactionManager.purchase( - productId, - from: paywallViewController + .internal(productId, paywallViewController) ) purchaseTask = nil } await purchaseTask?.value case .initiateRestore: - await dependencyContainer.transactionManager.tryToRestore(from: paywallViewController) + await dependencyContainer.transactionManager.tryToRestore(.internal(paywallViewController)) case .openedURL(let url): dependencyContainer.delegateAdapter.paywallWillOpenURL(url: url) case .openedUrlInSafari(let url): diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index c9b3dcf7e..bfd27a9a3 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "3.9.1" + s.version = "3.10.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift index 531dfbf1e..c12cdd080 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift @@ -18,11 +18,10 @@ final class ConfigManagerTests: XCTestCase { .setting(\.buildId, to: "123") network.configReturnValue = .success(newConfig) - let storage = StorageMock() let configManager = ConfigManager( options: SuperwallOptions(), storeKitManager: dependencyContainer.storeKitManager, - storage: storage, + storage: dependencyContainer.storage, network: network, paywallManager: dependencyContainer.paywallManager, deviceHelper: dependencyContainer.deviceHelper,