diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8f784db80..cd60b118e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,13 @@ ## Changes in this pull request -Issue fixed: # +- ### Checklist -- [ ] All unit and UI tests pass. Demo project builds and runs. -- [ ] I added tests, an experiment, or detailed why my change isn't tested. +- [ ] All unit tests pass. +- [ ] All UI tests pass. +- [ ] Demo project builds and runs. +- [ ] I added/updated tests or detailed why my change isn't tested. - [ ] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes. - [ ] I have run `swiftlint` in the main directory and fixed any issues. - [ ] I have updated the SDK documentation as well as the online docs. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed46eee6f..e4dfd4ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall-me/Superwall-iOS/releases) on GitHub. +## 3.4.0 + +### Enhancements + +- Adds `sdk_version`, `sdk_version_padded`, `app_build_string`, and `app_build_string_number` to the device object for use in rules. `sdk_version` is the version of the sdk, e.g. `3.4.0`. `sdk_version_padded` is the sdk version padded with zeros for use with string comparison. For example `003.004.000`. `app_build_string` is the build of your app and `app_build_string_number` is the build of your app casted as an Int. +- When you experience `no_rule_match`, the `TriggerFire` event params will specify which part of the rules didn't match in the format `"unmatched_rule_": ""`. Where `outcome` will either be `OCCURRENCE`, referring to the limit applied to a rule, or `EXPRESSION`. The `id` is the experiment id. +- Adds a `touches_began` implicit trigger. By adding the `touches_began` event to a campaign, you can show a paywall the first time a user touches anywhere in your app. +- Adds the ability to include a close button on a survey. +- If running in sandbox, the duration of a free trial notification added to a paywall will be converted from days to minutes for testing purposes. +- Adds the ability to show a survey after purchasing a product. + +### Fixes + +- Fixes issue where a survey attached to a paywall wouldn't show if you were also using the `paywall_decline` trigger. +- Fixes issue where verification was happening after the finishing of transactions when not using a `PurchaseController`. +- Fixes issue where the retrieved `StoreTransaction` associated with the purchased product may be `nil`. +- Fixes issue where a `presentationRequest` wasn't being tracked for implicit triggers like `session_start` when there was no internet. + ## 3.3.2 ### Fixes @@ -27,7 +45,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds the ability to add a paywall exit survey. Surveys are configured via the dashboard and added to paywalls. When added to a paywall, it will attempt to display when the user taps the close button. If the paywall has the `modalPresentationStyle` of `pageSheet`, `formSheet`, or `popover`, the survey will also attempt to display when the user tries to drag to dismiss the paywall. The probability of the survey showing is determined by the survey's configuration in the dashboard. A user will only ever see the survey once unless you reset responses via the dashboard. The survey will always show on exit of the paywall in the debugger. -- Adds the ability to add `survey_close` as a trigger and use the selected option title in rules. +- Adds the ability to add `survey_response` as a trigger and use the selected option title in rules. - Adds new `PaywallCloseReason` `.manualClose`. ### Fixes diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 265709de9..9653aebe1 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -35,16 +35,28 @@ enum InternalSuperwallEvent { struct AppInstall: TrackableSuperwallEvent { let superwallEvent: SuperwallEvent = .appInstall let appInstalledAtString: String - let hasPurchaseController: Bool + let hasExternalPurchaseController: Bool var customParameters: [String: Any] = [:] func getSuperwallParameters() async -> [String: Any] { return [ "application_installed_at": appInstalledAtString, - "using_purchase_controller": hasPurchaseController + "using_purchase_controller": hasExternalPurchaseController ] } } + struct TouchesBegan: TrackableSuperwallEvent { + let superwallEvent: SuperwallEvent = .touchesBegan + var customParameters: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { [:] } + } + + struct SurveyClose: TrackableSuperwallEvent { + let superwallEvent: SuperwallEvent = .surveyClose + var customParameters: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { [:] } + } + struct SurveyResponse: TrackableSuperwallEvent { var superwallEvent: SuperwallEvent { return .surveyResponse( @@ -233,11 +245,11 @@ enum InternalSuperwallEvent { } struct TriggerFire: TrackableSuperwallEvent { - let triggerResult: TriggerResult + let triggerResult: InternalTriggerResult var superwallEvent: SuperwallEvent { return .triggerFire( eventName: triggerName, - result: triggerResult + result: triggerResult.toPublicType() ) } let triggerName: String @@ -254,10 +266,14 @@ enum InternalSuperwallEvent { } switch triggerResult { - case .noRuleMatch: - return params + [ + case .noRuleMatch(let unmatchedRules): + params += [ "result": "no_rule_match" ] + for unmatchedRule in unmatchedRules { + params["unmatched_rule_\(unmatchedRule.experimentId)"] = unmatchedRule.source.rawValue + } + return params case .holdout(let experiment): return params + [ "variant_id": experiment.variant.id as Any, @@ -348,7 +364,7 @@ enum InternalSuperwallEvent { func getSuperwallParameters() async -> [String: Any] { var params: [String: Any] = [ - "survey_attached": paywallInfo.survey == nil ? false : true + "survey_attached": paywallInfo.surveys.isEmpty ? false : true ] if surveyPresentationResult != .noShow { diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 3a4df8519..6240f33b4 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -6,7 +6,7 @@ // import Foundation -import StoreKit +import Combine extension Superwall { /// Tracks an analytical event by sending it to the server and, for internal Superwall events, the delegate. @@ -75,66 +75,45 @@ extension Superwall { forEvent event: Trackable, withData eventData: EventData ) async { + let presentationInfo: PresentationInfo = .implicitTrigger(eventData) + + var request = dependencyContainer.makePresentationRequest( + presentationInfo, + isPaywallPresented: isPaywallPresented, + type: .presentation + ) + do { - try await dependencyContainer.configManager.configState - .compactMap { $0.getConfig() } - .throwableAsync() + try await waitForSubsStatusAndConfig(request, paywallStatePublisher: nil) } catch { - return + return logErrors(from: request, error) } - let presentationInfo: PresentationInfo = .implicitTrigger(eventData) - let outcome = TrackingLogic.canTriggerPaywall( event, triggers: Set(dependencyContainer.configManager.triggersByEventName.keys), paywallViewController: paywallViewController ) + var statePublisher = PassthroughSubject() + switch outcome { case .deepLinkTrigger: - if isPaywallPresented { - await dismiss() - } - let presentationRequest = dependencyContainer.makePresentationRequest( - presentationInfo, - isPaywallPresented: isPaywallPresented, - type: .presentation - ) - _ = try? await internallyPresent(presentationRequest).throwableAsync() + await dismiss() case .closePaywallThenTriggerPaywall: guard let lastPresentationItems = presentationItems.last else { return } - if isPaywallPresented { - await dismissForNextPaywall() - } - let presentationRequest = dependencyContainer.makePresentationRequest( - presentationInfo, - isPaywallPresented: isPaywallPresented, - type: .presentation - ) - _ = try? await internallyPresent( - presentationRequest, - lastPresentationItems.statePublisher - ).throwableAsync() + await dismissForNextPaywall() + statePublisher = lastPresentationItems.statePublisher case .triggerPaywall: - let presentationRequest = dependencyContainer.makePresentationRequest( - presentationInfo, - isPaywallPresented: isPaywallPresented, - type: .presentation - ) - _ = try? await internallyPresent(presentationRequest).throwableAsync() - case .disallowedEventAsTrigger: - Logger.debug( - logLevel: .warn, - scope: .superwallCore, - message: "Event Used as Trigger", - info: ["message": "You can't use events as triggers"], - error: nil - ) + break case .dontTriggerPaywall: return } + + request.flags.isPaywallPresented = isPaywallPresented + + internallyPresent(request, statePublisher) } } diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/TrackingLogic.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/TrackingLogic.swift index 893379ba0..d2b69fc02 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/TrackingLogic.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/TrackingLogic.swift @@ -12,7 +12,6 @@ enum TrackingLogic { enum ImplicitTriggerOutcome { case triggerPaywall case deepLinkTrigger - case disallowedEventAsTrigger case dontTriggerPaywall case closePaywallThenTriggerPaywall } diff --git a/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEvent.swift index 8c3a75d96..c80333cda 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEvent.swift @@ -138,6 +138,14 @@ public enum SuperwallEvent { reason: PaywallPresentationRequestStatusReason? ) + /// When the first touch was detected on the UIWindow of the app. + /// + /// This is only registered if there's an active `touches_began` trigger on your dashboard. + case touchesBegan + + /// When the user chose the close button on a survey instead of responding. + case surveyClose + var canImplicitlyTriggerPaywall: Bool { switch self { case .appInstall, @@ -147,7 +155,8 @@ public enum SuperwallEvent { .transactionFail, .paywallDecline, .transactionAbandon, - .surveyResponse: + .surveyResponse, + .touchesBegan: return true default: return false @@ -247,6 +256,10 @@ extension SuperwallEvent { return .init(objcEvent: .paywallPresentationRequest) case .surveyResponse: return .init(objcEvent: .surveyResponse) + case .touchesBegan: + return .init(objcEvent: .touchesBegan) + case .surveyClose: + return .init(objcEvent: .surveyClose) } } } diff --git a/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEventObjc.swift index a2dd5f2d8..ace89741d 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Event/SuperwallEventObjc.swift @@ -125,6 +125,14 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When the response to a paywall survey as been recorded. case surveyResponse + /// When the user touches the app's UIWindow for the first time. + /// + /// This is only tracked if there is an active `touches_began` trigger in a campaign. + case touchesBegan + + /// When the user taps the close button to skip the survey without recording a response. + case surveyClose + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } @@ -201,6 +209,10 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "paywallPresentationRequest" case .surveyResponse: return "survey_response" + case .touchesBegan: + return "touches_began" + case .surveyClose: + return "survey_close" } } } diff --git a/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift b/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift index 5fbab3936..08f7bd9c0 100644 --- a/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/Trigger Session Manager/TriggerSessionManager.swift @@ -136,7 +136,7 @@ actor TriggerSessionManager { for presentationInfo: PresentationInfo, on presentingViewController: UIViewController? = nil, paywall: Paywall? = nil, - triggerResult: TriggerResult?, + triggerResult: InternalTriggerResult?, trackEvent: (Trackable) async -> TrackingResult = Superwall.shared.track ) async { guard let eventName = presentationInfo.eventName else { @@ -152,7 +152,7 @@ actor TriggerSessionManager { presentationInfo: presentationInfo, presentingViewController: presentingViewController, paywall: paywall, - triggerResult: triggerResult + triggerResult: triggerResult?.toPublicType() ) else { return } diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index f68577320..84410c7f1 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -76,6 +76,7 @@ class ConfigManager { triggersByEventName = ConfigLogic.getTriggersByEventName(from: config.triggers) choosePaywallVariants(from: config.triggers) + await checkForTouchesBeganTrigger(in: config.triggers) configState.send(.retrieved(config)) Task { await preloadPaywalls() } @@ -101,6 +102,14 @@ class ConfigManager { Task { await preloadPaywalls() } } + /// Swizzles the UIWindow's `sendEvent` to intercept the first `began` touch event if + /// config's triggers contain `touches_began`. + private func checkForTouchesBeganTrigger(in triggers: Set) async { + if triggers.contains(where: { $0.eventName == SuperwallEvent.touchesBegan.description }) { + await UIWindow.swizzleSendEvent() + } + } + // MARK: - Assignments private func choosePaywallVariants(from triggers: Set) { diff --git a/Sources/SuperwallKit/Config/Models/Survey.swift b/Sources/SuperwallKit/Config/Models/Survey.swift index 1027b726f..08016897e 100644 --- a/Sources/SuperwallKit/Config/Models/Survey.swift +++ b/Sources/SuperwallKit/Config/Models/Survey.swift @@ -7,6 +7,7 @@ import Foundation +/// A survey attached to a paywall. @objc(SWKSurvey) @objcMembers final public class Survey: NSObject, Decodable { @@ -27,6 +28,9 @@ final public class Survey: NSObject, Decodable { /// The options to display in the alert controller. public let options: [SurveyOption] + /// An enum whose cases indicate when the survey should show. + public internal(set) var presentationCondition: SurveyShowCondition + /// The probability that the survey will present to the user. public internal(set) var presentationProbability: Double @@ -34,6 +38,9 @@ final public class Survey: NSObject, Decodable { /// response. public let includeOtherOption: Bool + /// Whether a close button should appear to allow users to skip the survey. + public let includeCloseOption: Bool + /// Rolls dice to see if survey should present or is in holdout. /// /// - Returns: `true` if user is in holdout, false if survey should present. @@ -83,7 +90,9 @@ final public class Survey: NSObject, Decodable { message: String, options: [SurveyOption], presentationProbability: Double, - includeOtherOption: Bool + includeOtherOption: Bool, + includeCloseOption: Bool, + presentationCondition: SurveyShowCondition ) { self.id = id self.assignmentKey = assignmentKey @@ -92,6 +101,8 @@ final public class Survey: NSObject, Decodable { self.options = options self.presentationProbability = presentationProbability self.includeOtherOption = includeOtherOption + self.includeCloseOption = includeCloseOption + self.presentationCondition = presentationCondition } } @@ -105,7 +116,9 @@ extension Survey: Stubbable { message: "test", options: [.stub()], presentationProbability: 1, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) } } diff --git a/Sources/SuperwallKit/Config/Models/SurveyOption.swift b/Sources/SuperwallKit/Config/Models/SurveyOption.swift index f9b7f5cc9..369d38c60 100644 --- a/Sources/SuperwallKit/Config/Models/SurveyOption.swift +++ b/Sources/SuperwallKit/Config/Models/SurveyOption.swift @@ -7,6 +7,7 @@ import Foundation +/// An option to display in a paywall survey. @objc(SWKSurveyOption) @objcMembers final public class SurveyOption: NSObject, Decodable { diff --git a/Sources/SuperwallKit/Config/Models/SurveyShowCondition.swift b/Sources/SuperwallKit/Config/Models/SurveyShowCondition.swift new file mode 100644 index 000000000..8e93cd501 --- /dev/null +++ b/Sources/SuperwallKit/Config/Models/SurveyShowCondition.swift @@ -0,0 +1,44 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 06/09/2023. +// + +import Foundation + +/// An enum whose cases indicate when a survey should +/// show. +@objc(SWKSurveyShowCondition) +public enum SurveyShowCondition: Int, Decodable { + /// Shows the survey when the user manually closes the paywall. + case onManualClose + + /// Shows the survey after the user purchases. + case onPurchase + + enum CodingKeys: String, CodingKey { + case onManualClose = "ON_MANUAL_CLOSE" + case onPurchase = "ON_PURCHASE" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + let reason = CodingKeys(rawValue: rawValue) + switch reason { + case .onManualClose: + self = .onManualClose + case .onPurchase: + self = .onPurchase + case .none: + throw DecodingError.valueNotFound( + String.self, + .init( + codingPath: [], + debugDescription: "Unsupported survey condition." + ) + ) + } + } +} diff --git a/Sources/SuperwallKit/Delegate/SuperwallDelegateAdapter.swift b/Sources/SuperwallKit/Delegate/SuperwallDelegateAdapter.swift index 11610680b..e1189e907 100644 --- a/Sources/SuperwallKit/Delegate/SuperwallDelegateAdapter.swift +++ b/Sources/SuperwallKit/Delegate/SuperwallDelegateAdapter.swift @@ -7,27 +7,12 @@ import Foundation import Combine +import StoreKit /// An adapter between the internal SDK and the public swift/objective c delegate. final class SuperwallDelegateAdapter { - var hasPurchaseController: Bool { - return swiftPurchaseController != nil || objcPurchaseController != nil - } - var swiftDelegate: SuperwallDelegate? var objcDelegate: SuperwallDelegateObjc? - var swiftPurchaseController: PurchaseController? - var objcPurchaseController: PurchaseControllerObjc? - - /// Called on init of the Superwall instance via - /// ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke``. - init( - swiftPurchaseController: PurchaseController?, - objcPurchaseController: PurchaseControllerObjc? - ) { - self.swiftPurchaseController = swiftPurchaseController - self.objcPurchaseController = objcPurchaseController - } @MainActor func handleCustomPaywallAction(withName name: String) { @@ -136,64 +121,3 @@ final class SuperwallDelegateAdapter { } } } - -// MARK: - Product Purchaser -extension SuperwallDelegateAdapter: ProductPurchaser { - @MainActor - func purchase( - product: StoreProduct - ) async -> PurchaseResult { - if let purchaseController = swiftPurchaseController { - guard let sk1Product = product.sk1Product else { - return .failed(PurchaseError.productUnavailable) - } - return await purchaseController.purchase(product: sk1Product) - } else if let purchaseController = objcPurchaseController { - guard let sk1Product = product.sk1Product else { - return .failed(PurchaseError.productUnavailable) - } - return await withCheckedContinuation { continuation in - purchaseController.purchase(product: sk1Product) { result, error in - if let error = error { - continuation.resume(returning: .failed(error)) - } else { - switch result { - case .purchased: - continuation.resume(returning: .purchased) - case .pending: - continuation.resume(returning: .pending) - case .cancelled: - continuation.resume(returning: .cancelled) - case .failed: - break - } - } - } - } - } - return .cancelled - } -} - -// MARK: - TransactionRestorer -extension SuperwallDelegateAdapter: TransactionRestorer { - @MainActor - func restorePurchases() async -> RestorationResult { - var result: RestorationResult = .failed(nil) - if let purchaseController = swiftPurchaseController { - result = await purchaseController.restorePurchases() - } else if let purchaseController = objcPurchaseController { - result = await withCheckedContinuation { continuation in - purchaseController.restorePurchases { result, error in - switch result { - case .restored: - continuation.resume(returning: .restored) - case .failed: - continuation.resume(returning: .failed(error)) - } - } - } - } - return result - } -} diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index d6d819223..08886e25a 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -9,6 +9,7 @@ import UIKit import Combine import SystemConfiguration +import StoreKit /// Contains all of the SDK's core utility objects that are normally directly injected as dependencies. /// @@ -41,11 +42,13 @@ final class DependencyContainer { objcPurchaseController: PurchaseControllerObjc? = nil, options: SuperwallOptions? = nil ) { - storeKitManager = StoreKitManager(factory: self) - delegateAdapter = SuperwallDelegateAdapter( + let purchaseController = InternalPurchaseController( + factory: self, swiftPurchaseController: swiftPurchaseController, objcPurchaseController: objcPurchaseController ) + storeKitManager = StoreKitManager(purchaseController: purchaseController) + delegateAdapter = SuperwallDelegateAdapter() storage = Storage(factory: self) network = Network(factory: self) @@ -81,12 +84,6 @@ final class DependencyContainer { configManager: configManager ) - appSessionManager = AppSessionManager( - configManager: configManager, - storage: storage, - delegate: self - ) - identityManager = IdentityManager( deviceHelper: deviceHelper, storage: storage, @@ -105,6 +102,13 @@ final class DependencyContainer { factory: self ) + // Must be after session events + appSessionManager = AppSessionManager( + configManager: configManager, + storage: storage, + delegate: self + ) + debugManager = DebugManager( storage: storage, factory: self @@ -115,6 +119,9 @@ final class DependencyContainer { sessionEventsManager: sessionEventsManager, factory: self ) + + // Initialise the product purchaser so that it can immediately start listening to transactions. + _ = storeKitManager.purchaseController.productPurchaser } } @@ -376,17 +383,6 @@ extension DependencyContainer: ConfigManagerFactory { } } -// MARK: - StoreKitCoordinatorFactory -extension DependencyContainer: StoreKitCoordinatorFactory { - func makeStoreKitCoordinator() -> StoreKitCoordinator { - return StoreKitCoordinator( - delegateAdapter: delegateAdapter, - storeKitManager: storeKitManager, - factory: self - ) - } -} - // MARK: - StoreTransactionFactory extension DependencyContainer: StoreTransactionFactory { func makeStoreTransaction(from transaction: SK1Transaction) async -> StoreTransaction { @@ -417,22 +413,11 @@ extension DependencyContainer: ProductPurchaserFactory { return ProductPurchaserSK1( storeKitManager: storeKitManager, sessionEventsManager: sessionEventsManager, - delegateAdapter: delegateAdapter, factory: self ) } } -// MARK: - Purchase Manager Factory -extension DependencyContainer: PurchaseManagerFactory { - func makePurchaseManager() -> PurchaseManager { - return PurchaseManager( - storeKitManager: storeKitManager, - hasPurchaseController: delegateAdapter.hasPurchaseController - ) - } -} - // MARK: - Options Factory extension DependencyContainer: OptionsFactory { func makeSuperwallOptions() -> SuperwallOptions { @@ -448,9 +433,9 @@ extension DependencyContainer: TriggerFactory { } // MARK: - Purchase Controller Factory -extension DependencyContainer: HasPurchaseControllerFactory { - func makeHasPurchaseController() -> Bool { - return delegateAdapter.hasPurchaseController +extension DependencyContainer: HasExternalPurchaseControllerFactory { + func makeHasExternalPurchaseController() -> Bool { + return storeKitManager.purchaseController.hasExternalPurchaseController } } @@ -467,3 +452,10 @@ extension DependencyContainer: ComputedPropertyRequestsFactory { return configManager.config?.allComputedProperties ?? [] } } + +// MARK: - Purchased Transactions Factory +extension DependencyContainer: PurchasedTransactionsFactory { + func makePurchasingCoordinator() -> PurchasingCoordinator { + return storeKitManager.purchaseController.productPurchaser.coordinator + } +} diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 42aa65395..9da76b9d7 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -8,6 +8,7 @@ import UIKit import Combine import SystemConfiguration +import StoreKit protocol ViewControllerFactory: AnyObject { @MainActor @@ -80,10 +81,6 @@ protocol ConfigManagerFactory: AnyObject { ) -> Paywall? } -protocol StoreKitCoordinatorFactory: AnyObject { - func makeStoreKitCoordinator() -> StoreKitCoordinator -} - protocol IdentityInfoFactory: AnyObject { func makeIdentityInfo() async -> IdentityInfo } @@ -97,8 +94,8 @@ protocol DeviceHelperFactory: AnyObject { func makeIsSandbox() -> Bool } -protocol HasPurchaseControllerFactory: AnyObject { - func makeHasPurchaseController() -> Bool +protocol HasExternalPurchaseControllerFactory: AnyObject { + func makeHasExternalPurchaseController() -> Bool } protocol ApiFactory: AnyObject { @@ -129,10 +126,6 @@ protocol StoreTransactionFactory: AnyObject { func makeStoreTransaction(from transaction: SK2Transaction) async -> StoreTransaction } -protocol PurchaseManagerFactory: AnyObject { - func makePurchaseManager() -> PurchaseManager -} - protocol OptionsFactory: AnyObject { func makeSuperwallOptions() -> SuperwallOptions } @@ -140,3 +133,7 @@ protocol OptionsFactory: AnyObject { protocol TriggerFactory: AnyObject { func makeTriggers() -> Set } + +protocol PurchasedTransactionsFactory { + func makePurchasingCoordinator() -> PurchasingCoordinator +} diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 64e350e39..7128ae2ad 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -3.3.2 +3.4.0 """ diff --git a/Sources/SuperwallKit/Misc/Extensions/Date/Date+WithinAnHourBefore.swift b/Sources/SuperwallKit/Misc/Extensions/Date/Date+WithinAnHourBefore.swift new file mode 100644 index 000000000..8f83d7fcf --- /dev/null +++ b/Sources/SuperwallKit/Misc/Extensions/Date/Date+WithinAnHourBefore.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 19/09/2023. +// + +import Foundation + +extension Date { + func isWithinAnHourBefore(_ date: Date) -> Bool { + let oneHourBefore = date.addingTimeInterval(-3600) + return compare(oneHourBefore) == .orderedDescending + } +} diff --git a/Sources/SuperwallKit/Misc/Extensions/Transaction+LatestSince.swift b/Sources/SuperwallKit/Misc/Extensions/Transaction+LatestSince.swift new file mode 100644 index 000000000..33f7f536d --- /dev/null +++ b/Sources/SuperwallKit/Misc/Extensions/Transaction+LatestSince.swift @@ -0,0 +1,27 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 18/09/2023. +// + +import StoreKit + +@available(iOS 15.0, *) +extension Transaction { + /// Gets the latest transaction for a given `productId` since + /// an hour up to a given `purchaseDate`. + static func latest( + for productId: String, + since purchaseDate: Date + ) async -> VerificationResult? { + let verificationResult = await Transaction.latest(for: productId) + + if let transaction = verificationResult.map({ $0.unsafePayloadValue }), + transaction.purchaseDate.isWithinAnHourBefore(purchaseDate) { + return verificationResult + } + + return nil + } +} diff --git a/Sources/SuperwallKit/Misc/Extensions/UIWindow+Landscape.swift b/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift similarity index 100% rename from Sources/SuperwallKit/Misc/Extensions/UIWindow+Landscape.swift rename to Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+Landscape.swift diff --git a/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+SwizzleSendEvent.swift b/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+SwizzleSendEvent.swift new file mode 100644 index 000000000..a1fba80bf --- /dev/null +++ b/Sources/SuperwallKit/Misc/Extensions/UIWindow/UIWindow+SwizzleSendEvent.swift @@ -0,0 +1,56 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 01/09/2023. +// + +import UIKit + +extension UIWindow { + /// Does a switcharoo with the UIWindow's `sendEvent` method and our own method so that + /// we can intercept the first `began` touch event. + static func swizzleSendEvent() { + let originalSelector = #selector(UIWindow.sendEvent(_:)) + let swizzledSelector = #selector(swizzledSendEvent(_:)) + + guard + let originalMethod = class_getInstanceMethod(UIWindow.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(UIWindow.self, swizzledSelector) + else { + return + } + + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + /// Tracks a `.touchesBegan` event for the first `began` touch event received on the `UIWindow`. + @objc private func swizzledSendEvent(_ event: UIEvent) { + if event.type == .touches { + // Check for a began touch event. + guard + let allTouches = event.allTouches, + !allTouches.filter({ $0.phase == .began }).isEmpty + else { + // If there aren't any touches or there isn't a touches began event, + // forward touch to original `sendEvent` function. + swizzledSendEvent(event) + return + } + Task { + let event = InternalSuperwallEvent.TouchesBegan() + await Superwall.shared.track(event) + } + + // Call the original implementation of sendEvent after tracking touchesBegan. + swizzledSendEvent(event) + + // Then reverse the swizzle because we're only interested in the first began touch event. + Self.swizzleSendEvent() + } else { + // Call the original implementation of sendEvent if the event we + // receive isn't a touch. + swizzledSendEvent(event) + } + } +} diff --git a/Sources/SuperwallKit/Models/Paywall/LocalNotification.swift b/Sources/SuperwallKit/Models/Paywall/LocalNotification.swift index 2a5c34cd6..bf43a5a01 100644 --- a/Sources/SuperwallKit/Models/Paywall/LocalNotification.swift +++ b/Sources/SuperwallKit/Models/Paywall/LocalNotification.swift @@ -23,8 +23,36 @@ public final class LocalNotification: NSObject, Decodable { /// The body text of the notification. public let body: String - /// The delay to the notification in minutes. + /// The delay to the notification in milliseconds. public let delay: Milliseconds + + init( + type: LocalNotificationType, + title: String, + subtitle: String?, + body: String, + delay: Milliseconds + ) { + self.type = type + self.title = title + self.subtitle = subtitle + self.body = body + self.delay = delay + } +} + +// MARK: - Stubbable +extension LocalNotification: Stubbable { + static func stub() -> LocalNotification { + let oneDay: Milliseconds = 86400000 + return .init( + type: .trialStarted, + title: "title", + subtitle: "subtitle", + body: "body", + delay: oneDay + ) + } } /// The type of notification. diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index f22db89f7..05c4afbf5 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -49,8 +49,8 @@ struct Paywall: Decodable { /// Indicates whether the caching of the paywall is enabled or not. let onDeviceCache: OnDeviceCaching - /// A survey to potentially show on close of the paywall. - var survey: Survey? + /// A surveys to potentially show when an action happens in the paywall. + var surveys: [Survey] /// The products associated with the paywall. var products: [Product] { @@ -119,7 +119,7 @@ struct Paywall: Decodable { case onDeviceCache case localNotifications case computedPropertyRequests = "computedProperties" - case survey + case surveys case responseLoadStartTime case responseLoadCompleteTime @@ -141,7 +141,12 @@ struct Paywall: Decodable { name = try values.decode(String.self, forKey: .name) url = try values.decode(URL.self, forKey: .url) htmlSubstitutions = try values.decode(String.self, forKey: .htmlSubstitutions) - survey = try values.decodeIfPresent(Survey.self, forKey: .survey) + + let throwableSurveys = try values.decodeIfPresent( + [Throwable].self, + forKey: .surveys + ) ?? [] + surveys = throwableSurveys.compactMap { try? $0.result.get() } let presentationStyle = try values.decode(PaywallPresentationStyle.self, forKey: .presentationStyle) let presentationCondition = try values.decode(PresentationCondition.self, forKey: .presentationCondition) @@ -227,7 +232,7 @@ struct Paywall: Decodable { onDeviceCache: OnDeviceCaching = .disabled, localNotifications: [LocalNotification] = [], computedPropertyRequests: [ComputedPropertyRequest] = [], - survey: Survey? = nil + surveys: [Survey] = [] ) { self.databaseId = databaseId self.identifier = identifier @@ -253,7 +258,7 @@ struct Paywall: Decodable { self.onDeviceCache = onDeviceCache self.localNotifications = localNotifications self.computedPropertyRequests = computedPropertyRequests - self.survey = survey + self.surveys = surveys } func getInfo( @@ -285,7 +290,7 @@ struct Paywall: Decodable { closeReason: closeReason, localNotifications: localNotifications, computedPropertyRequests: computedPropertyRequests, - survey: survey + surveys: surveys ) } diff --git a/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift b/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift index a3f3204aa..41c692370 100644 --- a/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift +++ b/Sources/SuperwallKit/Models/Triggers/RawExperiment.swift @@ -9,8 +9,13 @@ import Foundation /// An experiment without a confirmed variant assignment. struct RawExperiment: Decodable, Hashable { + /// The ID of the experiment var id: String + + /// The campaign ID. var groupId: String + + /// The variants associated with the experiment. var variants: [VariantOption] } diff --git a/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift b/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift index a7b4a1d70..5e80cb4fd 100644 --- a/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift +++ b/Sources/SuperwallKit/Models/Triggers/TriggerResult.swift @@ -39,3 +39,51 @@ public enum TriggerResult: Sendable, Equatable { /// In these instances, consider falling back to a native paywall. case error(NSError) } + +/// The result of a paywall trigger. `noRuleMatch` is an associated enum. +/// +/// Triggers can conditionally show paywalls. Contains the possible cases resulting from the trigger. +enum InternalTriggerResult: Equatable { + /// This event was not found on the dashboard. + /// + /// Please make sure you have added the event to a campaign on the dashboard and + /// double check its spelling. + case eventNotFound + + /// No matching rule was found for this trigger so no paywall will be shown. + case noRuleMatch([UnmatchedRule]) + + /// A matching rule was found and this user will be shown a paywall. + /// + /// - Parameters: + /// - experiment: The experiment associated with the trigger. + case paywall(Experiment) + + /// A matching rule was found and this user was assigned to a holdout group so will not be shown a paywall. + /// + /// - Parameters: + /// - experiment: The experiment associated with the trigger. + case holdout(Experiment) + + /// An error occurred and the user will not be shown a paywall. + /// + /// If the error code is `101`, it means that no view controller could be found to present on. Otherwise a network failure may have occurred. + /// + /// In these instances, consider falling back to a native paywall. + case error(NSError) + + func toPublicType() -> TriggerResult { + switch self { + case .eventNotFound: + return .eventNotFound + case .noRuleMatch: + return .noRuleMatch + case .paywall(let experiment): + return .paywall(experiment) + case .holdout(let experiment): + return .holdout(experiment) + case .error(let nSError): + return .error(nSError) + } + } +} diff --git a/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift b/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift index 6d6ec543a..e6f2a8660 100644 --- a/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift +++ b/Sources/SuperwallKit/Models/Triggers/TriggerRule.swift @@ -7,11 +7,52 @@ import Foundation -struct TriggerRuleOutcome { +struct UnmatchedRule: Equatable { + enum Source: String { + case expression = "EXPRESSION" + case occurrence = "OCCURRENCE" + } + let source: Source + let experimentId: String +} + +struct MatchedItem { let rule: TriggerRule let unsavedOccurrence: TriggerRuleOccurrence? } +enum TriggerRuleOutcome: Equatable { + static func == (lhs: TriggerRuleOutcome, rhs: TriggerRuleOutcome) -> Bool { + switch (lhs, rhs) { + case let (.match(item1), .match(item2)): + return item1.rule == item2.rule + && item1.unsavedOccurrence == item2.unsavedOccurrence + case let (.noMatch(unmatchedRule1), .noMatch(unmatchedRule2)): + return unmatchedRule1.source == unmatchedRule2.source + && unmatchedRule1.experimentId == unmatchedRule2.experimentId + default: + return false + } + } + + case noMatch(UnmatchedRule) + case match(MatchedItem) + + static func noMatch( + source: UnmatchedRule.Source, + experimentId: String + ) -> TriggerRuleOutcome { + return .noMatch(.init(source: source, experimentId: experimentId)) + } + + static func match( + rule: TriggerRule, + unsavedOccurrence: TriggerRuleOccurrence? = nil + ) -> TriggerRuleOutcome { + return .match(.init(rule: rule, unsavedOccurrence: unsavedOccurrence)) + } +} + struct TriggerRule: Decodable, Hashable { var experiment: RawExperiment var expression: String? diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index ae38c2c8c..60e560384 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -3,7 +3,7 @@ // // Created by Jake Mor on 8/10/21. // -// swiftlint:disable type_body_length +// swiftlint:disable type_body_length file_length import UIKit import Foundation @@ -147,6 +147,13 @@ class DeviceHelper { return result }() + let appBuildString: String = { + guard let build = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as? String else { + return "" + } + return build + }() + let interfaceType: String = { switch UIDevice.current.userInterfaceIdiom { case .pad: @@ -182,6 +189,8 @@ class DeviceHelper { return installDate }() + private let sdkVersionPadded: String + private var daysSinceInstall: Int { let fromDate = appInstallDate ?? Date() let toDate = Date() @@ -329,6 +338,52 @@ class DeviceHelper { return output } + static func makePaddedSdkVersion(using sdkVersion: String) -> String { + // Separate out the "beta" part from the main version. + let components = sdkVersion.split(separator: "-") + if components.isEmpty { + return "" + } + let versionNumber = String(components[0]) + + var appendix = "" + + // If there is a "beta" part... + if components.count > 1 { + // Separate out the number from the name, e.g. beta.1 -> [beta, 1] + let appendixComponents = components[1].split(separator: ".") + appendix = "-" + String(appendixComponents[0]) + + var appendixVersion = "" + + // Pad beta number and add to appendix + if appendixComponents.count > 1 { + appendixVersion = String(format: "%03d", Int(appendixComponents[1]) ?? 0) + appendix += "." + appendixVersion + } + } + + // Separate out the version numbers. + let versionComponents = versionNumber.split(separator: ".") + var newVersion = "" + if !versionComponents.isEmpty { + let major = String(format: "%03d", Int(versionComponents[0]) ?? 0) + newVersion += major + } + if versionComponents.count > 1 { + let minor = String(format: "%03d", Int(versionComponents[1]) ?? 0) + newVersion += ".\(minor)" + } + if versionComponents.count > 2 { + let patch = String(format: "%03d", Int(versionComponents[2]) ?? 0) + newVersion += ".\(patch)" + } + + newVersion += appendix + + return newVersion + } + private func getTemplateDevice() async -> [String: Any] { let identityInfo = await factory.makeIdentityInfo() let aliases = [identityInfo.aliasId] @@ -367,9 +422,12 @@ class DeviceHelper { localDateTime: localDateTimeString, isSandbox: isSandbox, subscriptionStatus: Superwall.shared.subscriptionStatus.description, - isFirstAppOpen: isFirstAppOpen + isFirstAppOpen: isFirstAppOpen, + sdkVersion: sdkVersion, + sdkVersionPadded: sdkVersionPadded, + appBuildString: appBuildString, + appBuildStringNumber: Int(appBuildString) ) - return template.toDictionary() } @@ -385,5 +443,6 @@ class DeviceHelper { self.appInstalledAtString = appInstallDate?.isoString ?? "" self.factory = factory reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, api.hostDomain) + self.sdkVersionPadded = Self.makePaddedSdkVersion(using: sdkVersion) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift index d6a9359de..9963f7556 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/GetPresentationResultLogic.swift @@ -9,7 +9,7 @@ import Foundation enum GetPresentationResultLogic { /// Converts a ``TriggerResult`` to a ``PresentationResult`` - static func convertTriggerResult(_ triggerResult: TriggerResult) -> PresentationResult { + static func convertTriggerResult(_ triggerResult: InternalTriggerResult) -> PresentationResult { switch triggerResult { case .eventNotFound: return .eventNotFound diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/PublicGetPresentationResult.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/PublicGetPresentationResult.swift index 14e4a1ca1..6812f7530 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/PublicGetPresentationResult.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Presentation Result/PublicGetPresentationResult.swift @@ -36,7 +36,7 @@ extension Superwall { return await internallyGetPresentationResult( forEvent: event, - requestType: .getPresentationResult + isImplicit: false ) } @@ -71,7 +71,7 @@ extension Superwall { /// - requestType: The presentation request type, which will control the flow of the pipeline. func internallyGetPresentationResult( forEvent event: Trackable, - requestType: PresentationRequestType + isImplicit: Bool ) async -> PresentationResult { let eventCreatedAt = Date() @@ -91,7 +91,7 @@ extension Superwall { .explicitTrigger(eventData), isDebuggerLaunched: false, isPaywallPresented: false, - type: requestType + type: isImplicit ? .getImplicitPresentationResult : .getPresentationResult ) return await getPresentationResult(for: presentationRequest) diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift index 615ea15c4..b21b2b527 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/CheckUserSubscription.swift @@ -18,7 +18,7 @@ extension Superwall { /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. func checkUserSubscription( request: PresentationRequest, - triggerResult: TriggerResult, + triggerResult: InternalTriggerResult, paywallStatePublisher: PassthroughSubject? ) async throws { switch triggerResult { diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift index 470d78f8a..784d2323e 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/GetPresenter.swift @@ -107,7 +107,7 @@ extension Superwall { private func activateSession( for request: PresentationRequest, paywall: Paywall, - triggerResult: TriggerResult + triggerResult: InternalTriggerResult ) async { let sessionEventsManager = dependencyContainer.sessionEventsManager await sessionEventsManager?.triggerSession.activateSession( diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/LogPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/LogPresentation.swift index fe62d5c1f..39e0f6f35 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/LogPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal Presentation/Operators/LogPresentation.swift @@ -19,7 +19,12 @@ extension Superwall { case .getPaywall: message += "Superwall.shared.getPaywall" case .presentation: - message += "Superwall.shared.register" + switch request.presentationInfo.triggerType { + case .explicit: + message += "Superwall.shared.register" + case .implicit: + message = "Tracking an implicit trigger" + } case .getPresentationResult: message += "Superwall.shared.getPresentationResult" case .getImplicitPresentationResult: diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index aa7ca384c..276d00ac5 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -103,9 +103,11 @@ public final class PaywallInfo: NSObject { /// The local notifications associated with the paywall. public let localNotifications: [LocalNotification] + /// An array of requests to compute a device property associated with an event at runtime. public let computedPropertyRequests: [ComputedPropertyRequest] - public let survey: Survey? + /// Surveys attached to a paywall. + public let surveys: [Survey] private unowned let factory: TriggerSessionManagerFactory @@ -134,7 +136,7 @@ public final class PaywallInfo: NSObject { closeReason: PaywallCloseReason = .none, localNotifications: [LocalNotification] = [], computedPropertyRequests: [ComputedPropertyRequest] = [], - survey: Survey? + surveys: [Survey] ) { self.databaseId = databaseId self.identifier = identifier @@ -152,7 +154,7 @@ public final class PaywallInfo: NSObject { self.featureGatingBehavior = featureGatingBehavior self.localNotifications = localNotifications self.computedPropertyRequests = computedPropertyRequests - self.survey = survey + self.surveys = surveys if eventData != nil { self.presentedBy = "event" @@ -315,7 +317,7 @@ extension PaywallInfo: Stubbable { isFreeTrialAvailable: false, presentationSourceType: "register", factory: dependencyContainer, - survey: nil + surveys: [] ) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift index 2a7108447..4499ad910 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift @@ -30,7 +30,7 @@ extension Superwall { } } - /// Dismisses the presented paywall. + /// Dismisses the presented paywall, if one exists. @MainActor @nonobjc public func dismiss() async { @@ -47,6 +47,7 @@ extension Superwall { } } + /// Dismisses the presented paywall, if it exists, in order to present a different one. @MainActor func dismissForNextPaywall() async { guard let paywallViewController = paywallViewController else { diff --git a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift index 7c3ded0fc..2c3bec376 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/Expression Evaluator/ExpressionEvaluator.swift @@ -12,11 +12,6 @@ struct ExpressionEvaluator { private let storage: Storage private unowned let factory: RuleAttributesFactory - struct TriggerFireOutcome { - let shouldFire: Bool - var unsavedOccurrence: TriggerRuleOccurrence? - } - init( storage: Storage, factory: RuleAttributesFactory @@ -28,18 +23,18 @@ struct ExpressionEvaluator { func evaluateExpression( fromRule rule: TriggerRule, eventData: EventData - ) async -> TriggerFireOutcome { + ) async -> TriggerRuleOutcome { // Expression matches all if rule.expressionJs == nil && rule.expression == nil { - let shouldFire = await shouldFire( - forOccurrence: rule.occurrence, - ruleMatched: true + let ruleMatched = await tryToMatchOccurrence( + from: rule, + expressionMatched: true ) - return shouldFire + return ruleMatched } guard let jsCtx = JSContext() else { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } jsCtx.exceptionHandler = { (_, value: JSValue?) in guard let value = value else { @@ -62,30 +57,30 @@ struct ExpressionEvaluator { ) } - guard let postfix = await getPostfix( - forRule: rule, + guard let base64Params = await getBase64Params( + from: rule, withEventData: eventData ) else { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } - let result = jsCtx.evaluateScript(script + "\n " + postfix) + let result = jsCtx.evaluateScript(script + "\n " + base64Params) if result?.isString == nil { - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } - let isMatched = result?.toString() == "true" + let expressionMatched = result?.toString() == "true" - let shouldFire = await shouldFire( - forOccurrence: rule.occurrence, - ruleMatched: isMatched + let ruleMatched = await tryToMatchOccurrence( + from: rule, + expressionMatched: expressionMatched ) - return shouldFire + return ruleMatched } - private func getPostfix( - forRule rule: TriggerRule, + private func getBase64Params( + from rule: TriggerRule, withEventData eventData: EventData ) async -> String? { let attributes = await factory.makeRuleAttributes( @@ -116,19 +111,19 @@ struct ExpressionEvaluator { return nil } - func shouldFire( - forOccurrence occurrence: TriggerRuleOccurrence?, - ruleMatched: Bool - ) async -> TriggerFireOutcome { - if ruleMatched { - guard let occurrence = occurrence else { + func tryToMatchOccurrence( + from rule: TriggerRule, + expressionMatched: Bool + ) async -> TriggerRuleOutcome { + if expressionMatched { + guard let occurrence = rule.occurrence else { Logger.debug( logLevel: .debug, scope: .paywallPresentation, message: "No occurrence parameter found for trigger rule." ) - return TriggerFireOutcome(shouldFire: true) + return .match(rule: rule) } let count = await storage @@ -142,14 +137,12 @@ struct ExpressionEvaluator { if shouldFire { unsavedOccurrence = occurrence + return .match(rule: rule, unsavedOccurrence: unsavedOccurrence) + } else { + return .noMatch(source: .occurrence, experimentId: rule.experiment.id) } - - return TriggerFireOutcome( - shouldFire: shouldFire, - unsavedOccurrence: unsavedOccurrence - ) } - return TriggerFireOutcome(shouldFire: false) + return .noMatch(source: .expression, experimentId: rule.experiment.id) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift index ca1343832..a607fd864 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Rule Logic/RuleLogic.swift @@ -16,7 +16,12 @@ struct ConfirmableAssignment: Equatable { struct RuleEvaluationOutcome { var confirmableAssignment: ConfirmableAssignment? var unsavedOccurrence: TriggerRuleOccurrence? - var triggerResult: TriggerResult + var triggerResult: InternalTriggerResult +} + +enum RuleMatchOutcome { + case matched(MatchedItem) + case noMatchingRules([UnmatchedRule]) } struct RuleLogic { @@ -50,16 +55,23 @@ struct RuleLogic { return RuleEvaluationOutcome(triggerResult: .eventNotFound) } - guard let ruleOutcome = await findMatchingRule( + let ruleMatchOutcome = await findMatchingRule( for: event, withTrigger: trigger - ) else { - return RuleEvaluationOutcome(triggerResult: .noRuleMatch) + ) + + let matchedRuleItem: MatchedItem + + switch ruleMatchOutcome { + case .matched(let item): + matchedRuleItem = item + case .noMatchingRules(let unmatchedRules): + return.init(triggerResult: .noRuleMatch(unmatchedRules)) } let variant: Experiment.Variant var confirmableAssignment: ConfirmableAssignment? - let rule = ruleOutcome.rule + let rule = matchedRuleItem.rule // For a matching rule there will be an unconfirmed (in-memory) or confirmed (on disk) variant assignment. // First check the disk, otherwise check memory. let confirmedAssignments = storage.getConfirmedAssignments() @@ -92,7 +104,7 @@ struct RuleLogic { case .holdout: return RuleEvaluationOutcome( confirmableAssignment: confirmableAssignment, - unsavedOccurrence: ruleOutcome.unsavedOccurrence, + unsavedOccurrence: matchedRuleItem.unsavedOccurrence, triggerResult: .holdout( Experiment( id: rule.experiment.id, @@ -104,7 +116,7 @@ struct RuleLogic { case .treatment: return RuleEvaluationOutcome( confirmableAssignment: confirmableAssignment, - unsavedOccurrence: ruleOutcome.unsavedOccurrence, + unsavedOccurrence: matchedRuleItem.unsavedOccurrence, triggerResult: .paywall( Experiment( id: rule.experiment.id, @@ -119,24 +131,28 @@ struct RuleLogic { func findMatchingRule( for event: EventData, withTrigger trigger: Trigger - ) async -> TriggerRuleOutcome? { + ) async -> RuleMatchOutcome { let expressionEvaluator = ExpressionEvaluator( storage: storage, factory: factory ) + var unmatchedRules: [UnmatchedRule] = [] + for rule in trigger.rules { let outcome = await expressionEvaluator.evaluateExpression( fromRule: rule, eventData: event ) - if outcome.shouldFire { - return TriggerRuleOutcome( - rule: rule, - unsavedOccurrence: outcome.unsavedOccurrence - ) + + switch outcome { + case .match(let item): + return .matched(item) + case .noMatch(let noRuleMatch): + unmatchedRules.append(noRuleMatch) } } - return nil + + return .noMatchingRules(unmatchedRules) } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 8bd4ffeb8..d554cf972 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -559,10 +559,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { ) present(alertController, animated: true) { [weak self] in - if let loadingState = self?.loadingState, - loadingState != .loadingURL { - self?.loadingState = .ready - } + self?.loadingState = .ready } } } @@ -661,17 +658,34 @@ extension PaywallViewController { presentationWillBegin() } + /// Determines whether a survey will show. + private var willShowSurvey: Bool { + if paywall.surveys.isEmpty { + return false + } + guard + modalPresentationStyle == .formSheet || + modalPresentationStyle == .pageSheet || + modalPresentationStyle == .popover + else { + return false + } + guard presentationController?.delegate == nil else { + return false + } + + for survey in paywall.surveys where survey.hasSeenSurvey(storage: storage) { + return false + } + return true + } + /// Prepares the view controller for presentation. Only called once per presentation. private func presentationWillBegin() { guard presentationWillPrepare else { return } - if let survey = paywall.survey, - survey.hasSeenSurvey(storage: storage) == false, - modalPresentationStyle == .formSheet - || modalPresentationStyle == .pageSheet - || modalPresentationStyle == .popover, - presentationController?.delegate == nil { + if willShowSurvey { didDisableSwipeForSurvey = true presentationController?.delegate = self isModalInPresentation = true @@ -771,7 +785,27 @@ extension PaywallViewController { let isDeclined = paywallResult == .declined let isManualClose = closeReason == .manualClose - func dismissView() { + func dismissView() async { + if isDeclined, isManualClose { + let trackedEvent = InternalSuperwallEvent.PaywallDecline(paywallInfo: info) + + let presentationResult = await Superwall.shared.internallyGetPresentationResult( + forEvent: trackedEvent, + isImplicit: true + ) + let paywallPresenterEvent = info.presentedByEventWithName + let presentedByPaywallDecline = paywallPresenterEvent == SuperwallEventObjc.paywallDecline.description + + await Superwall.shared.track(trackedEvent) + + if case .paywall = presentationResult, + !presentedByPaywallDecline { + // If a paywall_decline trigger is active and the current paywall wasn't presented + // by paywall_decline, it lands here so as not to dismiss the paywall. + // track() will do that before presenting the next paywall. + return + } + } if let delegate = delegate { didCallDelegate = true delegate.didFinish( @@ -780,22 +814,25 @@ extension PaywallViewController { shouldDismiss: true ) } else { - dismiss(animated: presentationIsAnimated) + await dismiss(animated: presentationIsAnimated) } } SurveyManager.presentSurveyIfAvailable( - paywall.survey, + paywall.surveys, + paywallResult: result, + paywallCloseReason: closeReason, using: self, loadingState: loadingState, - paywallIsManuallyDeclined: isDeclined && isManualClose, isDebuggerLaunched: request?.flags.isDebuggerLaunched == true, paywallInfo: info, storage: storage, factory: factory ) { [weak self] result in self?.surveyPresentationResult = result - dismissView() + Task { + await dismissView() + } } } @@ -833,7 +870,6 @@ extension PaywallViewController { Superwall.shared.destroyPresentingWindow() GameControllerManager.shared.clearDelegate(self) - if didDisableSwipeForSurvey { presentationController?.delegate = nil isModalInPresentation = false diff --git a/Sources/SuperwallKit/Paywall/View Controller/Survey/SurveyManager.swift b/Sources/SuperwallKit/Paywall/View Controller/Survey/SurveyManager.swift index 7e50f1385..f8a10647b 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Survey/SurveyManager.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Survey/SurveyManager.swift @@ -12,25 +12,29 @@ final class SurveyManager { static private var otherAlertController: UIAlertController? static func presentSurveyIfAvailable( - _ survey: Survey?, + _ surveys: [Survey], + paywallResult: PaywallResult, + paywallCloseReason: PaywallCloseReason, using presenter: PaywallViewController, loadingState: PaywallLoadingState, - paywallIsManuallyDeclined: Bool, isDebuggerLaunched: Bool, paywallInfo: PaywallInfo, storage: Storage, factory: TriggerFactory, completion: @escaping (SurveyPresentationResult) -> Void ) { - guard let survey = survey else { + guard let survey = selectSurvey( + from: surveys, + paywallResult: paywallResult, + paywallCloseReason: paywallCloseReason + ) else { completion(.noShow) return } - guard loadingState == .ready else { - completion(.noShow) - return - } - guard paywallIsManuallyDeclined else { + + guard + loadingState == .ready || loadingState == .loadingPurchase + else { completion(.noShow) return } @@ -145,6 +149,17 @@ final class SurveyManager { alertController.addAction(otherAction) } + if survey.includeCloseOption { + let closeButton = UIAlertAction(title: "Close", style: .cancel) { _ in + Task { + let event = InternalSuperwallEvent.SurveyClose() + await Superwall.shared.track(event) + } + completion(.show) + } + alertController.addAction(closeButton) + } + presenter.present(alertController, animated: true) } @@ -193,6 +208,40 @@ final class SurveyManager { otherAlertController = nil } + private static func selectSurvey( + from surveys: [Survey], + paywallResult: PaywallResult, + paywallCloseReason: PaywallCloseReason + ) -> Survey? { + let isPurchased: Bool + switch paywallResult { + case .purchased: + isPurchased = true + default: + isPurchased = false + } + let isDeclined = paywallResult == .declined + let isManualClose = paywallCloseReason == .manualClose + + let onManualClose = isDeclined && isManualClose + let onPurchase = isPurchased + + for survey in surveys { + switch survey.presentationCondition { + case .onManualClose: + if onManualClose { + return survey + } + case .onPurchase: + if onPurchase { + return survey + } + } + } + + return nil + } + @objc static private func alertTextFieldDidChange(_ sender: UITextField) { if let text = sender.text { diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift index 763e3b0c7..5af149b3f 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift @@ -42,6 +42,10 @@ struct DeviceTemplate: Codable { var isSandbox: String var subscriptionStatus: String var isFirstAppOpen: Bool + var sdkVersion: String + var sdkVersionPadded: String + var appBuildString: String + var appBuildStringNumber: Int? func toDictionary() -> [String: Any] { guard let data = try? JSONEncoder().encode(self) else { diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index aa5a50637..603580a0a 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -73,12 +73,12 @@ class Storage { /// The disk cache. private let cache: Cache - private unowned let factory: DeviceHelperFactory & HasPurchaseControllerFactory + private unowned let factory: DeviceHelperFactory & HasExternalPurchaseControllerFactory // MARK: - Configuration init( - factory: DeviceHelperFactory & HasPurchaseControllerFactory, + factory: DeviceHelperFactory & HasExternalPurchaseControllerFactory, cache: Cache = Cache(), coreDataManager: CoreDataManager = CoreDataManager() ) { @@ -172,13 +172,13 @@ class Storage { return } - let hasPurchaseController = factory.makeHasPurchaseController() + let hasExternalPurchaseController = factory.makeHasExternalPurchaseController() let deviceInfo = factory.makeDeviceInfo() Task { let event = InternalSuperwallEvent.AppInstall( appInstalledAtString: deviceInfo.appInstalledAtString, - hasPurchaseController: hasPurchaseController + hasExternalPurchaseController: hasExternalPurchaseController ) _ = await trackEvent(event) } diff --git a/Sources/SuperwallKit/StoreKit/Abstractions/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Abstractions/StoreProduct/StoreProduct.swift index 3801eac8e..ffda52ca6 100644 --- a/Sources/SuperwallKit/StoreKit/Abstractions/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Abstractions/StoreProduct/StoreProduct.swift @@ -21,6 +21,7 @@ public typealias SK1Product = SKProduct @available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) public typealias SK2Product = StoreKit.Product +/// A convenience wrapper around a StoreKit 1 or StoreKit 2 product. @objc(SWKStoreProduct) @objcMembers public final class StoreProduct: NSObject, StoreProductType, Sendable { diff --git a/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StorePayment.swift b/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StorePayment.swift index 9c88f86e4..fce21cec6 100644 --- a/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StorePayment.swift +++ b/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StorePayment.swift @@ -8,6 +8,7 @@ import Foundation import StoreKit +/// The payment for the transaction. @objc(SWKStorePayment) @objcMembers public final class StorePayment: NSObject, Encodable, Sendable { diff --git a/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StoreTransaction.swift b/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StoreTransaction.swift index e66fcbe37..6617fe81e 100644 --- a/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StoreTransaction.swift +++ b/Sources/SuperwallKit/StoreKit/Abstractions/StoreTransaction/StoreTransaction.swift @@ -15,6 +15,7 @@ public typealias SK1Transaction = SKPaymentTransaction @available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) public typealias SK2Transaction = StoreKit.Transaction +/// A convenience wrapper around a StoreKit 1 or StoreKit 2 transaction. @objc(SWKStoreTransaction) @objcMembers public final class StoreTransaction: NSObject, StoreTransactionType, Encodable { diff --git a/Sources/SuperwallKit/StoreKit/Coordinator/CoordinatorProtocols.swift b/Sources/SuperwallKit/StoreKit/Coordinator/CoordinatorProtocols.swift deleted file mode 100644 index a8df69f66..000000000 --- a/Sources/SuperwallKit/StoreKit/Coordinator/CoordinatorProtocols.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 03/01/2023. -// - -import Foundation - -protocol TransactionChecker: AnyObject { - /// Gets and validates a transaction of a product, if the user isn't using - /// a ``PurchaseController``. - func getAndValidateLatestTransaction( - of productId: String, - hasPurchaseController: Bool - ) async throws -> StoreTransaction? -} - -protocol ProductPurchaser: AnyObject { - /// Purchases a product and returns its result. - func purchase(product: StoreProduct) async -> PurchaseResult -} - -protocol ProductsFetcher: AnyObject { - /// Fetches a set of products from their identifiers. - func products( - identifiers: Set, - forPaywall paywallName: String? - ) async throws -> Set -} - -protocol TransactionRestorer: AnyObject { - /// Restores purchases. - /// - /// - Returns: A boolean indicating whether the restore request succeeded or failed. - /// This doesn't mean that the user is now subscribed, just that there were no errors - /// obtaining the restored transactions - func restorePurchases() async -> RestorationResult -} diff --git a/Sources/SuperwallKit/StoreKit/Coordinator/StoreKitCoordinator.swift b/Sources/SuperwallKit/StoreKit/Coordinator/StoreKitCoordinator.swift deleted file mode 100644 index 854c8cc2a..000000000 --- a/Sources/SuperwallKit/StoreKit/Coordinator/StoreKitCoordinator.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 15/12/2022. -// - -import Foundation - -/// Coordinates the purchasing, restoring and retrieving of products; the checking -/// of transactions; and the determining of the user's subscription status. -struct StoreKitCoordinator { - /// Fetches the products. - let productFetcher: ProductsFetcher - - /// Gets and validates transactions. - let txnChecker: TransactionChecker - - /// Purchases the product. - var productPurchaser: ProductPurchaser - - /// Restores purchases. - var txnRestorer: TransactionRestorer - - /// Checks if the user is subscribed. - unowned let delegateAdapter: SuperwallDelegateAdapter - unowned let storeKitManager: StoreKitManager - private let factory: StoreTransactionFactory & ProductPurchaserFactory - - init( - delegateAdapter: SuperwallDelegateAdapter, - storeKitManager: StoreKitManager, - factory: StoreTransactionFactory & ProductPurchaserFactory, - productsFetcher: ProductsFetcher = ProductsFetcherSK1() - ) { - self.factory = factory - self.delegateAdapter = delegateAdapter - self.storeKitManager = storeKitManager - self.productFetcher = productsFetcher - - let sk1ProductPurchaser = factory.makeSK1ProductPurchaser() - - if #available(iOS 15.0, *) { - self.txnChecker = TransactionVerifierSK2(factory: factory) - } else { - self.txnChecker = sk1ProductPurchaser - } - - let hasPurchaseController = delegateAdapter.hasPurchaseController - if hasPurchaseController { - self.productPurchaser = delegateAdapter - self.txnRestorer = delegateAdapter - } else { - self.productPurchaser = sk1ProductPurchaser - self.txnRestorer = sk1ProductPurchaser - } - } -} diff --git a/Sources/SuperwallKit/StoreKit/InternalPurchaseController.swift b/Sources/SuperwallKit/StoreKit/InternalPurchaseController.swift new file mode 100644 index 000000000..3c5b5b576 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/InternalPurchaseController.swift @@ -0,0 +1,160 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 29/08/2023. +// + +import Foundation +import StoreKit + +protocol RestoreDelegate: AnyObject { + func didRestore(result: RestorationResult) async +} + +final class InternalPurchaseController: PurchaseController { + var hasExternalPurchaseController: Bool { + return swiftPurchaseController != nil || objcPurchaseController != nil + } + private var swiftPurchaseController: PurchaseController? + private var objcPurchaseController: PurchaseControllerObjc? + private let factory: ProductPurchaserFactory + lazy var productPurchaser = factory.makeSK1ProductPurchaser() + weak var delegate: RestoreDelegate? + + init( + factory: ProductPurchaserFactory, + swiftPurchaseController: PurchaseController?, + objcPurchaseController: PurchaseControllerObjc? + ) { + self.swiftPurchaseController = swiftPurchaseController + self.objcPurchaseController = objcPurchaseController + self.factory = factory + } +} + +// MARK: - Subscription Status +extension InternalPurchaseController { + func syncSubscriptionStatus(withPurchases purchases: Set) async { + if hasExternalPurchaseController { + return + } + let activePurchases = purchases.filter { $0.isActive } + await MainActor.run { + if activePurchases.isEmpty { + Superwall.shared.subscriptionStatus = .inactive + } else { + Superwall.shared.subscriptionStatus = .active + } + } + } +} + +// MARK: - Restoration +extension InternalPurchaseController { + @MainActor + func restorePurchases() async -> RestorationResult { + if let purchaseController = swiftPurchaseController { + return await purchaseController.restorePurchases() + } else if let purchaseController = objcPurchaseController { + return await withCheckedContinuation { continuation in + purchaseController.restorePurchases { result, error in + switch result { + case .restored: + continuation.resume(returning: .restored) + case .failed: + continuation.resume(returning: .failed(error)) + } + } + } + } else { + let result = await productPurchaser.restorePurchases() + await delegate?.didRestore(result: result) + return result + } + } + + @MainActor + func tryToRestore(from paywallViewController: PaywallViewController) async { + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Attempting Restore" + ) + + paywallViewController.loadingState = .loadingPurchase + + let restorationResult = await restorePurchases() + + let hasRestored = restorationResult == .restored + let isUserSubscribed = Superwall.shared.subscriptionStatus == .active + + if hasRestored && isUserSubscribed { + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transactions Restored" + ) + await transactionWasRestored(paywallViewController: paywallViewController) + } else { + Logger.debug( + logLevel: .debug, + scope: .paywallTransactions, + message: "Transactions Failed to Restore" + ) + + paywallViewController.presentAlert( + title: Superwall.shared.options.paywalls.restoreFailed.title, + message: Superwall.shared.options.paywalls.restoreFailed.message, + closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle + ) + } + } + + private func transactionWasRestored(paywallViewController: PaywallViewController) async { + let paywallInfo = await paywallViewController.info + + let trackedEvent = InternalSuperwallEvent.Transaction( + state: .restore, + paywallInfo: paywallInfo, + product: nil, + model: nil + ) + await Superwall.shared.track(trackedEvent) + + if Superwall.shared.options.paywalls.automaticallyDismiss { + await Superwall.shared.dismiss(paywallViewController, result: .restored) + } + } +} + +// MARK: - Purchasing +extension InternalPurchaseController { + @MainActor + func purchase(product: SKProduct) async -> PurchaseResult { + if let purchaseController = swiftPurchaseController { + return await purchaseController.purchase(product: product) + } else if let purchaseController = objcPurchaseController { + return await withCheckedContinuation { continuation in + purchaseController.purchase(product: product) { result, error in + if let error = error { + continuation.resume(returning: .failed(error)) + } else { + switch result { + case .purchased: + continuation.resume(returning: .purchased) + case .pending: + continuation.resume(returning: .pending) + case .cancelled: + continuation.resume(returning: .cancelled) + case .failed: + break + } + } + } + } + } else { + return await productPurchaser.purchase(product: product) + } + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/ProductsFetcherSK1.swift b/Sources/SuperwallKit/StoreKit/Products/ProductsFetcherSK1.swift index 683b083de..f74effbad 100644 --- a/Sources/SuperwallKit/StoreKit/Products/ProductsFetcherSK1.swift +++ b/Sources/SuperwallKit/StoreKit/Products/ProductsFetcherSK1.swift @@ -14,7 +14,7 @@ import Foundation import StoreKit -class ProductsFetcherSK1: NSObject, ProductsFetcher { +class ProductsFetcherSK1: NSObject { private var cachedProductsByIdentifier: [String: SKProduct] = [:] private let queue = DispatchQueue(label: "com.superwall.ProductsManager") private var productsByRequest: [SKRequest: Set] = [:] diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Coder.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Coder.swift index 330fe55b5..db0fa84be 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Coder.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Coder.swift @@ -7,15 +7,15 @@ import Foundation -public typealias ASN1Codable = ASN1Encodable & ASN1Decodable +typealias ASN1Codable = ASN1Encodable & ASN1Decodable -public protocol ASN1Encodable: Encodable { } -public protocol ASN1Decodable: Decodable +protocol ASN1Encodable: Encodable { } +protocol ASN1Decodable: Decodable { static var template: ASN1Template { get } } -public protocol ASN1CodingKey: CodingKey +protocol ASN1CodingKey: CodingKey { var template: ASN1Template { get } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+KeyedDecodingContainer.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+KeyedDecodingContainer.swift index 8e35f34f1..c8412cc72 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+KeyedDecodingContainer.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+KeyedDecodingContainer.swift @@ -25,9 +25,9 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain private let state: _ASN1Decoder.State /// The path of coding keys taken to get to this point in decoding. - private(set) public var codingPath: [CodingKey] + private(set) var codingPath: [CodingKey] - public var rawData: Data { return container.rawData } + var rawData: Data { return container.rawData } // MARK: - Initialization @@ -44,12 +44,12 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain // MARK: - KeyedDecodingContainerProtocol Methods - public var allKeys: [Key] + var allKeys: [Key] { return [] } - public func contains(_ key: Key) -> Bool + func contains(_ key: Key) -> Bool { return false } @@ -59,19 +59,19 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return "\(key) (\"\(key.stringValue)\")" } - public func decodeNil(forKey key: Key) throws -> Bool + func decodeNil(forKey key: Key) throws -> Bool { assertionFailure("Not supposed to be here") return false } - public func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { assertionFailure("Not supposed to be here") return false } - public func decode(_ type: Int.Type, forKey key: Key) throws -> Int + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -85,18 +85,18 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return value } - public func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -110,47 +110,47 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return value } - public func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: String.Type, forKey key: Key) throws -> String + func decode(_ type: String.Type, forKey key: Key) throws -> String { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -164,7 +164,7 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return value } - public func decodeSkippedField(forKey key: Key) throws -> ASN1SkippedField + func decodeSkippedField(forKey key: Key) throws -> ASN1SkippedField { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -179,7 +179,7 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return value } - public func decodeData(forKey key: Key) throws -> Data + func decodeData(forKey key: Key) throws -> Data { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -194,7 +194,7 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return value } - public func decode(_ type: T.Type, forKey key: Key) throws -> T + func decode(_ type: T.Type, forKey key: Key) throws -> T { if type == Data.self || type == NSData.self { @@ -239,14 +239,14 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return obj } - public func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { assertionFailure("Hasn't implemented yet") let container = try ASN1KeyedDecodingContainer(referencing: self.decoder, wrapping: self.container) return KeyedDecodingContainer(container) } - public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer + func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { return try ASN1UnkeyedDecodingContainer(referencing: self.decoder, wrapping: objToUnbox(forKey: key)) } @@ -261,13 +261,13 @@ internal struct ASN1KeyedDecodingContainer : KeyedDecodingContain return _ASN1Decoder(referencing: try objToUnbox(forKey: k as! K), at: self.decoder.codingPath, options: self.decoder.options) } - public func superDecoder() throws -> Decoder + func superDecoder() throws -> Decoder { assertionFailure("Hasn't implemented yet") return try _superDecoder(forKey: ASN1Key.super) } - public func superDecoder(forKey key: Key) throws -> Decoder + func superDecoder(forKey key: Key) throws -> Decoder { return try _superDecoder(forKey: key) } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+SingleValueContainer.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+SingleValueContainer.swift index 241ea4cfd..ffc6e34fd 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+SingleValueContainer.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+SingleValueContainer.swift @@ -13,94 +13,94 @@ extension _ASN1Decoder: SingleValueDecodingContainer { // MARK: SingleValueDecodingContainer Methods - public func decodeNil() -> Bool + func decodeNil() -> Bool { assertionFailure("Not supposed to be here") return false } - public func decode(_ type: Bool.Type) throws -> Bool + func decode(_ type: Bool.Type) throws -> Bool { assertionFailure("Not supposed to be here") return false } - public func decode(_ type: Int.Type) throws -> Int + func decode(_ type: Int.Type) throws -> Int { return try self.unbox(self.storage.current, as: Int.self)! } - public func decode(_ type: Int8.Type) throws -> Int8 + func decode(_ type: Int8.Type) throws -> Int8 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Int16.Type) throws -> Int16 + func decode(_ type: Int16.Type) throws -> Int16 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Int32.Type) throws -> Int32 + func decode(_ type: Int32.Type) throws -> Int32 { return try self.unbox(self.storage.current, as: Int32.self)! } - public func decode(_ type: Int64.Type) throws -> Int64 + func decode(_ type: Int64.Type) throws -> Int64 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt.Type) throws -> UInt + func decode(_ type: UInt.Type) throws -> UInt { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt8.Type) throws -> UInt8 + func decode(_ type: UInt8.Type) throws -> UInt8 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt16.Type) throws -> UInt16 + func decode(_ type: UInt16.Type) throws -> UInt16 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt32.Type) throws -> UInt32 + func decode(_ type: UInt32.Type) throws -> UInt32 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: UInt64.Type) throws -> UInt64 + func decode(_ type: UInt64.Type) throws -> UInt64 { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Float.Type) throws -> Float + func decode(_ type: Float.Type) throws -> Float { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: Double.Type) throws -> Double + func decode(_ type: Double.Type) throws -> Double { assertionFailure("Not supposed to be here") return 0 } - public func decode(_ type: String.Type) throws -> String + func decode(_ type: String.Type) throws -> String { return try self.unbox(self.storage.current, as: String.self)! } - public func decode(_ type: T.Type) throws -> T + func decode(_ type: T.Type) throws -> T { if type == ASN1SkippedField.self { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+UnkeyedDecodingContainer.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+UnkeyedDecodingContainer.swift index 356b5d701..46a4f0fc0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+UnkeyedDecodingContainer.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder+UnkeyedDecodingContainer.swift @@ -9,7 +9,7 @@ import Foundation // MARK: ASN1UnkeyedDecodingContainer -public protocol ASN1UnkeyedDecodingContainerProtocol: UnkeyedDecodingContainer +protocol ASN1UnkeyedDecodingContainerProtocol: UnkeyedDecodingContainer { var rawData: Data { get } var valueData: Data { get } @@ -136,10 +136,10 @@ internal struct ASN1UnkeyedDecodingContainer private let state: _ASN1Decoder.State /// The path of coding keys taken to get to this point in decoding. - public var codingPath: [CodingKey] + var codingPath: [CodingKey] /// The index of the element we're about to decode. - private(set) public var currentIndex: Int + private(set) var currentIndex: Int var count: Int? diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder.swift index 8f6754d41..669566d1d 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Decoder.swift @@ -9,7 +9,7 @@ import Foundation typealias ASN1DecoderConsumedValue = Int -open class ASN1Decoder +final class ASN1Decoder { //fileprivate //TODO struct EncodingOptions @@ -18,7 +18,7 @@ open class ASN1Decoder let userInfo: [CodingUserInfoKey : Any] = [:] } - public init() {} + init() {} // MARK: - Decoding Values @@ -30,7 +30,7 @@ open class ASN1Decoder /// - returns: A value of the requested type. /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not valid ASN1. /// - throws: An error if any value throws an error during decoding. - open func decode(_ type: T.Type, from data: Data, template: ASN1Template? = nil) throws -> T + func decode(_ type: T.Type, from data: Data, template: ASN1Template? = nil) throws -> T { let t: ASN1Template = template ?? type.template @@ -55,20 +55,20 @@ open class ASN1Decoder internal struct ASN1Key: CodingKey { - public var stringValue: String - public var intValue: Int? + var stringValue: String + var intValue: Int? - public init?(stringValue: String) { + init?(stringValue: String) { self.stringValue = stringValue self.intValue = nil } - public init?(intValue: Int) { + init?(intValue: Int) { self.stringValue = "\(intValue)" self.intValue = intValue } - public init(stringValue: String, intValue: Int?) { + init(stringValue: String, intValue: Int?) { self.stringValue = stringValue self.intValue = intValue } @@ -83,7 +83,7 @@ internal struct ASN1Key: CodingKey // MARK: _ASN1Decoder -public protocol ASN1DecoderProtocol: Decoder +protocol ASN1DecoderProtocol: Decoder { var dataToDecode: Data { get } func extractValueData() throws -> Data @@ -91,18 +91,18 @@ public protocol ASN1DecoderProtocol: Decoder extension _ASN1Decoder { - public var dataToDecode: Data + var dataToDecode: Data { return self.storage.current.rawData } - public func extractValueData() throws -> Data + func extractValueData() throws -> Data { return self.storage.current.valueData } } -//TODO: private -class _ASN1Decoder: ASN1DecoderProtocol + +final class _ASN1Decoder: ASN1DecoderProtocol { internal struct Storage { @@ -141,7 +141,7 @@ class _ASN1Decoder: ASN1DecoderProtocol } } - class State + final class State { var dataPtr: UnsafePointer var consumedMyself: Int @@ -170,9 +170,9 @@ class _ASN1Decoder: ASN1DecoderProtocol } } - public var codingPath: [CodingKey] = [] + var codingPath: [CodingKey] = [] - public var userInfo: [CodingUserInfoKey: Any] { return options.userInfo } + var userInfo: [CodingUserInfoKey: Any] { return options.userInfo } var options: ASN1Decoder.EncodingOptions! @@ -188,8 +188,7 @@ class _ASN1Decoder: ASN1DecoderProtocol self.options = options } - public init() - { + init() { self.storage = Storage() } @@ -200,7 +199,7 @@ class _ASN1Decoder: ASN1DecoderProtocol /// - returns: A keyed decoding container view into this decoder. /// - throws: `DecodingError.typeMismatch` if the encountered stored value is /// not a keyed container. - public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { let container = try ASN1KeyedDecodingContainer(referencing: self, wrapping: self.storage.current) return KeyedDecodingContainer(container) @@ -212,7 +211,7 @@ class _ASN1Decoder: ASN1DecoderProtocol /// - returns: An unkeyed container view into this decoder. /// - throws: `DecodingError.typeMismatch` if the encountered stored value is /// not an unkeyed container. - public func unkeyedContainer() throws -> UnkeyedDecodingContainer + func unkeyedContainer() throws -> UnkeyedDecodingContainer { return try ASN1UnkeyedDecodingContainer(referencing: self, wrapping: self.storage.current) } @@ -223,7 +222,7 @@ class _ASN1Decoder: ASN1DecoderProtocol /// - returns: A single value container view into this decoder. /// - throws: `DecodingError.typeMismatch` if the encountered stored value is /// not a single value container. - public func singleValueContainer() throws -> SingleValueDecodingContainer + func singleValueContainer() throws -> SingleValueDecodingContainer { return self } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Templates.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Templates.swift index 39155e68c..582d55bfd 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Templates.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Templates.swift @@ -7,7 +7,7 @@ import Foundation -public class ASN1Template +class ASN1Template { var expectedTags: [ASN1Tag] = [] @@ -16,19 +16,19 @@ public class ASN1Template expectedTags.append(kind) } - public func implicit(tag: ASN1Tag) -> ASN1Template + func implicit(tag: ASN1Tag) -> ASN1Template { return self } - public func explicit(tag: ASN1Tag) -> ASN1Template + func explicit(tag: ASN1Tag) -> ASN1Template { expectedTags.append(tag) return self } - public func constructed() -> ASN1Template + func constructed() -> ASN1Template { if expectedTags.isEmpty { @@ -45,7 +45,7 @@ public class ASN1Template } } -public extension ASN1Template +extension ASN1Template { static func contextSpecific(_ id: ASN1Tag) -> ASN1Template { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Types.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Types.swift index 7a93e3a00..500b84c01 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Types.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/ASN1Types.swift @@ -43,18 +43,18 @@ extension ASN1Object } } -public typealias ASN1Tag = UInt8 +typealias ASN1Tag = UInt8 -public struct ASN1SkippedField: ASN1Decodable +struct ASN1SkippedField: ASN1Decodable { - public var rawData: Data + var rawData: Data - public static var template: ASN1Template { ASN1Template.universal(0) } + static var template: ASN1Template { ASN1Template.universal(0) } } -public struct ASN1Null: ASN1Decodable +struct ASN1Null: ASN1Decodable { - public static var template: ASN1Template { ASN1Template.universal(ASN1Identifier.Tag.null) } + static var template: ASN1Template { ASN1Template.universal(ASN1Identifier.Tag.null) } } extension ASN1Tag @@ -86,77 +86,77 @@ extension ASN1Tag } } -public struct ASN1Identifier +struct ASN1Identifier { - public struct Modifiers + struct Modifiers { - public static let methodMask: UInt8 = 0x20 - public static let primitiv: UInt8 = 0x00 - public static let constructed: UInt8 = 0x20 + static let methodMask: UInt8 = 0x20 + static let primitiv: UInt8 = 0x00 + static let constructed: UInt8 = 0x20 - public static let classMask: UInt8 = 0xc0 - public static let universal: UInt8 = 0x00 - public static let application: UInt8 = 0x40 - public static let contextSpecific: UInt8 = 0x80 - public static let `private`: UInt8 = 0xc0 + static let classMask: UInt8 = 0xc0 + static let universal: UInt8 = 0x00 + static let application: UInt8 = 0x40 + static let contextSpecific: UInt8 = 0x80 + static let `private`: UInt8 = 0xc0 - public static let any: UInt32 = 0x00400 + static let any: UInt32 = 0x00400 } - public struct Tag + struct Tag { - public static let tagMask: UInt8 = 0xff - public static let tagNumMask: UInt8 = 0x7f + static let tagMask: UInt8 = 0xff + static let tagNumMask: UInt8 = 0x7f - public static let endOfContent: ASN1Tag = 0x00 - public static let boolean: ASN1Tag = 0x01 - public static let integer: ASN1Tag = 0x02 - public static let bitString: ASN1Tag = 0x03 - public static let octetString: ASN1Tag = 0x04 - public static let null: ASN1Tag = 0x05 - public static let objectIdentifier: ASN1Tag = 0x06 - public static let objectDescriptor: ASN1Tag = 0x07 - public static let external: ASN1Tag = 0x08 - public static let read: ASN1Tag = 0x09 - public static let enumerated: ASN1Tag = 0x0A - public static let embeddedPdv: ASN1Tag = 0x0B - public static let utf8String: ASN1Tag = 0x0C - public static let relativeOid: ASN1Tag = 0x0D - public static let sequence: ASN1Tag = 0x10 - public static let set: ASN1Tag = 0x11 - public static let numericString: ASN1Tag = 0x12 - public static let printableString: ASN1Tag = 0x13 - public static let t61String: ASN1Tag = 0x14 - public static let videotexString: ASN1Tag = 0x15 - public static let ia5String: ASN1Tag = 0x16 - public static let utcTime: ASN1Tag = 0x17 - public static let generalizedTime: ASN1Tag = 0x18 - public static let graphicString: ASN1Tag = 0x19 - public static let visibleString: ASN1Tag = 0x1A - public static let generalString: ASN1Tag = 0x1B - public static let universalString: ASN1Tag = 0x1C - public static let characterString: ASN1Tag = 0x1D - public static let bmpString: ASN1Tag = 0x1E - public static let highTag: ASN1Tag = 0x1f + static let endOfContent: ASN1Tag = 0x00 + static let boolean: ASN1Tag = 0x01 + static let integer: ASN1Tag = 0x02 + static let bitString: ASN1Tag = 0x03 + static let octetString: ASN1Tag = 0x04 + static let null: ASN1Tag = 0x05 + static let objectIdentifier: ASN1Tag = 0x06 + static let objectDescriptor: ASN1Tag = 0x07 + static let external: ASN1Tag = 0x08 + static let read: ASN1Tag = 0x09 + static let enumerated: ASN1Tag = 0x0A + static let embeddedPdv: ASN1Tag = 0x0B + static let utf8String: ASN1Tag = 0x0C + static let relativeOid: ASN1Tag = 0x0D + static let sequence: ASN1Tag = 0x10 + static let set: ASN1Tag = 0x11 + static let numericString: ASN1Tag = 0x12 + static let printableString: ASN1Tag = 0x13 + static let t61String: ASN1Tag = 0x14 + static let videotexString: ASN1Tag = 0x15 + static let ia5String: ASN1Tag = 0x16 + static let utcTime: ASN1Tag = 0x17 + static let generalizedTime: ASN1Tag = 0x18 + static let graphicString: ASN1Tag = 0x19 + static let visibleString: ASN1Tag = 0x1A + static let generalString: ASN1Tag = 0x1B + static let universalString: ASN1Tag = 0x1C + static let characterString: ASN1Tag = 0x1D + static let bmpString: ASN1Tag = 0x1E + static let highTag: ASN1Tag = 0x1f - public init() + init() { assertionFailure("You can't construct this struct") } - public static func custom(raw: UInt8) -> ASN1Tag + static func custom(raw: UInt8) -> ASN1Tag { return raw } } - public enum Method: UInt8 + enum Method: UInt8 { case primitive = 0x00 case constructed = 0x01 } - public enum Class: UInt8, RawRepresentable + enum Class: UInt8, RawRepresentable { case universal = 0x00 //0 case application = 0x01 //1 diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/DecodingError+Extensions.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/DecodingError+Extensions.swift index 0a53fc4df..cbda9944e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/DecodingError+Extensions.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/DecodingError+Extensions.swift @@ -7,7 +7,7 @@ import Foundation -public extension DecodingError +extension DecodingError { /// Returns a `.typeMismatch` error describing the expected type. /// diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/Foundation+ASN1Coder.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/Foundation+ASN1Coder.swift index 3ae9df322..c592295f8 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/Foundation+ASN1Coder.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/Foundation+ASN1Coder.swift @@ -9,7 +9,7 @@ import Foundation extension String: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.utf8String) } @@ -17,7 +17,7 @@ extension String: ASN1Decodable extension Int: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(2) } @@ -25,7 +25,7 @@ extension Int: ASN1Decodable extension Int32: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(2) } @@ -33,7 +33,7 @@ extension Int32: ASN1Decodable extension Data: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { assertionFailure("Provide template") return ASN1Template.universal(0) @@ -43,7 +43,7 @@ extension Data: ASN1Decodable extension String.Encoding { - public var template: ASN1Template + var template: ASN1Template { switch self { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/PKCS7/PKCS7.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/PKCS7/PKCS7.swift index a0c4f8ce3..0d5f81de4 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/PKCS7/PKCS7.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift/PKCS7/PKCS7.swift @@ -7,7 +7,7 @@ import Foundation -public enum OID: String +enum OID: String { /// NIST Algorithm case sha1 = "1.3.14.3.2.26" @@ -26,7 +26,7 @@ public enum OID: String case encryptedData = "1.2.840.113549.1.7.6" } -public extension OID +extension OID { @available(iOS 10.0, *) func encryptionAlgorithm() -> SecKeyAlgorithm @@ -48,10 +48,10 @@ public extension OID } } -public struct PKCS7Container: ASN1Decodable +struct PKCS7Container: ASN1Decodable { - public var oid: ASN1SkippedField - public private(set) var signedData: SignedData + var oid: ASN1SkippedField + private(set) var signedData: SignedData enum CodingKeys: ASN1CodingKey { @@ -72,20 +72,20 @@ public struct PKCS7Container: ASN1Decodable } -public extension PKCS7Container +extension PKCS7Container { struct SignedData: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.contextSpecific(0).constructed().explicit(tag: 16).constructed() } - public var version: Int32 - public var alg: DigestAlgorithmIdentifiersContainer - public var contentInfo: ContentInfo - public var certificates: CetrificatesContaner - public var signerInfos: SignerInfos + var version: Int32 + var alg: DigestAlgorithmIdentifiersContainer + var contentInfo: ContentInfo + var certificates: CetrificatesContaner + var signerInfos: SignerInfos enum CodingKeys: ASN1CodingKey { @@ -116,9 +116,9 @@ public extension PKCS7Container struct DigestAlgorithmIdentifiersContainer: ASN1Decodable { - public var items: [Item] + var items: [Item] - public init(from decoder: Decoder) throws + init(from decoder: Decoder) throws { var container: UnkeyedDecodingContainer = try decoder.unkeyedContainer() @@ -132,12 +132,12 @@ public extension PKCS7Container self.items = items } - public static var template: ASN1Template { ASN1Template.universal(ASN1Identifier.Tag.set).constructed() } + static var template: ASN1Template { ASN1Template.universal(ASN1Identifier.Tag.set).constructed() } - public struct Item: ASN1Decodable + struct Item: ASN1Decodable { - public var algorithm: String - public var parameters: ASN1Null + var algorithm: String + var parameters: ASN1Null enum CodingKeys: ASN1CodingKey { @@ -156,7 +156,7 @@ public extension PKCS7Container } } - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.sequence).constructed() } @@ -166,13 +166,13 @@ public extension PKCS7Container struct ContentInfo: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.sequence).constructed() } - public var oid: ASN1SkippedField - public var payload: ASN1SkippedField + var oid: ASN1SkippedField + var payload: ASN1SkippedField enum CodingKeys: ASN1CodingKey { @@ -195,11 +195,11 @@ public extension PKCS7Container struct Certificate: ASN1Decodable { - public var cert: TPSCertificate - public var signatureAlgorithm: ASN1SkippedField - public var signatureValue: Data + var cert: TPSCertificate + var signatureAlgorithm: ASN1SkippedField + var signatureValue: Data - public var rawData: Data + var rawData: Data enum CodingKeys: ASN1CodingKey { @@ -221,7 +221,7 @@ public extension PKCS7Container } } - public init(from decoder: Decoder) throws + init(from decoder: Decoder) throws { let dec = decoder as! ASN1DecoderProtocol let container = try decoder.container(keyedBy: CodingKeys.self) @@ -232,7 +232,7 @@ public extension PKCS7Container self.signatureValue = try container.decode(Data.self, forKey: .signatureValue) } - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.sequence).constructed() } @@ -240,14 +240,14 @@ public extension PKCS7Container struct TPSCertificate: ASN1Decodable { - public var version: Int - public var serialNumber: Int - public var signature: ASN1SkippedField - public var issuer: ASN1SkippedField - public var validity: ASN1SkippedField - public var subject: ASN1SkippedField - public var subjectPublicKeyInfo: Data // We will need only this field - public var extensions: ASN1SkippedField + var version: Int + var serialNumber: Int + var signature: ASN1SkippedField + var issuer: ASN1SkippedField + var validity: ASN1SkippedField + var subject: ASN1SkippedField + var subjectPublicKeyInfo: Data // We will need only this field + var extensions: ASN1SkippedField enum CodingKeys: ASN1CodingKey { @@ -284,7 +284,7 @@ public extension PKCS7Container } } - public init(from decoder: Decoder) throws + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -301,7 +301,7 @@ public extension PKCS7Container self.extensions = try container.decode(ASN1SkippedField.self, forKey: .extensions) } - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.sequence).constructed() } @@ -309,9 +309,9 @@ public extension PKCS7Container struct CetrificatesContaner: ASN1Decodable { - public let certificates: [Certificate] + let certificates: [Certificate] - public init(from decoder: Decoder) throws + init(from decoder: Decoder) throws { var container: UnkeyedDecodingContainer = try decoder.unkeyedContainer() @@ -324,7 +324,7 @@ public extension PKCS7Container self.certificates = certificates } - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.contextSpecific(0).constructed().implicit(tag: ASN1Identifier.Tag.sequence) } @@ -332,16 +332,16 @@ public extension PKCS7Container struct SignerInfos: ASN1Decodable { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(ASN1Identifier.Tag.set).constructed().explicit(tag: ASN1Identifier.Tag.sequence).constructed() } - public var version: Int - public var signerIdentifier: ASN1SkippedField - public var digestAlgorithm: ASN1SkippedField - public var digestEncryptionAlgorithm: ASN1SkippedField - public var encryptedDigest: Data + var version: Int + var signerIdentifier: ASN1SkippedField + var digestAlgorithm: ASN1SkippedField + var digestEncryptionAlgorithm: ASN1SkippedField + var encryptedDigest: Data enum CodingKeys: ASN1CodingKey { @@ -373,7 +373,7 @@ public extension PKCS7Container extension PKCS7Container { - public static var template: ASN1Template + static var template: ASN1Template { return ASN1Template.universal(16).constructed() } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ReceiptManager.swift index e43ae71f7..a819f3843 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ReceiptManager.swift @@ -12,54 +12,36 @@ actor ReceiptManager: NSObject { var purchasedSubscriptionGroupIds: Set? private var purchases: Set = [] private var receiptRefreshCompletion: ((Bool) -> Void)? - private weak var delegate: ProductsFetcher? + private weak var delegate: ProductsFetcherSK1? private let receiptData: () -> Data? + private let purchaseController: InternalPurchaseController init( - delegate: ProductsFetcher, + delegate: ProductsFetcherSK1, + purchaseController: InternalPurchaseController, receiptData: @escaping () -> Data? = ReceiptLogic.getReceiptData ) { self.delegate = delegate self.receiptData = receiptData + self.purchaseController = purchaseController } /// Loads purchased products from the receipt, storing the purchased subscription group identifiers, /// purchases and active purchases. @discardableResult func loadPurchasedProducts() async -> Set? { - let hasPurchaseController = Superwall.shared.dependencyContainer.delegateAdapter.hasPurchaseController - - guard let payload = ReceiptLogic.getPayload(using: receiptData) else { - if !hasPurchaseController { - await MainActor.run { - Superwall.shared.subscriptionStatus = .inactive - } - } - return nil - } - guard let delegate = delegate else { - if !hasPurchaseController { - await MainActor.run { - Superwall.shared.subscriptionStatus = .inactive - } - } + guard + let payload = ReceiptLogic.getPayload(using: receiptData), + let delegate = delegate + else { + await purchaseController.syncSubscriptionStatus(withPurchases: []) return nil } let purchases = payload.purchases self.purchases = purchases - - if !hasPurchaseController { - let activePurchases = purchases.filter { $0.isActive } - await MainActor.run { - if activePurchases.isEmpty { - Superwall.shared.subscriptionStatus = .inactive - } else { - Superwall.shared.subscriptionStatus = .active - } - } - } + await purchaseController.syncSubscriptionStatus(withPurchases: purchases) let purchasedProductIds = Set(purchases.map { $0.productIdentifier }) diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index ed992cede..90f36e08b 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -1,13 +1,18 @@ import Foundation +import StoreKit import Combine actor StoreKitManager { - /// Coordinates: The purchasing, restoring and retrieving of products; the checking - /// of transactions; and the determining of the user's subscription status. - lazy var coordinator = factory.makeStoreKitCoordinator() - private unowned let factory: StoreKitCoordinatorFactory - private lazy var receiptManager = ReceiptManager(delegate: self) + /// Handler purchasing and restoring. + let purchaseController: InternalPurchaseController + /// Retrieves products from storekit. + private let productsFetcher: ProductsFetcherSK1 + + private lazy var receiptManager = ReceiptManager( + delegate: productsFetcher, + purchaseController: purchaseController + ) private(set) var productsById: [String: StoreProduct] = [:] private struct ProductProcessingResult { let productIdsToLoad: Set @@ -15,8 +20,13 @@ actor StoreKitManager { let products: [Product] } - init(factory: StoreKitCoordinatorFactory) { - self.factory = factory + init( + purchaseController: InternalPurchaseController, + productsFetcher: ProductsFetcherSK1 = ProductsFetcherSK1() + ) { + self.productsFetcher = productsFetcher + self.purchaseController = purchaseController + purchaseController.delegate = self } func getProductVariables(for paywall: Paywall) async -> [ProductVariable] { @@ -52,7 +62,7 @@ actor StoreKitManager { responseProducts: responseProducts ) - let products = try await products( + let products = try await productsFetcher.products( identifiers: processingResult.productIdsToLoad, forPaywall: paywallName ) @@ -122,79 +132,12 @@ actor StoreKitManager { } // MARK: - Restoration -extension StoreKitManager { - @MainActor - func tryToRestore(_ paywallViewController: PaywallViewController) async { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Attempting Restore" - ) - - paywallViewController.loadingState = .loadingPurchase - - let restorationResult = await coordinator.txnRestorer.restorePurchases() - - await processRestoration( - restorationResult: restorationResult, - paywallViewController: paywallViewController - ) - } - - /// After restoring, it checks to see whether the user is actually subscribed or not. - /// - /// This is accessed by both the transaction manager and the restoration manager. - @MainActor - func processRestoration( - restorationResult: RestorationResult, - paywallViewController: PaywallViewController - ) async { - let hasRestored = restorationResult == .restored - - if !Superwall.shared.dependencyContainer.delegateAdapter.hasPurchaseController { - await refreshReceipt() - if hasRestored { - await loadPurchasedProducts() - } - } - - let isUserSubscribed = Superwall.shared.subscriptionStatus == .active - - if hasRestored && isUserSubscribed { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transactions Restored" - ) - await transactionWasRestored(paywallViewController: paywallViewController) - } else { - Logger.debug( - logLevel: .debug, - scope: .paywallTransactions, - message: "Transactions Failed to Restore" - ) - - paywallViewController.presentAlert( - title: Superwall.shared.options.paywalls.restoreFailed.title, - message: Superwall.shared.options.paywalls.restoreFailed.message, - closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle - ) - } - } - - private func transactionWasRestored(paywallViewController: PaywallViewController) async { - let paywallInfo = await paywallViewController.info - - let trackedEvent = InternalSuperwallEvent.Transaction( - state: .restore, - paywallInfo: paywallInfo, - product: nil, - model: nil - ) - await Superwall.shared.track(trackedEvent) - - if Superwall.shared.options.paywalls.automaticallyDismiss { - await Superwall.shared.dismiss(paywallViewController, result: .restored) +extension StoreKitManager: RestoreDelegate { + func didRestore(result: RestorationResult) async { + let hasRestored = result == .restored + await refreshReceipt() + if hasRestored { + await loadPurchasedProducts() } } } @@ -233,16 +176,3 @@ extension StoreKitManager { return await receiptManager.isFreeTrialAvailable(for: product) } } - -// MARK: - ProductsFetcher -extension StoreKitManager: ProductsFetcher { - nonisolated func products( - identifiers: Set, - forPaywall paywallName: String? - ) async throws -> Set { - return try await coordinator.productFetcher.products( - identifiers: identifiers, - forPaywall: paywallName - ) - } -} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationProtocols.swift b/Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationProtocols.swift new file mode 100644 index 000000000..76b42f408 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationProtocols.swift @@ -0,0 +1,27 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 15/09/2023. +// + +import Foundation +import UserNotifications + +protocol NotificationSettings { + var authorizationStatus: UNAuthorizationStatus { get } +} + +extension UNNotificationSettings: NotificationSettings {} + +protocol NotificationAuthorizable { + func requestAuthorization(options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void) + func getSettings(completionHandler: @escaping (NotificationSettings) -> Void) + func add(_ request: UNNotificationRequest) async throws +} + +extension UNUserNotificationCenter: NotificationAuthorizable { + func getSettings(completionHandler: @escaping (NotificationSettings) -> Void) { + getNotificationSettings(completionHandler: completionHandler) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/NotificationScheduler.swift b/Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationScheduler.swift similarity index 60% rename from Sources/SuperwallKit/StoreKit/Transactions/NotificationScheduler.swift rename to Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationScheduler.swift index cd34d0ec6..3c4dbea2e 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/NotificationScheduler.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Notifications/NotificationScheduler.swift @@ -11,15 +11,15 @@ import UserNotifications enum NotificationScheduler { static let superwallIdentifier = "com.superwall.ios" - private static func askForPermissionsIfNecessary() async -> Bool { - if await checkIsAuthorized() { + private static func askForPermissionsIfNecessary( + using notificationCenter: NotificationAuthorizable + ) async -> Bool { + if await checkIsAuthorized(using: notificationCenter) { return true } - let center = UNUserNotificationCenter.current() - return await withCheckedContinuation { continuation in - center.requestAuthorization(options: [.alert, .sound, .badge]) { isAuthorized, _ in + notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { isAuthorized, _ in if isAuthorized { return continuation.resume(returning: true) } else { @@ -29,32 +29,47 @@ enum NotificationScheduler { } } - static func scheduleNotifications(_ notifications: [LocalNotification]) async { + static func scheduleNotifications( + _ notifications: [LocalNotification], + factory: DeviceHelperFactory, + notificationCenter: NotificationAuthorizable = UNUserNotificationCenter.current() + ) async { if notifications.isEmpty { return } - guard await NotificationScheduler.askForPermissionsIfNecessary() else { + guard await NotificationScheduler.askForPermissionsIfNecessary(using: notificationCenter) else { return } await withTaskGroup(of: Void.self) { taskGroup in for notification in notifications { taskGroup.addTask { - await scheduleNotification(notification) + await scheduleNotification(notification, factory: factory, notificationCenter: notificationCenter) } } } } - private static func scheduleNotification(_ notification: LocalNotification) async { + private static func scheduleNotification( + _ notification: LocalNotification, + factory: DeviceHelperFactory, + notificationCenter: NotificationAuthorizable + ) async { let content = UNMutableNotificationContent() content.title = notification.title content.subtitle = notification.subtitle ?? "" content.body = notification.body + var delay = notification.delay / 1000 + + let isSandbox = factory.makeIsSandbox() + if isSandbox { + delay = delay / 24 / 60 + } + // Show this notification X seconds from now. let trigger = UNTimeIntervalNotificationTrigger( - timeInterval: notification.delay / 1000, + timeInterval: delay, repeats: false ) @@ -67,7 +82,7 @@ enum NotificationScheduler { // Add our notification request do { - try await UNUserNotificationCenter.current().add(request) + try await notificationCenter.add(request) } catch { Logger.debug( logLevel: .warn, @@ -77,9 +92,9 @@ enum NotificationScheduler { } } - private static func checkIsAuthorized() async -> Bool { + private static func checkIsAuthorized(using notificationCenter: NotificationAuthorizable) async -> Bool { return await withCheckedContinuation { continuation in - UNUserNotificationCenter.current().getNotificationSettings { settings in + notificationCenter.getSettings { settings in switch settings.authorizationStatus { case .authorized, .ephemeral, diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserLogic.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserLogic.swift index 634ee8ecf..7a9da8559 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserLogic.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserLogic.swift @@ -9,10 +9,31 @@ import Foundation import StoreKit enum ProductPurchaserLogic { + /// Validates the latest transaction static func validate( transaction: SKPaymentTransaction, + since purchaseDate: Date?, withProductId productId: String - ) throws { + ) async throws { + guard let purchaseDate = purchaseDate else { + throw PurchaseError.unknown + } + + // Get latest transaction since the purchase date using sk2. + // Check that it's verified. + if #available(iOS 15.0, *) { + if let verificationResult = await Transaction.latest( + for: productId, + since: purchaseDate + ) { + guard case .verified = verificationResult else { + throw PurchaseError.unverifiedTransaction + } + return + } + } + + // Otherwise, check that the local receipt is valid. guard transaction.payment.productIdentifier == productId else { throw PurchaseError.noTransactionDetected } @@ -20,7 +41,6 @@ enum ProductPurchaserLogic { throw PurchaseError.noTransactionDetected } - // Validation guard let localReceipt = try? InAppReceipt() else { throw PurchaseError.unverifiedTransaction } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift index 05b2c4151..930a809f7 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/ProductPurchaserSK1.swift @@ -10,29 +10,7 @@ import StoreKit final class ProductPurchaserSK1: NSObject { // MARK: Purchasing - actor Purchasing { - private var completion: ((PurchaseResult) -> Void)? - private var productId: String? - - func productId(is productId: String) -> Bool { - return productId == self.productId - } - - func setCompletion(_ completion: @escaping (PurchaseResult) -> Void) { - self.completion = completion - } - - func setProductId(_ productId: String) { - self.productId = productId - } - - func completePurchase(result: PurchaseResult) { - completion?(result) - self.completion = nil - self.productId = nil - } - } - private let purchasing = Purchasing() + let coordinator = PurchasingCoordinator() // MARK: Restoration final class Restoration { @@ -46,13 +24,9 @@ final class ProductPurchaserSK1: NSObject { } private let restoration = Restoration() - /// The latest transaction, used for purchasing and verifying purchases. - private var latestTransaction: SKPaymentTransaction? - // MARK: Dependencies - private unowned let storeKitManager: StoreKitManager - private unowned let sessionEventsManager: SessionEventsManager - private unowned let delegateAdapter: SuperwallDelegateAdapter + private weak var storeKitManager: StoreKitManager? + private weak var sessionEventsManager: SessionEventsManager? private let factory: StoreTransactionFactory deinit { @@ -62,84 +36,35 @@ final class ProductPurchaserSK1: NSObject { init( storeKitManager: StoreKitManager, sessionEventsManager: SessionEventsManager, - delegateAdapter: SuperwallDelegateAdapter, factory: StoreTransactionFactory ) { self.storeKitManager = storeKitManager self.sessionEventsManager = sessionEventsManager - self.delegateAdapter = delegateAdapter self.factory = factory super.init() SKPaymentQueue.default().add(self) } -} -// MARK: - ProductPurchaser -extension ProductPurchaserSK1: ProductPurchaser { /// Purchases a product, waiting for the completion block to be fired and /// returning a purchase result. - func purchase(product: StoreProduct) async -> PurchaseResult { - guard let sk1Product = product.sk1Product else { - return .failed(PurchaseError.productUnavailable) - } - - await purchasing.setProductId(product.productIdentifier) + func purchase(product: SKProduct) async -> PurchaseResult { + await coordinator.beginPurchase(of: product.productIdentifier) let task = Task { return await withCheckedContinuation { continuation in Task { - await purchasing.setCompletion { result in + await coordinator.setCompletion { result in continuation.resume(returning: result) } } } } - let payment = SKPayment(product: sk1Product) + let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) return await task.value } -} - -// MARK: - TransactionChecker -extension ProductPurchaserSK1: TransactionChecker { - /// Checks that a product has been purchased based on the last transaction - /// received on the queue. If user is not using a ``PurchaseController``, it - /// checks that the receipts are valid. - /// - /// The receipts are updated on successful purchase. - /// - /// Read more in [Apple's docs](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/choosing_a_receipt_validation_technique#//apple_ref/doc/uid/TP40010573). - func getAndValidateLatestTransaction( - of productId: String, - hasPurchaseController: Bool - ) async throws -> StoreTransaction? { - if hasPurchaseController { - if let latestTransaction = latestTransaction { - let storeTransaction = await factory.makeStoreTransaction(from: latestTransaction) - return storeTransaction - } - return nil - } - - guard let latestTransaction = latestTransaction else { - throw PurchaseError.noTransactionDetected - } - - try ProductPurchaserLogic.validate( - transaction: latestTransaction, - withProductId: productId - ) - let storeTransaction = await factory.makeStoreTransaction(from: latestTransaction) - self.latestTransaction = nil - - return storeTransaction - } -} - -// MARK: - TransactionRestorer -extension ProductPurchaserSK1: TransactionRestorer { func restorePurchases() async -> RestorationResult { let result = await withCheckedContinuation { continuation in // Using restoreCompletedTransactions instead of just refreshing @@ -191,13 +116,12 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { Task { let isPaywallPresented = Superwall.shared.isPaywallPresented let paywallViewController = Superwall.shared.paywallViewController + let purchaseDate = await coordinator.purchaseDate for transaction in transactions { - latestTransaction = transaction + await coordinator.storeIfPurchased(transaction) await checkForTimeout(of: transaction, in: paywallViewController) - await updatePurchaseCompletionBlock(for: transaction) + await updatePurchaseCompletionBlock(for: transaction, purchaseDate: purchaseDate) await checkForRestoration(transaction, isPaywallPresented: isPaywallPresented) - finishIfPossible(transaction) - Task(priority: .background) { await record(transaction) } @@ -243,21 +167,49 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { } /// Sends a `PurchaseResult` to the completion block and stores the latest purchased transaction. - private func updatePurchaseCompletionBlock(for transaction: SKPaymentTransaction) async { - guard await purchasing.productId(is: transaction.payment.productIdentifier) else { + private func updatePurchaseCompletionBlock( + for skTransaction: SKPaymentTransaction, + purchaseDate: Date? + ) async { + // Only continue if using internal purchase controller. The transaction may be + // readded to the queue if finishing fails so we need to make sure + // we can re-finish the transaction. + if storeKitManager?.purchaseController.hasExternalPurchaseController == true { return } - switch transaction.transactionState { + switch skTransaction.transactionState { case .purchased: - await purchasing.completePurchase(result: .purchased) + do { + try await ProductPurchaserLogic.validate( + transaction: skTransaction, + since: purchaseDate, + withProductId: skTransaction.payment.productIdentifier + ) + SKPaymentQueue.default().finishTransaction(skTransaction) + + await coordinator.completePurchase( + of: skTransaction, + result: .purchased + ) + } catch { + SKPaymentQueue.default().finishTransaction(skTransaction) + await coordinator.completePurchase( + of: skTransaction, + result: .failed(error) + ) + } case .failed: - if let error = transaction.error { + SKPaymentQueue.default().finishTransaction(skTransaction) + if let error = skTransaction.error { if let error = error as? SKError { switch error.code { case .paymentCancelled, .overlayCancelled: - return await purchasing.completePurchase(result: .cancelled) + return await coordinator.completePurchase( + of: skTransaction, + result: .cancelled + ) default: break } @@ -265,16 +217,22 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { if #available(iOS 14, *) { switch error.code { case .overlayTimeout: - return await purchasing.completePurchase(result: .cancelled) + await coordinator.completePurchase( + of: skTransaction, + result: .cancelled + ) default: break } } } - await purchasing.completePurchase(result: .failed(error)) + await coordinator.completePurchase( + of: skTransaction, + result: .failed(error)) } case .deferred: - await purchasing.completePurchase(result: .pending) + SKPaymentQueue.default().finishTransaction(skTransaction) + await coordinator.completePurchase(of: skTransaction, result: .pending) default: break } @@ -285,43 +243,55 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { _ transaction: SKPaymentTransaction, isPaywallPresented: Bool ) async { - guard let product = await storeKitManager.productsById[transaction.payment.productIdentifier] else { + guard case .restored = transaction.transactionState else { return } - guard isPaywallPresented else { + SKPaymentQueue.default().finishTransaction(transaction) + guard let product = await storeKitManager?.productsById[transaction.payment.productIdentifier] else { return } - switch transaction.transactionState { - case .restored: - await sessionEventsManager.triggerSession.trackTransactionRestoration( - withId: transaction.transactionIdentifier, - product: product - ) - default: - break + guard isPaywallPresented else { + return } + + await sessionEventsManager?.triggerSession.trackTransactionRestoration( + withId: transaction.transactionIdentifier, + product: product + ) } - /// Finishes transactions only if the delegate doesn't return a ``PurchaseController``. - private func finishIfPossible(_ transaction: SKPaymentTransaction) { - if delegateAdapter.hasPurchaseController { - return + @available(iOS 15.0, *) + private func hasRestored( + _ transaction: StoreTransaction, + purchaseStartAt: Date? + ) -> Bool { + guard let purchaseStartAt = purchaseStartAt else { + return false } - - switch transaction.transactionState { - case .purchased, - .failed, - .restored: - SKPaymentQueue.default().finishTransaction(transaction) - default: - break + // If has a transaction date and that happened before purchase + // button was pressed... + if let transactionDate = transaction.transactionDate, + transactionDate < purchaseStartAt { + // ...and if it has an expiration date that expires in the future, + // then we must have restored. + if let expirationDate = transaction.expirationDate { + if expirationDate >= Date() { + return true + } + } else { + // If no expiration date, it must be a non-consumable product + // which has been restored. + return true + } } + + 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) + await sessionEventsManager?.enqueue(storeTransaction) } /// Loads purchased products in the StoreKitManager if a purchase or restore has occurred. @@ -331,6 +301,6 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver { ) == nil { return } - await storeKitManager.loadPurchasedProducts() + await storeKitManager?.loadPurchasedProducts() } } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift new file mode 100644 index 000000000..579535e35 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 29/08/2023. +// + +import Foundation + +enum PurchaseError: LocalizedError { + case productUnavailable + case unknown + case noTransactionDetected + case unverifiedTransaction + + var errorDescription: String? { + switch self { + case .productUnavailable: + return "There was an error retrieving the product to purchase." + case .noTransactionDetected: + return "No receipt was found on device for the product transaction." + case .unverifiedTransaction: + return "The product transaction could not be verified." + case .unknown: + return "An unknown error occurred." + } + } +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseManager.swift deleted file mode 100644 index 488126711..000000000 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseManager.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 06/12/2022. -// - -import Foundation -import StoreKit - -enum PurchaseError: LocalizedError { - case productUnavailable - case unknown - case noTransactionDetected - case unverifiedTransaction - - var errorDescription: String? { - switch self { - case .productUnavailable: - return "There was an error retrieving the product to purchase." - case .noTransactionDetected: - return "No receipt was found on device for the product transaction." - case .unverifiedTransaction: - return "The product transaction could not be verified." - case .unknown: - return "An unknown error occurred." - } - } -} - -struct PurchaseManager { - unowned let storeKitManager: StoreKitManager - let hasPurchaseController: Bool - - /// Purchases the product and then checks for a transaction - func purchase(product: StoreProduct) async -> InternalPurchaseResult { - let purchaseStartAt = Date() - - let result = await storeKitManager.coordinator.productPurchaser.purchase(product: product) - - switch result { - case .failed(let error): - return .failed(error) - case .pending: - return .pending - case .cancelled: - return .cancelled - case .purchased: - do { - let transaction = try await storeKitManager.coordinator.txnChecker.getAndValidateLatestTransaction( - of: product.productIdentifier, - hasPurchaseController: hasPurchaseController - ) - - if hasRestored( - transaction, - hasPurchaseController: hasPurchaseController, - purchaseStartAt: purchaseStartAt - ) { - return .restored - } - - return .purchased(transaction) - } catch { - return .failed(error) - } - } - } - - /// Checks whether the purchased product was actually a restoration. This happens (in sandbox), - /// when a user purchases, then deletes the app, then launches the paywall and purchases again. - private func hasRestored( - _ transaction: StoreTransaction?, - hasPurchaseController: Bool, - purchaseStartAt: Date - ) -> Bool { - if hasPurchaseController { - return false - } - guard let transaction = transaction else { - return false - } - - // If has a transaction date and that happened before purchase - // button was pressed... - if let transactionDate = transaction.transactionDate, - transactionDate < purchaseStartAt { - // ...and if it has an expiration date that expires in the future, - // then we must have restored. - if let expirationDate = transaction.expirationDate { - if expirationDate >= Date() { - return true - } - } else { - // If no expiration date, it must be a non-consumable product - // which has been restored. - return true - } - } - - return false - } -} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift new file mode 100644 index 000000000..e054bfc13 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchasingCoordinator.swift @@ -0,0 +1,135 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 14/09/2023. +// + +import Foundation +import StoreKit + +/// An actor that manages and coordinates the storing of types associated with purchasing. +actor PurchasingCoordinator { + private var completion: ((PurchaseResult) -> Void)? + var productId: String? + var lastInternalTransaction: SKPaymentTransaction? + var purchaseDate: Date? + var transactions: [String: SKPaymentTransaction] = [:] + + /// A boolean indicating whether the given `date` is within an hour of the `purchaseDate`. + func dateIsWithinLastHour(_ transactionDate: Date?) -> Bool { + guard + let transactionDate = transactionDate, + let purchaseDate = purchaseDate + else { + return false + } + return transactionDate.isWithinAnHourBefore(purchaseDate) + } + + func setCompletion(_ completion: @escaping (PurchaseResult) -> Void) { + self.completion = completion + } + + func beginPurchase(of productId: String) { + self.purchaseDate = Date() + self.productId = productId + } + + /// Gets the latest transaction of a specified product ID. + func getLatestTransaction( + forProductId productId: String, + factory: StoreTransactionFactory + ) async -> StoreTransaction? { + // Get the date a purchase was initiated. This can never be nil after + // a purchase. + guard let purchaseDate = purchaseDate else { + return nil + } + + // If on iOS 15+, try and get latest transaction using SK2. + if #available(iOS 15.0, *) { + if let verificationResult = await Transaction.latest( + for: productId, + since: purchaseDate + ) { + // Skip verification step as this has already been done. + let transaction = verificationResult.unsafePayloadValue + return await factory.makeStoreTransaction(from: transaction) + } + } + + // If no transaction retrieved, try to get last transaction if + // the SDK handled purchasing. + if let transaction = lastInternalTransaction { + return await factory.makeStoreTransaction(from: transaction) + } + + func getLastExternalStoreTransaction() async -> StoreTransaction? { + if let transaction = transactions[productId], + dateIsWithinLastHour(transaction.transactionDate) { + return await factory.makeStoreTransaction(from: transaction) + } + return nil + } + + // Otherwise get the last externally purchased transaction from the payment queue. + if let transaction = await getLastExternalStoreTransaction() { + return transaction + } + + // If still no transaction, wait 500ms and try again before returning nil. + try? await Task.sleep(nanoseconds: 500_000_000) + + if let transaction = await getLastExternalStoreTransaction() { + return transaction + } + + return nil + } + + /// Stores the transaction if purchased and is the latest for a specific product ID. + /// This is used as a fallback if we can't retrieve the transaction using SK2. + func storeIfPurchased(_ transaction: SK1Transaction) async { + guard case .purchased = transaction.transactionState else { + return + } + let productId = transaction.payment.productIdentifier + + // If there is an existing transaction, which has a transaction date... + if let existingTransaction = transactions[productId], + let existingTransactionDate = existingTransaction.transactionDate { + // And if the new transaction date was after the stored transaction, update. + // Else, ignore. + if transaction.transactionDate?.compare(existingTransactionDate) == .orderedDescending { + transactions[productId] = transaction + } + } else { + // If there isn't an existing transaction, store. + transactions[productId] = transaction + } + } + + func completePurchase( + of transaction: SK1Transaction, + result: PurchaseResult + ) { + // Only complete if the product ID of the transaction is the same as + // the purchasing transaction. + guard productId == transaction.payment.productIdentifier else { + return + } + // If the transaction completed a purchase, check it is within the last + // hour since starting purchase. Otherwise old purchased products may come + // through and complete the purchase. + if result == .purchased { + guard dateIsWithinLastHour(transaction.transactionDate) else { + return + } + } + lastInternalTransaction = transaction + completion?(result) + completion = nil + productId = nil + } +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionErrorLogic.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionErrorLogic.swift index 76caec8f1..0575c9c8b 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionErrorLogic.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionErrorLogic.swift @@ -8,8 +8,12 @@ import Foundation import StoreKit +/// The error that occurred when a transaction failed. public enum TransactionError: Error { + /// The transaction is pending approval via a parent or guardian. case pending(String) + + /// The transaction failed. case failure(String, StoreProduct) } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 8695745f5..39a6cea91 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -13,8 +13,13 @@ import Combine final class TransactionManager { private unowned let storeKitManager: StoreKitManager private unowned let sessionEventsManager: SessionEventsManager - private let purchaseManager: PurchaseManager - private let factory: PurchaseManagerFactory & OptionsFactory & TriggerFactory + typealias Factories = ProductPurchaserFactory + & OptionsFactory + & TriggerFactory + & StoreTransactionFactory + & DeviceHelperFactory + & PurchasedTransactionsFactory + private let factory: Factories /// The paywall view controller that the last product was purchased from. private var lastPaywallViewController: PaywallViewController? @@ -22,12 +27,11 @@ final class TransactionManager { init( storeKitManager: StoreKitManager, sessionEventsManager: SessionEventsManager, - factory: PurchaseManagerFactory & OptionsFactory & TriggerFactory + factory: Factories ) { self.storeKitManager = storeKitManager self.sessionEventsManager = sessionEventsManager self.factory = factory - purchaseManager = factory.makePurchaseManager() } /// Purchases the given product and handles the result appropriately. @@ -35,7 +39,7 @@ final class TransactionManager { /// - Parameters: /// - productId: The ID of the product to purchase. /// - paywallViewController: The `PaywallViewController` that the product is being - /// purhcased from. + /// purchased from. func purchase( _ productId: String, from paywallViewController: PaywallViewController @@ -46,14 +50,13 @@ final class TransactionManager { await prepareToStartTransaction(of: product, from: paywallViewController) - let result = await purchaseManager.purchase(product: product) + let result = await purchase(product) switch result { - case .purchased(let transaction): + case .purchased: await didPurchase( product, - from: paywallViewController, - transaction: transaction + from: paywallViewController ) case .failed(let error): let superwallOptions = factory.makeSuperwallOptions() @@ -87,11 +90,6 @@ final class TransactionManager { paywallViewController: paywallViewController ) } - case .restored: - await storeKitManager.processRestoration( - restorationResult: .restored, - paywallViewController: paywallViewController - ) case .pending: await handlePendingTransaction(from: paywallViewController) case .cancelled: @@ -99,6 +97,13 @@ final class TransactionManager { } } + private func purchase(_ product: StoreProduct) async -> PurchaseResult { + guard let sk1Product = product.sk1Product else { + return .failed(PurchaseError.productUnavailable) + } + return await storeKitManager.purchaseController.purchase(product: sk1Product) + } + /// Cancels the transaction timeout when the application resigns active. /// /// When the purchase sheet appears, the application resigns active. @@ -167,8 +172,7 @@ final class TransactionManager { /// Dismisses the view controller, if the developer hasn't disabled the option. private func didPurchase( _ product: StoreProduct, - from paywallViewController: PaywallViewController, - transaction: StoreTransaction? + from paywallViewController: PaywallViewController ) async { Logger.debug( logLevel: .debug, @@ -181,6 +185,12 @@ final class TransactionManager { error: nil ) + let purchasingCoordinator = factory.makePurchasingCoordinator() + let transaction = await purchasingCoordinator.getLatestTransaction( + forProductId: product.productIdentifier, + factory: factory + ) + if let transaction = transaction { await self.sessionEventsManager.enqueue(transaction) } @@ -314,7 +324,7 @@ final class TransactionManager { $0.type == .trialStarted } - await NotificationScheduler.scheduleNotifications(notifications) + await NotificationScheduler.scheduleNotifications(notifications, factory: factory) } else { let trackedEvent = InternalSuperwallEvent.SubscriptionStart( paywallInfo: paywallInfo, diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionVerifierSK2.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionVerifierSK2.swift deleted file mode 100644 index 07975b60e..000000000 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionVerifierSK2.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 16/01/2023. -// - -import Foundation -import StoreKit - -@available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) -final class TransactionVerifierSK2: TransactionChecker { - let factory: StoreTransactionFactory - - init(factory: StoreTransactionFactory) { - self.factory = factory - } - - /// An iOS 15+-only function that checks for a transaction of the product. - /// - /// We need this function because on iOS 15+, the `Transaction.updates` listener doesn't notify us - /// of transactions for recent purchases. - func getAndValidateLatestTransaction( - of productId: String, - hasPurchaseController: Bool - ) async throws -> StoreTransaction? { - let verificationResult = await Transaction.latest(for: productId) - - // If the user provided a ``PurchaseController``, return the transaction without - // verifying. Otherwise, verify the transaction. - if hasPurchaseController { - if let transaction = verificationResult.map({ $0.unsafePayloadValue }) { - return await factory.makeStoreTransaction(from: transaction) - } - return nil - } - - guard case let .verified(transaction) = verificationResult else { - throw PurchaseError.unverifiedTransaction - } - - return await factory.makeStoreTransaction(from: transaction) - } -} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 3e1e40478..d15bc50e9 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -6,7 +6,7 @@ import Combine /// The primary class for integrating Superwall into your application. After configuring via /// ``configure(apiKey:purchaseController:options:completion:)-52tke``, it provides access to -/// all its featured via instance functions and variables. +/// all its features via instance functions and variables. @objcMembers public final class Superwall: NSObject, ObservableObject { // MARK: - Public Properties @@ -465,36 +465,18 @@ extension Superwall: PaywallViewControllerEventDelegate { switch paywallEvent { case .closed: - let trackedEvent = InternalSuperwallEvent.PaywallDecline(paywallInfo: paywallViewController.info) - - let presentationResult = await internallyGetPresentationResult( - forEvent: trackedEvent, - requestType: .getImplicitPresentationResult + dismiss( + paywallViewController, + result: .declined, + closeReason: .manualClose ) - let paywallPresenterEvent = paywallViewController.info.presentedByEventWithName - let presentedByPaywallDecline = paywallPresenterEvent == SuperwallEventObjc.paywallDecline.description - - if case .paywall = presentationResult, - !presentedByPaywallDecline { - // If a paywall_decline trigger is active and the current paywall wasn't presented - // by paywall_decline, it lands here so as not to dismiss the paywall. - // track() will do that before presenting the next paywall. - } else { - dismiss( - paywallViewController, - result: .declined, - closeReason: .manualClose - ) - } - - await Superwall.shared.track(trackedEvent) case .initiatePurchase(let productId): await dependencyContainer.transactionManager.purchase( productId, from: paywallViewController ) case .initiateRestore: - await dependencyContainer.storeKitManager.tryToRestore(paywallViewController) + await dependencyContainer.storeKitManager.purchaseController.tryToRestore(from: paywallViewController) case .openedURL(let url): dependencyContainer.delegateAdapter.paywallWillOpenURL(url: url) case .openedUrlInSafari(let url): diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index c61157ee8..71c25b952 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "3.3.2" + s.version = "3.4.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/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 933a1c6de..f4aeb06b5 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -32,7 +32,7 @@ final class TrackingTests: XCTestCase { func test_appInstall() async { let appInstalledAtString = "now" - let result = await Superwall.shared.track(InternalSuperwallEvent.AppInstall(appInstalledAtString: appInstalledAtString, hasPurchaseController: true)) + let result = await Superwall.shared.track(InternalSuperwallEvent.AppInstall(appInstalledAtString: appInstalledAtString, hasExternalPurchaseController: true)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) XCTAssertTrue(result.parameters.eventParams["$using_purchase_controller"] as! Bool) @@ -96,7 +96,6 @@ final class TrackingTests: XCTestCase { func test_surveyResponse() async { // Given - let eventName = "TestName" let survey = Survey.stub() let paywallInfo = PaywallInfo.stub() let event = InternalSuperwallEvent.SurveyResponse( @@ -289,12 +288,18 @@ final class TrackingTests: XCTestCase { func test_triggerFire_noRuleMatch() async { let triggerName = "My Trigger" let dependencyContainer = DependencyContainer() - let result = await Superwall.shared.track(InternalSuperwallEvent.TriggerFire(triggerResult: .noRuleMatch, triggerName: triggerName, sessionEventsManager: dependencyContainer.sessionEventsManager)) + let unmatchedRules: [UnmatchedRule] = [ + .init(source: .expression, experimentId: "1"), + .init(source: .occurrence, experimentId: "2") + ] + let result = await Superwall.shared.track(InternalSuperwallEvent.TriggerFire(triggerResult: .noRuleMatch(unmatchedRules), triggerName: triggerName, sessionEventsManager: dependencyContainer.sessionEventsManager)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) XCTAssertEqual(result.parameters.eventParams["$event_name"] as! String, "trigger_fire") XCTAssertEqual(result.parameters.eventParams["$result"] as! String, "no_rule_match") XCTAssertEqual(result.parameters.eventParams["$trigger_name"] as! String, triggerName) + XCTAssertEqual(result.parameters.eventParams["$unmatched_rule_1"] as! String, "EXPRESSION") + XCTAssertEqual(result.parameters.eventParams["$unmatched_rule_2"] as! String, "OCCURRENCE") // TODO: Missing test for trigger_session_id here. Need to figure out a way to activate it } @@ -581,7 +586,7 @@ final class TrackingTests: XCTestCase { func test_paywallClose_survey_show() async { let paywall: Paywall = .stub() - .setting(\.survey, to: .stub()) + .setting(\.surveys, to: [.stub()]) let paywallInfo = paywall.getInfo(fromEvent: .stub(), factory: DependencyContainer()) let result = await Superwall.shared.track( InternalSuperwallEvent.PaywallClose( @@ -633,7 +638,7 @@ final class TrackingTests: XCTestCase { func test_paywallClose_survey_noShow() async { let paywall: Paywall = .stub() - .setting(\.survey, to: .stub()) + .setting(\.surveys, to: [.stub()]) let paywallInfo = paywall.getInfo(fromEvent: .stub(), factory: DependencyContainer()) let result = await Superwall.shared.track( InternalSuperwallEvent.PaywallClose( @@ -685,7 +690,7 @@ final class TrackingTests: XCTestCase { func test_paywallClose_survey_holdout() async { let paywall: Paywall = .stub() - .setting(\.survey, to: .stub()) + .setting(\.surveys, to: [.stub()]) let paywallInfo = paywall.getInfo(fromEvent: .stub(), factory: DependencyContainer()) let result = await Superwall.shared.track( InternalSuperwallEvent.PaywallClose( @@ -737,7 +742,7 @@ final class TrackingTests: XCTestCase { func test_paywallClose_noSurvey() async { let paywall: Paywall = .stub() - .setting(\.survey, to: nil) + .setting(\.surveys, to: []) let paywallInfo = paywall.getInfo(fromEvent: .stub(), factory: DependencyContainer()) let result = await Superwall.shared.track( InternalSuperwallEvent.PaywallClose( @@ -1242,8 +1247,7 @@ final class TrackingTests: XCTestCase { let paywallInfo: PaywallInfo = .stub() let productId = "abc" let product = StoreProduct(sk1Product: MockSkProduct(productIdentifier: productId)) - let dependencyContainer = DependencyContainer() - let skTransaction = MockSKPaymentTransaction(state: .purchased) + let result = await Superwall.shared.track(InternalSuperwallEvent.SubscriptionStart(paywallInfo: paywallInfo, product: product)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) @@ -1318,8 +1322,6 @@ final class TrackingTests: XCTestCase { let paywallInfo: PaywallInfo = .stub() let productId = "abc" let product = StoreProduct(sk1Product: MockSkProduct(productIdentifier: productId)) - let dependencyContainer = DependencyContainer() - let skTransaction = MockSKPaymentTransaction(state: .purchased) let result = await Superwall.shared.track(InternalSuperwallEvent.FreeTrialStart(paywallInfo: paywallInfo, product: product)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) @@ -1394,8 +1396,6 @@ final class TrackingTests: XCTestCase { let paywallInfo: PaywallInfo = .stub() let productId = "abc" let product = StoreProduct(sk1Product: MockSkProduct(productIdentifier: productId)) - let dependencyContainer = DependencyContainer() - let skTransaction = MockSKPaymentTransaction(state: .purchased) let result = await Superwall.shared.track(InternalSuperwallEvent.NonRecurringProductPurchase(paywallInfo: paywallInfo, product: product)) XCTAssertNotNil(result.parameters.eventParams["$app_session_id"]) XCTAssertTrue(result.parameters.eventParams["$is_standard_event"] as! Bool) diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift index 74ec10886..752c8c026 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift @@ -14,7 +14,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_noParams() async { // Given let event = InternalSuperwallEvent.AppLaunch() - let storage = StorageMock() // When let parameters = await TrackingLogic.processParameters( @@ -112,7 +111,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_attributes_withCustomParams() async { // Given - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -140,7 +138,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_containsDollar() async { // Given - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -168,7 +165,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_containArray() async { // Given - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -196,7 +192,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_containDictionary() async { // Given - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -225,7 +220,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_containsDate() async { // Given let date = Date(timeIntervalSince1970: 1650534735) - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -254,7 +248,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_containsUrl() async { // Given let url = URL(string: "https://www.google.com")! - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -282,7 +275,6 @@ final class TrackingLogicTests: XCTestCase { func testProcessParameters_superwallEvent_customParams_nilValue() async { // Given - let eventName = "TestName" let event = InternalSuperwallEvent.Attributes( appInstalledAtString: "abc", customParameters: [ @@ -334,7 +326,7 @@ final class TrackingLogicTests: XCTestCase { ) let outcome = TrackingLogic.canTriggerPaywall( - InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasPurchaseController: false), + InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasExternalPurchaseController: false), triggers: Set(["app_install"]), paywallViewController: paywallVc ) @@ -343,7 +335,7 @@ final class TrackingLogicTests: XCTestCase { func testDidStartNewSession_canTriggerPaywall_isntTrigger() { let outcome = TrackingLogic.canTriggerPaywall( - InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasPurchaseController: false), + InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasExternalPurchaseController: false), triggers: [], paywallViewController: nil ) @@ -352,7 +344,7 @@ final class TrackingLogicTests: XCTestCase { func testDidStartNewSession_canTriggerPaywall_isAllowedInternalEvent() { let outcome = TrackingLogic.canTriggerPaywall( - InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasPurchaseController: false), + InternalSuperwallEvent.AppInstall(appInstalledAtString: "", hasExternalPurchaseController: false), triggers: ["app_install"], paywallViewController: nil ) diff --git a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift index edc0f9b5a..d804a8178 100644 --- a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/TriggerSessionManagerTests.swift @@ -187,7 +187,7 @@ final class TriggerSessionManagerTests: XCTestCase { // When await sessionManager.activateSession( for: .explicitTrigger(eventData), - triggerResult: .noRuleMatch + triggerResult: .noRuleMatch([]) ) let triggerSessions = await queue.triggerSessions diff --git a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift deleted file mode 100644 index ea3ebc27e..000000000 --- a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorLogicTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 11/07/2022. -// - -import Foundation -import XCTest -@testable import SuperwallKit - -@available(iOS 14.0, *) -final class ExpressionEvaluatorLogicTests: XCTestCase { - func testShouldFire_noMatch() async { - let dependencyContainer = DependencyContainer() - let storage = StorageMock() - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub(), - ruleMatched: false - ) - XCTAssertFalse(outcome.shouldFire) - } - - func testShouldFire_noOccurrenceRule() async { - let dependencyContainer = DependencyContainer() - let storage = StorageMock() - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: nil, - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } - - func testShouldFire_shouldntFire_maxCountGTCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 1), - ruleMatched: true - ) - XCTAssertFalse(outcome.shouldFire) - } - - func testShouldFire_shouldFire_maxCountEqualToCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 0) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 1), - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } - - func testShouldFire_shouldFire_maxCountLtCount() async { - let dependencyContainer = DependencyContainer() - let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) - let storage = StorageMock(coreDataManager: coreDataManagerMock) - let evaluator = ExpressionEvaluator( - storage: storage, - factory: dependencyContainer - ) - let outcome = await evaluator.shouldFire( - forOccurrence: .stub() - .setting(\.maxCount, to: 4), - ruleMatched: true - ) - XCTAssertTrue(outcome.shouldFire) - } -} diff --git a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift index 9f653d453..4b1d8e720 100644 --- a/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift +++ b/Tests/SuperwallKitTests/Config/Assignments/Expression Evaluator/ExpressionEvaluatorTests.swift @@ -10,7 +10,101 @@ import Foundation import XCTest @testable import SuperwallKit +@available(iOS 14.0, *) final class ExpressionEvaluatorTests: XCTestCase { + // MARK: - tryToMatchOccurrence + func test_tryToMatchOccurrence_noMatch() async { + let dependencyContainer = DependencyContainer() + let storage = StorageMock() + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: .stub()) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: false + ) + XCTAssertEqual(outcome, .noMatch(source: .expression, experimentId: rule.experiment.id)) + } + + func test_tryToMatchOccurrence_noOccurrenceRule() async { + let dependencyContainer = DependencyContainer() + let storage = StorageMock() + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: nil) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + XCTAssertEqual(outcome, .match(rule: rule)) + } + + func test_tryToMatchOccurrence_shouldntFire_maxCountGTCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let rule = TriggerRule.stub() + .setting(\.occurrence, to: .stub().setting(\.maxCount, to: 1)) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .noMatch(source: .occurrence, experimentId: rule.experiment.id)) + } + + func test_tryToMatchOccurrence_shouldFire_maxCountEqualToCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 0) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let occurrence: TriggerRuleOccurrence = .stub().setting(\.maxCount, to: 1) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: occurrence) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .match(rule: rule, unsavedOccurrence: occurrence)) + } + + func test_tryToMatchOccurrence_shouldFire_maxCountLtCount() async { + let dependencyContainer = DependencyContainer() + let coreDataManagerMock = CoreDataManagerFakeDataMock(internalOccurrenceCount: 1) + let storage = StorageMock(coreDataManager: coreDataManagerMock) + let evaluator = ExpressionEvaluator( + storage: storage, + factory: dependencyContainer + ) + + let occurrence: TriggerRuleOccurrence = .stub().setting(\.maxCount, to: 4) + let rule = TriggerRule.stub() + .setting(\.occurrence, to: occurrence) + let outcome = await evaluator.tryToMatchOccurrence( + from: rule, + expressionMatched: true + ) + + XCTAssertEqual(outcome, .match(rule: rule, unsavedOccurrence: occurrence)) + } + + // MARK: - evaluateExpression func testExpressionMatchesAll() async { let dependencyContainer = DependencyContainer() dependencyContainer.storage.reset() @@ -18,13 +112,16 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + + let rule: TriggerRule = .stub() + .setting(\.expression, to: nil) + .setting(\.expressionJs, to: nil) let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: nil) - .setting(\.expressionJs, to: nil), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + + XCTAssertEqual(result, .match(rule: rule)) } // MARK: - Expression @@ -37,13 +134,14 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes(["a": "b"]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "user.a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "user.a == \"b\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: [:], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) - XCTAssertNil(result.unsavedOccurrence) + + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expression_withOccurrence() async { @@ -55,14 +153,15 @@ final class ExpressionEvaluatorTests: XCTestCase { ) dependencyContainer.identityManager.mergeUserAttributes(["a": "b"]) let occurrence = TriggerRuleOccurrence(key: "a", maxCount: 1, interval: .infinity) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "user.a == \"b\"") + .setting(\.occurrence, to: occurrence) let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "user.a == \"b\"") - .setting(\.occurrence, to: occurrence), + fromRule: rule, eventData: EventData(name: "ss", parameters: [:], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) - XCTAssertEqual(result.unsavedOccurrence, occurrence) + + XCTAssertEqual(result, .match(rule: rule, unsavedOccurrence: occurrence)) } func testExpressionEvaluator_expressionParams() async { @@ -73,12 +172,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "params.a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "params.a == \"b\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionDeviceTrue() async { @@ -89,12 +189,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "device.platform == \"iOS\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "device.platform == \"iOS\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionDeviceFalse() async { @@ -105,12 +206,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "device.platform == \"Android\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "device.platform == \"Android\""), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } func testExpressionEvaluator_expressionFalse() async { @@ -121,12 +223,13 @@ final class ExpressionEvaluatorTests: XCTestCase { factory: dependencyContainer ) dependencyContainer.identityManager.mergeUserAttributes([:]) + let rule: TriggerRule = .stub() + .setting(\.expression, to: "a == \"b\"") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expression, to: "a == \"b\""), + fromRule: rule, eventData: .stub() ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } /* func testExpressionEvaluator_events() { @@ -151,12 +254,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(){ return true }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(){ return true }; superwallEvaluator"), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSValues_true() async { @@ -165,12 +269,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator"), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSValues_false() async { @@ -179,12 +284,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return values.params.a ==\"b\" }; superwallEvaluator"), + fromRule: rule, eventData: EventData(name: "ss", parameters: ["a": "b"], createdAt: Date()) ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } func testExpressionEvaluator_expressionJSNumbers() async { @@ -193,12 +299,13 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "function superwallEvaluator(values) { return 1 == 1 }; superwallEvaluator") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: "function superwallEvaluator(values) { return 1 == 1 }; superwallEvaluator"), + fromRule: rule, eventData: .stub() ) - XCTAssertTrue(result.shouldFire) + XCTAssertEqual(result, .match(rule: rule)) } /* func testExpressionEvaluator_expressionJSValues_events() { @@ -221,11 +328,12 @@ final class ExpressionEvaluatorTests: XCTestCase { storage: dependencyContainer.storage, factory: dependencyContainer ) + let rule: TriggerRule = .stub() + .setting(\.expressionJs, to: "") let result = await evaluator.evaluateExpression( - fromRule: .stub() - .setting(\.expressionJs, to: ""), + fromRule: rule, eventData: .stub() ) - XCTAssertFalse(result.shouldFire) + XCTAssertEqual(result, .noMatch(source: .expression, experimentId: rule.experiment.id)) } } diff --git a/Tests/SuperwallKitTests/Config/Models/SurveyTests.swift b/Tests/SuperwallKitTests/Config/Models/SurveyTests.swift index 8c0965c47..a368c406c 100644 --- a/Tests/SuperwallKitTests/Config/Models/SurveyTests.swift +++ b/Tests/SuperwallKitTests/Config/Models/SurveyTests.swift @@ -27,7 +27,9 @@ final class SurveyTests: XCTestCase { message: "test", options: [.stub()], presentationProbability: 0, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) let isHoldout = survey.shouldAssignHoldout( isDebuggerLaunched: false, @@ -44,7 +46,9 @@ final class SurveyTests: XCTestCase { message: "test", options: [.stub()], presentationProbability: 1, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) let isHoldout = survey.shouldAssignHoldout( isDebuggerLaunched: false, @@ -61,7 +65,9 @@ final class SurveyTests: XCTestCase { message: "test", options: [.stub()], presentationProbability: 0.4, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) func random(in: Range) -> Double { return 0.5 @@ -89,7 +95,9 @@ final class SurveyTests: XCTestCase { message: "test", options: [.stub()], presentationProbability: 0.4, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) let existingAssignmentKey = "abc" let storage = StorageMock(internalSurveyAssignmentKey: existingAssignmentKey) @@ -105,7 +113,9 @@ final class SurveyTests: XCTestCase { message: "test", options: [.stub()], presentationProbability: 0.4, - includeOtherOption: true + includeOtherOption: true, + includeCloseOption: true, + presentationCondition: .onManualClose ) let existingAssignmentKey = "abc" let storage = StorageMock(internalSurveyAssignmentKey: existingAssignmentKey) diff --git a/Tests/SuperwallKitTests/Dependencies/StoreKitCoordinatorFactoryMock.swift b/Tests/SuperwallKitTests/Dependencies/StoreKitCoordinatorFactoryMock.swift deleted file mode 100644 index f322a30e0..000000000 --- a/Tests/SuperwallKitTests/Dependencies/StoreKitCoordinatorFactoryMock.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 13/01/2023. -// - -import Foundation -@testable import SuperwallKit - -final class StoreKitCoordinatorFactoryMock: StoreKitCoordinatorFactory { - let coordinator: StoreKitCoordinator - - init(coordinator: StoreKitCoordinator) { - self.coordinator = coordinator - } - - func makeStoreKitCoordinator() -> StoreKitCoordinator { - return coordinator - } -} diff --git a/Tests/SuperwallKitTests/Misc/Extensions/Date+IsWithinAnHourBeforeTests.swift b/Tests/SuperwallKitTests/Misc/Extensions/Date+IsWithinAnHourBeforeTests.swift new file mode 100644 index 000000000..70cc8bdbf --- /dev/null +++ b/Tests/SuperwallKitTests/Misc/Extensions/Date+IsWithinAnHourBeforeTests.swift @@ -0,0 +1,47 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 19/09/2023. +// +// swiftlint:disable all + +import XCTest +@testable import SuperwallKit + +class Date_WithinAnHourBeforeTests: XCTestCase { + func test_isWithinAnHourBefore_twoHoursAgo() { + let now = Date() + let oneHourBefore = now.addingTimeInterval(-7200) + let result = oneHourBefore.isWithinAnHourBefore(now) + XCTAssertFalse(result) + } + + func test_isWithinAnHourBefore_oneHourAgo() { + let now = Date() + let oneHourBefore = now.addingTimeInterval(-3600) + let result = oneHourBefore.isWithinAnHourBefore(now) + XCTAssertFalse(result) + } + + func test_isWithinAnHourBefore_59minsAgo() { + let now = Date() + let oneHourBefore = now.addingTimeInterval(-3599) + let result = oneHourBefore.isWithinAnHourBefore(now) + XCTAssertTrue(result) + } + + func test_isWithinAnHourBefore_exactSame() { + let now = Date() + let oneHourBefore = now + let result = oneHourBefore.isWithinAnHourBefore(now) + XCTAssertTrue(result) + } + + func test_isWithinAnHourBefore_inFuture() { + let now = Date() + let oneHourBefore = now.addingTimeInterval(60) + let result = oneHourBefore.isWithinAnHourBefore(now) + XCTAssertTrue(result) + } +} diff --git a/Tests/SuperwallKitTests/Misc/Extensions/Date+IsoStringTests.swift b/Tests/SuperwallKitTests/Misc/Extensions/Date+IsoStringTests.swift index 72d295b72..f6d429ea6 100644 --- a/Tests/SuperwallKitTests/Misc/Extensions/Date+IsoStringTests.swift +++ b/Tests/SuperwallKitTests/Misc/Extensions/Date+IsoStringTests.swift @@ -4,12 +4,11 @@ // // Created by Yusuf Tör on 09/03/2022. // +// swiftlint:disable all import XCTest @testable import SuperwallKit -// swiftlint:disable all - class Date_IsoStringTests: XCTestCase { func testIsoString() { // Given diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift new file mode 100644 index 000000000..c7359a43a --- /dev/null +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -0,0 +1,62 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 15/09/2023. +// +// swiftlint:disable all + +import XCTest +import Combine +@testable import SuperwallKit + +@available(iOS 14.0, *) +final class DeviceHelperTests: XCTestCase { + func test_makePaddedSdkVersion_withBeta() { + let version = "3.0.0-beta.1" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.000.000-beta.001") + } + + func test_makePaddedSdkVersion_patchVersion() { + let version = "3.0.1" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.000.001") + } + + func test_makePaddedSdkVersion_minorVersion() { + let version = "3.1.1" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.001.001") + } + + func test_makePaddedSdkVersion_biggerMinorVersion() { + let version = "3.10.1" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.010.001") + } + + func test_makePaddedSdkVersion_rc() { + let version = "3.10.1-rc.30" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.010.001-rc.030") + } + + func test_makePaddedSdkVersion_limit() { + let version = "312.123.123-rc.310" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "312.123.123-rc.310") + } + + func test_makePaddedSdkVersion_twoComponents() { + let version = "3.0" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003.000") + } + + func test_makePaddedSdkVersion_oneComponents() { + let version = "3" + let paddedVersion = DeviceHelper.makePaddedSdkVersion(using: version) + XCTAssertEqual(paddedVersion, "003") + } +} diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index 1b463a3e1..f373f9aa0 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -16,12 +16,11 @@ final class NetworkTests: XCTestCase { urlSession: CustomURLSessionMock, injectedApplicationStatePublisher: AnyPublisher, completion: @escaping () -> Void - ) async { - let task = Task { + ) { + _ = Task { let dependencyContainer = DependencyContainer() let network = Network(urlSession: urlSession, factory: dependencyContainer) - let requestId = "abc" - + _ = try? await network.getConfig( injectedApplicationStatePublisher: injectedApplicationStatePublisher, isRetryingCallback: {} @@ -37,7 +36,7 @@ final class NetworkTests: XCTestCase { .eraseToAnyPublisher() let expectation = expectation(description: "config completed") expectation.isInverted = true - await configWrapper( + configWrapper( urlSession: urlSession, injectedApplicationStatePublisher: publisher ) { diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/ConfirmPaywallAssignmentOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/ConfirmPaywallAssignmentOperatorTests.swift index 00f90cfaf..3b456361f 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/ConfirmPaywallAssignmentOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/ConfirmPaywallAssignmentOperatorTests.swift @@ -82,13 +82,6 @@ final class ConfirmPaywallAssignmentOperatorTests: XCTestCase { type: .getPaywall(.stub()) ) - let input = PresentablePipelineOutput( - debugInfo: [:], - paywallViewController: dependencyContainer.makePaywallViewController(for: .stub(), withCache: nil, delegate: nil), - presenter: UIViewController(), - confirmableAssignment: ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")) - ) - Superwall.shared.confirmPaywallAssignment( ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")), request: request, @@ -119,13 +112,6 @@ final class ConfirmPaywallAssignmentOperatorTests: XCTestCase { type: .getPresentationResult ) - let input = PresentablePipelineOutput( - debugInfo: [:], - paywallViewController: dependencyContainer.makePaywallViewController(for: .stub(), withCache: nil, delegate: nil), - presenter: UIViewController(), - confirmableAssignment: ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")) - ) - Superwall.shared.confirmPaywallAssignment( ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")), request: request, @@ -156,13 +142,6 @@ final class ConfirmPaywallAssignmentOperatorTests: XCTestCase { type: .getImplicitPresentationResult ) - let input = PresentablePipelineOutput( - debugInfo: [:], - paywallViewController: dependencyContainer.makePaywallViewController(for: .stub(), withCache: nil, delegate: nil), - presenter: UIViewController(), - confirmableAssignment: ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")) - ) - Superwall.shared.confirmPaywallAssignment( ConfirmableAssignment(experimentId: "", variant: .init(id: "", type: .treatment, paywallId: "")), request: request, diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/GetPaywallVcOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/GetPaywallVcOperatorTests.swift index 88bcfa32e..b61c22d3c 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/GetPaywallVcOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/GetPaywallVcOperatorTests.swift @@ -72,8 +72,6 @@ final class GetPaywallVcOperatorTests: XCTestCase { @MainActor func test_getPaywallViewController_error_userNotSubscribed() async { - let experiment = Experiment(id: "", groupId: "", variant: .init(id: "", type: .treatment, paywallId: "")) - let statePublisher = PassthroughSubject() let stateExpectation = expectation(description: "Output a state") stateExpectation.expectedFulfillmentCount = 2 @@ -127,8 +125,6 @@ final class GetPaywallVcOperatorTests: XCTestCase { @MainActor func test_getPaywallViewController_success_paywallNotAlreadyPresented() async { - let experiment = Experiment(id: "", groupId: "", variant: .init(id: "", type: .treatment, paywallId: "")) - let statePublisher = PassthroughSubject() let stateExpectation = expectation(description: "Output a state") stateExpectation.isInverted = true diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift index 08bf8b40f..f69831dc0 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/HandleTriggerResultOperatorTests.swift @@ -96,7 +96,7 @@ final class HandleTriggerResultOperatorTests: XCTestCase { func test_handleTriggerResult_noRuleMatch() async { let input = RuleEvaluationOutcome( - triggerResult: .noRuleMatch + triggerResult: .noRuleMatch([]) ) let statePublisher = PassthroughSubject() diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift index ae04f6545..71321f494 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift @@ -11,8 +11,11 @@ import XCTest @available(iOS 14.0, *) @MainActor final class SurveyManagerTests: XCTestCase { - func test_presentSurveyIfAvailable_paywallDeclined() { - let survey = Survey.stub() + func test_presentSurveyIfAvailable_paywallDeclined_purchaseSurvey() { + let surveys = [ + Survey.stub() + .setting(\.presentationCondition, to: .onPurchase) + ] let expectation = expectation(description: "called completion block") let dependencyContainer = DependencyContainer() @@ -36,10 +39,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - survey, + surveys, + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: false, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), @@ -49,7 +53,7 @@ final class SurveyManagerTests: XCTestCase { expectation.fulfill() } ) - wait(for: [expectation]) + wait(for: [expectation], timeout: 0.2) } func test_presentSurveyIfAvailable_surveyNil() { @@ -76,10 +80,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - nil, + [], + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), @@ -89,11 +94,12 @@ final class SurveyManagerTests: XCTestCase { expectation.fulfill() } ) - wait(for: [expectation]) + wait(for: [expectation], timeout: 0.2) } func test_presentSurveyIfAvailable_loadingState_loadingPurchase() { let expectation = expectation(description: "called completion block") + expectation.isInverted = true let dependencyContainer = DependencyContainer() let messageHandler = PaywallMessageHandler( @@ -116,20 +122,21 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - .stub(), + [.stub().setting(\.presentationCondition, to: .onPurchase)], + paywallResult: .purchased(productId: "abc"), + paywallCloseReason: .systemLogic, using: paywallVc, loadingState: .loadingPurchase, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), factory: dependencyContainer, completion: { result in - XCTAssertEqual(result, .noShow) + XCTAssertEqual(result, .show) expectation.fulfill() } ) - wait(for: [expectation]) + wait(for: [expectation], timeout: 0.2) } func test_presentSurveyIfAvailable_loadingState_loadingURL() { @@ -156,10 +163,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - .stub(), + [.stub().setting(\.presentationCondition, to: .onManualClose)], + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .loadingURL, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), @@ -196,10 +204,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - .stub(), + [.stub().setting(\.presentationCondition, to: .onManualClose)], + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .manualLoading, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), @@ -236,10 +245,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - .stub(), + [.stub().setting(\.presentationCondition, to: .onManualClose)], + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .unknown, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: StorageMock(), @@ -254,9 +264,11 @@ final class SurveyManagerTests: XCTestCase { func test_presentSurveyIfAvailable_sameAssignmentKey() { let storageMock = StorageMock(internalSurveyAssignmentKey: "1") - let survey = Survey.stub() - .setting(\.assignmentKey, to: "1") - + let surveys = [ + Survey.stub() + .setting(\.assignmentKey, to: "1") + .setting(\.presentationCondition, to: .onManualClose) + ] let expectation = expectation(description: "called completion block") let dependencyContainer = DependencyContainer() @@ -280,10 +292,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - survey, + surveys, + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: storageMock, @@ -300,8 +313,11 @@ final class SurveyManagerTests: XCTestCase { func test_presentSurveyIfAvailable_zeroPresentationProbability() { let storageMock = StorageMock() - let survey = Survey.stub() - .setting(\.presentationProbability, to: 0) + let surveys = [ + Survey.stub() + .setting(\.presentationProbability, to: 0) + .setting(\.presentationCondition, to: .onManualClose) + ] let expectation = expectation(description: "called completion block") let dependencyContainer = DependencyContainer() @@ -327,10 +343,11 @@ final class SurveyManagerTests: XCTestCase { SurveyManager.presentSurveyIfAvailable( - survey, + surveys, + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: storageMock, @@ -347,7 +364,7 @@ final class SurveyManagerTests: XCTestCase { func test_presentSurveyIfAvailable_debuggerLaunched() { let storageMock = StorageMock() - let survey = Survey.stub() + let surveys = [Survey.stub()] let expectation = expectation(description: "called completion block") expectation.isInverted = true @@ -373,10 +390,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - survey, + surveys, + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: true, isDebuggerLaunched: true, paywallInfo: .stub(), storage: storageMock, @@ -394,8 +412,11 @@ final class SurveyManagerTests: XCTestCase { let storageMock = StorageMock() storageMock.reset() - let survey = Survey.stub() - .setting(\.presentationProbability, to: 1) + let surveys = [ + Survey.stub() + .setting(\.presentationProbability, to: 1) + .setting(\.presentationCondition, to: .onManualClose) + ] let expectation = expectation(description: "called completion block") expectation.isInverted = true @@ -421,10 +442,11 @@ final class SurveyManagerTests: XCTestCase { ) SurveyManager.presentSurveyIfAvailable( - survey, + surveys, + paywallResult: .declined, + paywallCloseReason: .manualClose, using: paywallVc, loadingState: .ready, - paywallIsManuallyDeclined: true, isDebuggerLaunched: false, paywallInfo: .stub(), storage: storageMock, diff --git a/Tests/SuperwallKitTests/Storage/StorageMock.swift b/Tests/SuperwallKitTests/Storage/StorageMock.swift index 8a06c51a8..1d2199d7d 100644 --- a/Tests/SuperwallKitTests/Storage/StorageMock.swift +++ b/Tests/SuperwallKitTests/Storage/StorageMock.swift @@ -18,7 +18,7 @@ final class StorageMock: Storage { var didClearCachedSessionEvents = false var didSave = false - class DeviceInfoFactoryMock: DeviceHelperFactory, HasPurchaseControllerFactory { + class DeviceInfoFactoryMock: DeviceHelperFactory, HasExternalPurchaseControllerFactory { func makeDeviceInfo() -> DeviceInfo { return DeviceInfo(appInstalledAtString: "a", locale: "b") } @@ -27,7 +27,7 @@ final class StorageMock: Storage { return true } - func makeHasPurchaseController() -> Bool { + func makeHasExternalPurchaseController() -> Bool { return false } } diff --git a/Tests/SuperwallKitTests/Storage/StorageTests.swift b/Tests/SuperwallKitTests/Storage/StorageTests.swift new file mode 100644 index 000000000..74390c928 --- /dev/null +++ b/Tests/SuperwallKitTests/Storage/StorageTests.swift @@ -0,0 +1,41 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 15/09/2023. +// +// swiftlint:disable all + +import XCTest +@testable import SuperwallKit + +class StorageTests: XCTestCase { + func test_saveConfirmedAssignments() { + let dependencyContainer = DependencyContainer() + let storage = Storage(factory: dependencyContainer) + let network = NetworkMock(factory: dependencyContainer) + let configManager = ConfigManager( + options: nil, + storeKitManager: dependencyContainer.storeKitManager, + storage: storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + factory: dependencyContainer + ) + + let assignments: [Experiment.ID: Experiment.Variant] = [ + "123": .init(id: "1", type: .treatment, paywallId: "23") + ] + storage.saveConfirmedAssignments(assignments) + + let retrievedAssignments = storage.getConfirmedAssignments() + XCTAssertEqual(retrievedAssignments["123"], assignments["123"]) + + storage.reset() + configManager.reset() + + let retrievedAssignments2 = storage.getConfirmedAssignments() + XCTAssertTrue(retrievedAssignments2.isEmpty) + } +} + diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTests.swift index 917b06feb..4c04c49e6 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTests.swift @@ -10,37 +10,24 @@ import XCTest @testable import SuperwallKit class ReceiptManagerTests: XCTestCase { - var storeKitCoordinatorFactoryMock: StoreKitCoordinatorFactoryMock! - - // MARK: - loadPurchasedProducts - private func makeStoreKitManager(with productsFetcher: ProductsFetcherSK1) -> StoreKitManager { - let dependencyContainer = DependencyContainer() - let coordinator = StoreKitCoordinator( - delegateAdapter: dependencyContainer.delegateAdapter, - storeKitManager: dependencyContainer.storeKitManager, - factory: dependencyContainer, - productsFetcher: productsFetcher - ) - storeKitCoordinatorFactoryMock = StoreKitCoordinatorFactoryMock( - coordinator: coordinator - ) - let storeKitManager = StoreKitManager(factory: storeKitCoordinatorFactoryMock) - - return storeKitManager - } + let dependencyContainer = DependencyContainer() + lazy var purchaseController = InternalPurchaseController( + factory: dependencyContainer, + swiftPurchaseController: nil, + objcPurchaseController: nil + ) func test_loadPurchasedProducts_nilProducts() async { let product = MockSkProduct(subscriptionGroupIdentifier: "abc") let productsFetcher = ProductsFetcherSK1Mock( productCompletionResult: .success([StoreProduct(sk1Product: product)]) ) - let manager = makeStoreKitManager(with: productsFetcher) - let getReceiptData: () -> Data = { return MockReceiptData.newReceipt } let receiptManager = ReceiptManager( - delegate: manager, + delegate: productsFetcher, + purchaseController: purchaseController, receiptData: getReceiptData ) @@ -53,13 +40,12 @@ class ReceiptManagerTests: XCTestCase { let productsFetcher = ProductsFetcherSK1Mock( productCompletionResult: .failure(TestError("error")) ) - let manager = makeStoreKitManager(with: productsFetcher) - let getReceiptData: () -> Data = { return MockReceiptData.newReceipt } let receiptManager = ReceiptManager( - delegate: manager, + delegate: productsFetcher, + purchaseController: purchaseController, receiptData: getReceiptData ) diff --git a/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift b/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift index cc2977560..6aaef24c3 100644 --- a/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift @@ -11,23 +11,12 @@ import XCTest import StoreKit class StoreKitManagerTests: XCTestCase { - var storeKitCoordinatorFactoryMock: StoreKitCoordinatorFactoryMock! - - private func makeStoreKitManager(with productsFetcher: ProductsFetcherSK1) -> StoreKitManager { - let dependencyContainer = DependencyContainer() - let coordinator = StoreKitCoordinator( - delegateAdapter: dependencyContainer.delegateAdapter, - storeKitManager: dependencyContainer.storeKitManager, - factory: dependencyContainer, - productsFetcher: productsFetcher - ) - storeKitCoordinatorFactoryMock = StoreKitCoordinatorFactoryMock( - coordinator: coordinator - ) - let storeKitManager = StoreKitManager(factory: storeKitCoordinatorFactoryMock) - - return storeKitManager - } + let dependencyContainer = DependencyContainer() + lazy var purchaseController = InternalPurchaseController( + factory: dependencyContainer, + swiftPurchaseController: nil, + objcPurchaseController: nil + ) func test_getProducts_primaryProduct() async { let dependencyContainer = DependencyContainer() @@ -107,7 +96,10 @@ class StoreKitManagerTests: XCTestCase { func test_getProducts_substitutePrimaryProduct_oneResponseProduct() async { let productsResult: Result, Error> = .success([]) let productsFetcher = ProductsFetcherSK1Mock(productCompletionResult: productsResult) - let manager = makeStoreKitManager(with: productsFetcher) + let manager = StoreKitManager( + purchaseController: purchaseController, + productsFetcher: productsFetcher + ) let primary = MockSkProduct(productIdentifier: "abc") let substituteProducts = PaywallProducts( @@ -131,7 +123,10 @@ class StoreKitManagerTests: XCTestCase { StoreProduct(sk1Product: responseProduct2) ]) let productsFetcher = ProductsFetcherSK1Mock(productCompletionResult: productsResult) - let manager = makeStoreKitManager(with: productsFetcher) + let manager = StoreKitManager( + purchaseController: purchaseController, + productsFetcher: productsFetcher + ) let primary = MockSkProduct(productIdentifier: "abc") let substituteProducts = PaywallProducts( diff --git a/Tests/SuperwallKitTests/StoreKit/Transactions/NotificationSchedulerTests.swift b/Tests/SuperwallKitTests/StoreKit/Transactions/NotificationSchedulerTests.swift new file mode 100644 index 000000000..4fe201a33 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Transactions/NotificationSchedulerTests.swift @@ -0,0 +1,107 @@ +// +// File.swift +// +// +// Created by Yusuf Tör on 15/09/2023. +// +// swiftlint:disable all + +import XCTest +@testable import SuperwallKit +import StoreKit + +class NotificationSchedulerTests: XCTestCase { + class NotificationCenter: NotificationAuthorizable { + let settings: NotificationSettings + + init(settings: NotificationSettings) { + self.settings = settings + } + + var requests: [UNNotificationRequest] = [] + + func requestAuthorization(options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void) { + completionHandler(true, nil) + } + + func getSettings(completionHandler: @escaping (NotificationSettings) -> Void) { + completionHandler(settings) + } + + func add(_ request: UNNotificationRequest) async throws { + requests.append(request) + } + } + + func test_scheduleNotifications_noSandbox() async { + class Factory: DeviceHelperFactory { + func makeIsSandbox() -> Bool { + return false + } + func makeDeviceInfo() -> DeviceInfo { + return .init(appInstalledAtString: "", locale: "") + } + } + + class AuthorizedNotificationSettings: NotificationSettings { + var authorizationStatus: UNAuthorizationStatus { + return .authorized + } + } + + let factory = Factory() + let notification: LocalNotification = .stub() + let notifications: [LocalNotification] = [notification] + let notificationCenter = NotificationCenter(settings: AuthorizedNotificationSettings()) + + await NotificationScheduler.scheduleNotifications( + notifications, + factory: factory, + notificationCenter: notificationCenter + ) + + + XCTAssertEqual(notificationCenter.requests.count, 1) + + XCTAssertEqual(notificationCenter.requests.first!.content.title, notification.title) + XCTAssertEqual(notificationCenter.requests.first!.content.body, notification.body) + XCTAssertEqual(notificationCenter.requests.first!.content.subtitle, notification.subtitle) + XCTAssertEqual((notificationCenter.requests.first!.trigger as! UNTimeIntervalNotificationTrigger).timeInterval, notification.delay / 1000) + } + + func test_scheduleNotifications_sandbox() async { + class Factory: DeviceHelperFactory { + func makeIsSandbox() -> Bool { + return true + } + func makeDeviceInfo() -> DeviceInfo { + return .init(appInstalledAtString: "", locale: "") + } + } + + class AuthorizedNotificationSettings: NotificationSettings { + var authorizationStatus: UNAuthorizationStatus { + return .authorized + } + } + + let factory = Factory() + let notification: LocalNotification = .stub() + let notifications: [LocalNotification] = [notification] + let notificationCenter = NotificationCenter(settings: AuthorizedNotificationSettings()) + + await NotificationScheduler.scheduleNotifications( + notifications, + factory: factory, + notificationCenter: notificationCenter + ) + + + XCTAssertEqual(notificationCenter.requests.count, 1) + + XCTAssertEqual(notificationCenter.requests.first!.content.title, notification.title) + XCTAssertEqual(notificationCenter.requests.first!.content.body, notification.body) + XCTAssertEqual(notificationCenter.requests.first!.content.subtitle, notification.subtitle) + XCTAssertEqual((notificationCenter.requests.first!.trigger as! UNTimeIntervalNotificationTrigger).timeInterval, notification.delay / 1000 / 24 / 60) + } +}