diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 61085e9d7..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG]" -labels: '' -assignees: '' - ---- - -## New issue checklist -- [ ] I have reviewed the [`README`](https://github.com/superwall-me/paywall-ios/blob/master/README.md) and [documentation](https://docs.superwall.com/docs) -- [ ] I have searched [existing issues](https://github.com/superwall-me/paywall-ios/issues) and this is not a duplicate -- [ ] I have attempted to reproduce the issue and include an example project. - -### General information - -- `Superwall` version: -- iOS version(s): -- CocoaPods/Carthage version (if applicable): -- Xcode version: -- Devices/Simulators affected: -- Reproducible in the demo project? (Yes/No): -- Related issues: - -### Describe the bug -A clear and concise description of what the bug is. The more detail you can provide the faster our team will be able to triage and resolve the issue. - -### Steps to reproduce -Please also include a description of expected vs. actual behaviour - -### Other Information -e.g. stacktraces, suggestions how to fix, links for us to have context, eg. stackoverflow, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..89cb08a44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,131 @@ +name: πŸ› Bug report +description: Report a bug in SuperwallKit. The more you give us up front, the faster we can find the cause. +title: "[BUG] " +labels: ["bug"] +body: + - type: checkboxes + id: checklist + attributes: + label: New issue checklist + options: + - label: I have read the [documentation](https://superwall.com/docs) and this doesn't appear to be expected behavior + required: true + - label: I have searched [existing issues](https://github.com/superwall/Superwall-iOS/issues) and this is not a duplicate + required: true + - label: I have tried to reproduce the issue in a minimal sample project I can share + required: false + - type: input + id: affected-user + attributes: + label: Affected user + description: > + A link to a user who hit the bug lets us check their event logs, which is often the + fastest route to a diagnosis. In the + [dashboard](https://superwall.com/select-application?pathname=/applications/:app/users/v2), + open **Users**, search for the app user ID you pass to `Superwall.shared.identify(userId:)` + (or the `$SuperwallAlias:...` ID from `Superwall.shared.userId` if you don't identify users), + open the user, and paste the page URL. The link only opens for your own team and us, + but issues are public and the URL contains the user ID itself β€” so if your app user IDs + are personally identifying (e.g. an email address), paste the user's `$SuperwallAlias:...` + URL instead (shown on their user page), or share the link privately via the support chat + in the dashboard and mention this issue. If the bug isn't tied to a user (e.g. a build + error), write "N/A". + placeholder: https://superwall.com/applications//users/v2/ + validations: + required: true + - type: input + id: occurred-at + attributes: + label: When did it happen? + description: Approximate date and time the bug occurred, including timezone. Helps us correlate server-side logs. + placeholder: e.g. 2026-08-15 14:30 UTC + - type: input + id: sdk-version + attributes: + label: SuperwallKit version + placeholder: e.g. 4.16.3 + validations: + required: true + - type: input + id: previous-sdk-version + attributes: + label: Last SDK version that worked + description: If the bug appeared after updating the SDK, which version were you on before? + placeholder: e.g. 4.15.4 + - type: input + id: ios-version + attributes: + label: iOS version(s) + placeholder: e.g. iOS 26.5 + validations: + required: true + - type: input + id: xcode-version + attributes: + label: Xcode version + placeholder: e.g. 26.1 + validations: + required: true + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - Swift Package Manager + - CocoaPods + - Other + validations: + required: true + - type: input + id: devices + attributes: + label: Devices/simulators affected + placeholder: e.g. iPhone 17 Pro, all simulators + - type: dropdown + id: repro-example + attributes: + label: Reproducible in the example app? + options: + - "Yes" + - "No" + - Haven't tried + validations: + required: true + - type: textarea + id: sdk-setup + attributes: + label: How is the SDK set up? + description: | + Briefly describe your integration β€” it changes which code paths run: + - Are you using a `PurchaseController` (e.g. with RevenueCat)? If so, where do you set `Superwall.shared.subscriptionStatus`? + - How do you present paywalls (`register(placement:)`, `PaywallView`, `getPaywall`)? + - Do you call `Superwall.shared.identify(userId:)`? + validations: + required: true + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of the bug, including expected vs. actual behavior. Screenshots and screen recordings help a lot. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: Debug logs + description: Set `options.logging.level = .debug` in `SuperwallOptions` before configuring the SDK, reproduce the bug, and paste the console output covering when it happened. Automatically formatted as code. Please redact anything personal β€” logs can contain user attributes you've set. + render: shell + - type: textarea + id: other + attributes: + label: Other information + description: Stack traces, crash reports, related issues, links, or anything else that gives context. diff --git a/.swiftlint.yml b/.swiftlint.yml index 50194293a..5db4d245a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,9 @@ excluded: - ${PWD}/Pods - ${PWD}/DerivedData - ${PWD}/.build + # Git worktrees live here, each holding a full copy of the SDK source, so + # linting them reports another branch's violations against this one. + - ${PWD}/.claude - ${PWD}/Examples - ${PWD}/Tests - ${PWD}/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6996d7851..b02ffdaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.17.0 +### Enhancements + +- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. +- Adds `userAttributes` to the `RedemptionInfo` you get from `didRedeemLink`, so you can read the answers someone gave on your web paywall funnel after they redeem in your app. +- Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. + +### Fixes + +- Fixes trial reminders being scheduled for users who bought without getting the intro offer. +- Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. `reset()` and identifying a different user now keep the integration attributes that describe the device, such as the AppsFlyer and Adjust IDs, and drop the ones that describe the person, such as the Amplitude and Customer.io user IDs. +- Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes β€” the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. +- Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. +- Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. +- Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. +- Fixes a crash when `register` is called from more than one thread at a time. +- Fixes a hang when identifying a different user after an install attribution match had been found. +- Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. +- Fixes audiences matching users they shouldn't when you use a Purchase Controller. +- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. + ## 4.16.3 ### Fixes diff --git a/CLAUDE.md b/CLAUDE.md index 419b8b5f7..58b693796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,10 @@ When bumping the version, update all three files: 3. `CHANGELOG.md` (add new version entry at top) - Follows semantic versioning +- **Never use an `## Unreleased` heading in `CHANGELOG.md`.** Unreleased changes always live under the next concrete version number (e.g. `## 4.16.4`). +- To pick that number, compare the version on `develop` with the version on `master`: + - If develop's version is **above** master's, a release is already staged β€” add your entries to that existing top section. Do not bump again. + - If develop's version **equals** master's, start the next release: add a new version section and bump all three files together (patch/minor/major per the change). ### Testing @@ -110,3 +114,15 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL - [ ] I have run `swiftlint` in the main directory and fixed any issues. - [ ] I have updated the SDK documentation as well as the online docs. - [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md) + +### Integration device identifiers + +AttributionFetcher refreshes IDFV/IDFA/ATT when setting integration attributes and +on app activation after an integration has been configured. Compare the complete +refreshed snapshot, not just provider IDs. Sync device values into user attributes +(the server integration router reads those) only when that snapshot changes, since +every sync costs a `user_attributes` event; explicit nulls clear stale IDs after +ATT revocation. ATT is serialized as a numeric string in integration attributes. +`idfv`, `idfa` and `attStatus` are SDK-owned user-attribute keys β€” document any +change to that set in the changelog and the online docs. Don't gate the IDFA on +the ATT status: `identifierForAdvertisers` already filters the all-zero id. diff --git a/Examples/Advanced/Advanced/Info.plist b/Examples/Advanced/Advanced/Info.plist index b7789af4d..34418debb 100644 --- a/Examples/Advanced/Advanced/Info.plist +++ b/Examples/Advanced/Advanced/Info.plist @@ -17,7 +17,7 @@ NSAppTransportSecurity - NSAllowsArbitraryLoads + NSAllowsLocalNetworking UIAppFonts diff --git a/Examples/Basic/Basic/Info.plist b/Examples/Basic/Basic/Info.plist index 05ff7f9a9..2fe75ce19 100644 --- a/Examples/Basic/Basic/Info.plist +++ b/Examples/Basic/Basic/Info.plist @@ -13,6 +13,11 @@ + NSAppTransportSecurity + + NSAllowsLocalNetworking + + UIAppFonts Rubik-Regular.ttf diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index f4f15fbe7..db6fbff27 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -19,7 +19,18 @@ final class AttributionFetcher { private let queue = DispatchQueue(label: "com.superwall.attributionfetcher") private let timerQueue = DispatchQueue(label: "com.superwall.attributionfetcher.timer") private var redeemTimer: DispatchSourceTimer? + private var activeObserver: NSObjectProtocol? + private let vendorIdProvider: (() -> String)? + private let attStatusProvider: (() -> Int?)? + private let idfaProvider: (() -> String?)? + private let syncUserAttributes: ([String: Any?]) -> Void private var _integrationAttributes: [String: String] = [:] + + /// The identifiers last handed to `syncUserAttributes`. Only a change + /// is worth syncing: every sync costs a `user_attributes` event, a delegate + /// callback, a Core Data row and a re-encode of the whole attribute dict. + private var _lastSyncedDeviceIdentifiers: [String: String]? + private unowned let storage: Storage private unowned let webEntitlementRedeemer: WebEntitlementRedeemer private unowned let deviceHelper: DeviceHelper @@ -135,54 +146,55 @@ final class AttributionFetcher { init( storage: Storage, deviceHelper: DeviceHelper, - webEntitlementRedeemer: WebEntitlementRedeemer + webEntitlementRedeemer: WebEntitlementRedeemer, + vendorIdProvider: (() -> String)? = nil, + attStatusProvider: (() -> Int?)? = nil, + idfaProvider: (() -> String?)? = nil, + syncUserAttributes: @escaping ([String: Any?]) -> Void = { + Superwall.shared.setUserAttributes($0) + } ) { + self.syncUserAttributes = syncUserAttributes + self.vendorIdProvider = vendorIdProvider + self.attStatusProvider = attStatusProvider + self.idfaProvider = idfaProvider self.storage = storage self.deviceHelper = deviceHelper self.webEntitlementRedeemer = webEntitlementRedeemer self._integrationAttributes = storage.get(IntegrationAttributes.self) ?? [:] + if let notification = SystemInfo.applicationDidBecomeActiveNotification { + activeObserver = NotificationCenter.default.addObserver( + forName: notification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.refreshDeviceIdentifiers() + } + } + } + + deinit { + if let activeObserver { + NotificationCenter.default.removeObserver(activeObserver) + } } func setIntegrationAttribute( attribute: IntegrationAttribute, - value: String?, - appTransactionId: String + value: String? ) { - let attributes = [attribute.description: value] - mergeIntegrationAttributes(attributes: attributes, appTransactionId: appTransactionId) + mergeIntegrationAttributes(attributes: [attribute.description: value]) } - func mergeIntegrationAttributes( - attributes: [String: String?], - appTransactionId: String - ) { + func mergeIntegrationAttributes(attributes: [String: String?]) { queue.async { [weak self] in guard let self = self else { return } - // Check if any values have actually changed - var hasChanges = false - for (key, newValue) in attributes { - let currentValue = self._integrationAttributes[key] - if currentValue != newValue { - hasChanges = true - break - } - } - - // If no changes, don't proceed - guard hasChanges else { - return + // Compare after refreshing device data: the provider ID can stay the + // same while ATT changes or a previously unavailable IDFV appears. + if self._mergeIntegrationAttributes(attributes: attributes) { + self._debouncedRedeem() } - - // Update attributes immediately - self._mergeIntegrationAttributes( - attributes: attributes, - appTransactionId: appTransactionId, - shouldRedeem: false // Don't redeem immediately - ) - - // Debounce only the redeem call - self._debouncedRedeem() } } @@ -222,52 +234,163 @@ final class AttributionFetcher { } } - private func _mergeIntegrationAttributes( - attributes: [String: String?], - appTransactionId: String, - shouldRedeem: Bool = true - ) { + /// - Returns: Whether the integration attributes changed. + private func _mergeIntegrationAttributes(attributes: [String: String?]) -> Bool { var mergedAttributes = _integrationAttributes - var hasChanges = false - - mergedAttributes["idfa"] = identifierForAdvertisers + for (key, value) in attributes { + mergedAttributes[key] = value + } + let device = currentDeviceIdentifiers + for key in DeviceIdentifiers.keys { + mergedAttributes[key] = device[key] + } - let identifierForVendor = deviceHelper.vendorId - mergedAttributes["idfv"] = identifierForVendor + // The router reads user attributes, not the integration_attributes event. + // Explicit nulls clear an IDFA retained from before consent was revoked. + if device != _lastSyncedDeviceIdentifiers { + _lastSyncedDeviceIdentifiers = device + syncUserAttributes(DeviceIdentifiers.userAttributes(for: device)) + } - for key in attributes.keys { - let newValue = attributes[key] - let currentValue = _integrationAttributes[key] + guard mergedAttributes != _integrationAttributes else { return false } + let updatedAttributes = mergedAttributes + Task { + let event = InternalSuperwallEvent.IntegrationAttributes( + audienceFilterParams: updatedAttributes + ) + await Superwall.shared.track(event) + } + storage.save(mergedAttributes, forType: IntegrationAttributes.self) + _integrationAttributes = mergedAttributes + return true + } +} - if currentValue != newValue { - hasChanges = true - if let value = newValue { - mergedAttributes[key] = value - } else { - mergedAttributes.removeValue(forKey: key) - } +// MARK: - Device identifiers +extension AttributionFetcher { + /// Forgets what was last synced when a write lands on one of the SDK's keys + /// carrying something other than what the SDK put there, so the next merge + /// puts its own value back. + /// + /// The sync gate compares against what the SDK last sent rather than what the + /// user's attributes actually hold, so without this an app that removed + /// `idfv` would keep it missing until an identifier itself changed β€” which on + /// a settled device may be never. + /// + /// Compares the values rather than just the keys because the SDK's own sync + /// comes back through this same setter, as do internal writers like the + /// enrichment response, which echoes the user's attributes back verbatim. A + /// write that agrees with what was sent isn't an overwrite and mustn't cost a + /// resync. + func forgetSyncedDeviceIdentifiers(ifChangedBy attributes: [String: Any?]) { + let touched = attributes.filter { DeviceIdentifiers.keys.contains($0.key) } + if touched.isEmpty { + return + } + queue.async { [weak self] in + guard let self, + let lastSynced = self._lastSyncedDeviceIdentifiers else { + return + } + let isOverwritten = touched.contains { key, value in + !DeviceIdentifiers.isWhatWasSent(value, forKey: key, in: lastSynced) + } + if isOverwritten { + self._lastSyncedDeviceIdentifiers = nil } } + } - // Only proceed if there are actual changes - guard hasChanges else { - return + /// The ATT authorization status, or `nil` where the OS has no such concept. + private var attStatus: Int? { + if let attStatusProvider { + return attStatusProvider() + } + #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS) + if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) { + return TrackingManagerProxy().trackingAuthorizationStatus() + } + #endif + return nil + } + + private var currentDeviceIdentifiers: [String: String] { + var attributes: [String: String] = [:] + + let vendorId = vendorIdProvider?() ?? deviceHelper.vendorId + if !vendorId.isEmpty { + attributes["idfv"] = vendorId } - Task { - let attributes = InternalSuperwallEvent.IntegrationAttributes( - audienceFilterParams: mergedAttributes - ) - await Superwall.shared.track(attributes) + if let attStatus { + attributes["attStatus"] = String(attStatus) } - storage.save(mergedAttributes, forType: IntegrationAttributes.self) - _integrationAttributes = mergedAttributes + // Don't gate this on the ATT status. Before iOS 14.5 the IDFA is available + // while ATT still reads `notDetermined`, and `TrackingManagerProxy` returns + // `notDetermined` both for a genuine one and for a build where the class + // can't be found, so the status can't tell those apart. The OS hands back + // the all-zero id when it doesn't want to share one, and + // `identifierForAdvertisers` already filters that out. + attributes["idfa"] = idfaProvider?() ?? identifierForAdvertisers - if shouldRedeem { - Task { - await webEntitlementRedeemer.redeem(.integrationAttributes) + return attributes + } + + func refreshDeviceIdentifiers() { + queue.async { [weak self] in + guard let self, !self._integrationAttributes.isEmpty else { return } + if self._mergeIntegrationAttributes(attributes: [:]) { + self._debouncedRedeem() + } + } + } + + /// Re-scopes the integration attributes for the user that `reset()` just + /// created. + /// + /// Identifiers that describe the person β€” see `isInstallScoped` β€” belong to + /// whoever was just signed out, so they go. The install-scoped ones describe + /// the same device either way and stay, along with the device identifiers. + /// + /// A reset clears the user's attributes and deletes the stored copy, which + /// lives in the user-specific directory, while the in-memory copy outlives + /// it. So write what's kept back to disk and hand all of it β€” not just what + /// changed β€” to the new user, who has none of it. Without the file the next + /// cold launch would start empty and the activation refresh would never run + /// again, so a later consent change would never clear the IDFA. + /// + /// Runs synchronously: `identify` redeems for the new user right after the + /// reset, and that request reads the stored attributes rather than this + /// object. No redeem is scheduled here; the identity redeems on its own. + func resetIntegrationAttributes() { + queue.sync { + if _integrationAttributes.isEmpty { + return + } + var kept = _integrationAttributes.filter { key, _ in + DeviceIdentifiers.keys.contains(key) + || IntegrationAttribute.installScopedKeys.contains(key) + } + if kept.isEmpty { + _integrationAttributes = [:] + _lastSyncedDeviceIdentifiers = nil + return + } + + let device = currentDeviceIdentifiers + for key in DeviceIdentifiers.keys { + kept[key] = device[key] + } + _integrationAttributes = kept + _lastSyncedDeviceIdentifiers = device + storage.save(kept, forType: IntegrationAttributes.self) + + var userAttributes = DeviceIdentifiers.userAttributes(for: device) + for (key, value) in kept where !DeviceIdentifiers.keys.contains(key) { + userAttributes[key] = value } + syncUserAttributes(userAttributes) } } } diff --git a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift new file mode 100644 index 000000000..f0b4b2c7b --- /dev/null +++ b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift @@ -0,0 +1,73 @@ +// +// DeviceIdentifiers.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 16/09/2026. +// + +import Foundation + +/// The identifiers the SDK owns in both the integration attributes and the +/// user's attributes: the vendor id, the advertising id, and the tracking +/// consent status that governs whether the advertising id exists. +/// +/// Not to be confused with the device attributes behind +/// `Superwall.getDeviceAttributes()`, which describe the device and the session +/// rather than identify it. +enum DeviceIdentifiers { + static let keys = ["idfa", "idfv", "attStatus"] + + /// The identifiers as the user's attributes should carry them. + /// + /// A missing identifier is sent as an explicit `NSNull()` rather than left + /// out: `setUserAttributes` reads a Swift `nil` as "delete this key", which + /// would leave the server holding the last value it saw. A null overwrites + /// that, which is what clears an IDFA once consent is revoked. + static func userAttributes(for identifiers: [String: String]) -> [String: Any?] { + var userAttributes: [String: Any?] = [:] + for key in keys { + if let value = identifiers[key] { + userAttributes[key] = value + } else { + userAttributes[key] = NSNull() + } + } + return userAttributes + } + + /// Whether a user-attribute value is the one last sent for that key. + /// + /// An identifier left out of the snapshot was sent as `NSNull()`, so a null + /// agreeing with an absent identifier is a match, not an overwrite. + static func isWhatWasSent( + _ value: Any?, + forKey key: String, + in lastSynced: [String: String] + ) -> Bool { + guard let sent = lastSynced[key] else { + return value is NSNull + } + return stringValue(of: value) == sent + } + + /// What a value says, rather than what it's boxed as. + /// + /// `attStatus` goes out as a quoted number and comes back through whatever + /// echoes the user's attributes, so a server holding it as a JSON number + /// returns an `NSNumber` carrying the same answer. Reading that as a + /// different value would charge a redundant sync for every round trip. + /// + /// Booleans are not numbers here, whatever `NSNumber` says: `true` renders as + /// `"1"` and would pass for the `restricted` status, so an app writing one + /// would keep it instead of having the real status put back. + private static func stringValue(of value: Any?) -> String? { + if let string = value as? String { + return string + } + if let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { + return number.stringValue + } + return nil + } +} diff --git a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift index d0a713f32..213a344ac 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift @@ -7,74 +7,121 @@ /// An enum that represents attributes for third-party integrations with Superwall. @objc(SWKIntegrationAttribute) -public enum IntegrationAttribute: Int { - /// The unique Adjust identifier for the user. +public enum IntegrationAttribute: Int, CaseIterable { + /// The unique Adjust identifier for the device. + /// + /// Kept when you reset or identify a different user. case adjustId /// The Amplitude device identifier. + /// + /// Kept when you reset or identify a different user. case amplitudeDeviceId /// The Amplitude user identifier. + /// + /// Cleared when you reset or identify a different user. case amplitudeUserId - /// The unique Appsflyer identifier for the user. + /// The unique Appsflyer identifier for the device. + /// + /// Kept when you reset or identify a different user. case appsflyerId /// The Braze `alias_name` in User Alias Object. + /// + /// Cleared when you reset or identify a different user. case brazeAliasName /// The Braze `alias_label` in User Alias Object. + /// + /// Cleared when you reset or identify a different user. case brazeAliasLabel /// The OneSignal User ID (`onesignal_id`) for the user. + /// + /// Cleared when you reset or identify a different user. case onesignalId - /// The Facebook Anonymous identifier for the user. + /// The Facebook Anonymous identifier for the app install. + /// + /// Kept when you reset or identify a different user. case fbAnonId /// The Firebase instance identifier. + /// + /// Kept when you reset or identify a different user. case firebaseAppInstanceId /// The Firebase installation ID. + /// + /// Kept when you reset or identify a different user. case firebaseInstallationId /// The Iterable identifier for the user. + /// + /// Cleared when you reset or identify a different user. case iterableUserId /// The Iterable campaign identifier. + /// + /// Cleared when you reset or identify a different user. case iterableCampaignId /// The Iterable template identifier. + /// + /// Cleared when you reset or identify a different user. case iterableTemplateId /// The Mixpanel user identifier. + /// + /// Cleared when you reset or identify a different user. case mixpanelDistinctId /// The unique mParticle user identifier (mpid). + /// + /// Cleared when you reset or identify a different user. case mparticleId /// The CleverTap user identifier. + /// + /// Cleared when you reset or identify a different user. case clevertapId - /// The Airship channel identifier for the user. + /// The Airship channel identifier, which registers the device rather than + /// the named user. + /// + /// Kept when you reset or identify a different user. case airshipChannelId /// The unique Kochava device identifier. + /// + /// Kept when you reset or identify a different user. case kochavaDeviceId - /// The Tenjin identifier. + /// The Tenjin device identifier. + /// + /// Kept when you reset or identify a different user. case tenjinId /// The PostHog User identifer + /// + /// Cleared when you reset or identify a different user. case posthogUserId /// The Customer.io person's identifier (`id)`. + /// + /// Cleared when you reset or identify a different user. case customerioId - /// The Appstack identifier. + /// The Appstack device identifier. + /// + /// Kept when you reset or identify a different user. case appstackId /// The Singular device identifier (SDID). + /// + /// Kept when you reset or identify a different user. case singularDeviceId } @@ -131,3 +178,48 @@ extension IntegrationAttribute: CustomStringConvertible { } } } + +// MARK: - Scope +extension IntegrationAttribute { + /// Whether the identifier belongs to the app install rather than to the + /// person using it. + /// + /// A reset or an `identify` to a different user keeps the install-scoped + /// identifiers β€” they describe the same device either way β€” and drops the + /// rest, which belong to whoever was just signed out. The switch is + /// exhaustive on purpose: a new integration has to be placed on one side. + var isInstallScoped: Bool { + switch self { + case .adjustId, + .amplitudeDeviceId, + .appsflyerId, + .fbAnonId, + .firebaseAppInstanceId, + .firebaseInstallationId, + .airshipChannelId, + .kochavaDeviceId, + .tenjinId, + .appstackId, + .singularDeviceId: + return true + case .amplitudeUserId, + .brazeAliasName, + .brazeAliasLabel, + .onesignalId, + .iterableUserId, + .iterableCampaignId, + .iterableTemplateId, + .mixpanelDistinctId, + .mparticleId, + .clevertapId, + .posthogUserId, + .customerioId: + return false + } + } + + /// The keys of every identifier that survives a reset. + static let installScopedKeys: Set = Set( + allCases.filter(\.isInstallScoped).map(\.description) + ) +} diff --git a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift index fa5893e7b..d6690cabf 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift @@ -103,7 +103,15 @@ final class MMPAttributionManager { guard let cached = storage.get(MMPAcquisitionDataStorage.self) else { return } - mergeAcquisitionAttributesIfNeeded(cached) + let attributes = convertJSONToDictionary(attribution: cached) + if attributes.isEmpty { + return + } + // Merged without checking the current attributes first. The reset just + // wiped them, so there is nothing to compare against, and on the + // `identify()` path this runs on the identity manager's queue, where + // reading `userAttributes` would wait on that same queue and hang it. + Superwall.shared.setUserAttributes(attributes) } private func mergeAcquisitionAttributesIfNeeded(_ acquisitionAttributes: [String: JSON]) { diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index eb366a835..26495cb3c 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -324,6 +324,7 @@ enum InternalSuperwallEvent { struct SubscriptionStatusDidChange: TrackableSuperwallEvent { let superwallEvent: SuperwallEvent = .subscriptionStatusDidChange let status: SubscriptionStatus + var grantedEntitlements: Set = [] var audienceFilterParams: [String: Any] = [:] func getSuperwallParameters() async -> [String: Any] { var params: [String: Any] = [ @@ -333,6 +334,18 @@ enum InternalSuperwallEvent { params += [ "active_entitlement_ids": entitlements.map(\.id).joined(separator: ",") ] + // The merged set doesn't record where each entitlement came from, so + // this lists the active IDs that are also granted β€” a device record + // that beat a grant for the same ID is included too. + let grantedIds = Set(grantedEntitlements.map(\.id)) + let activeGrantedIds = entitlements + .filter { $0.isActive && grantedIds.contains($0.id) } + .map(\.id) + if !activeGrantedIds.isEmpty { + params += [ + "granted_entitlement_ids": activeGrantedIds.joined(separator: ",") + ] + } } return params } diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 2d2f7eff5..30e2b0154 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -90,11 +90,9 @@ extension Superwall { forPlacement placement: Trackable, withData placementData: PlacementData ) async { - // Assign the current register task while capturing the previous one. - previousRegisterTask = Task { [weak self, previousRegisterTask] in - // Wait until the previous register task is finished before continuing. - await previousRegisterTask?.value - + // Queue the work behind any register call already in flight and return, + // so implicit triggers and `register` calls don't present over each other. + registerTaskCoordinator.enqueue { [weak self] in await self?.internallyHandleImplicitTrigger( forPlacement: placement, withData: placementData diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 54aaf9a35..bc2c09581 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -35,6 +35,26 @@ class ConfigManager { var configRetryCount = 0 + /// The purchases load that `fetchConfiguration` published `configState` ahead + /// of, when the saved customer info proved the user was still entitled. Trial + /// eligibility waits on it so an upgrade during the load still sees the active + /// subscription groups. + /// + /// Protected by a queue: it's written on the config fetch and read from the + /// purchase path, which doesn't wait for config. + private var _initialPurchasesLoad: Task? + private let initialPurchasesLoadQueue = DispatchQueue( + label: "com.superwall.initialPurchasesLoad" + ) + + var initialPurchasesLoad: Task? { + initialPurchasesLoadQueue.sync { _initialPurchasesLoad } + } + + private func setInitialPurchasesLoad(_ task: Task) { + initialPurchasesLoadQueue.sync { _initialPurchasesLoad = task } + } + private unowned let storeKitManager: StoreKitManager unowned let storage: Storage private unowned let network: Network @@ -44,9 +64,9 @@ class ConfigManager { private unowned let webEntitlementRedeemer: WebEntitlementRedeemer let expressionEvaluator: CELEvaluator - /// Serializes preloading so concurrent callers can't race on the task + /// Runs preloads one at a time so concurrent callers can't race on the task /// reference. See ``preloadAllPaywalls()``. - private let preloadingCoordinator = PreloadingTaskCoordinator() + private let preloadingCoordinator = SerialTaskCoordinator() typealias Factory = RequestFactory & AudienceFilterAttributesFactory @@ -123,7 +143,6 @@ class ConfigManager { await paywallManager.removePaywalls(withIds: removedOrChangedPaywallIds) await processConfig(newConfig, isFirstTime: false) - configState.send(.retrieved(newConfig)) let configRefresh = InternalSuperwallEvent.ConfigRefresh( buildId: newConfig.buildId, @@ -150,15 +169,7 @@ class ConfigManager { // Step 1: Determine fetch strategy based on subscription status and cached data let cachedConfig = storage.get(LatestConfig.self) - let cachedSubsStatus = storage.get(SubscriptionStatusKey.self) - - let isTestModeSubscription = storage.get(IsTestModeActiveSubscription.self) ?? false - let isSubscribed: Bool - if case .active = cachedSubsStatus, !isTestModeSubscription { - isSubscribed = true - } else { - isSubscribed = false - } + let isSubscribed = hasActiveCachedSubscription() let shouldFetchAsync = cachedConfig != nil && isSubscribed @@ -193,9 +204,20 @@ class ConfigManager { InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes) ) - // Step 6: Process config and set state - await processConfig(config, isFirstTime: true) - configState.send(.retrieved(config)) + // Step 6: Process config and set state. + // + // `processConfig` reads StoreKit, which can take many seconds on a weak + // network, and every `register` call waits on `configState`. When the + // customer info saved by the previous launch proves the user is entitled + // through a date that hasn't arrived yet, config is published before that + // read and the in-memory purchase state is rebuilt from the saved copy. + // Only changes made since the last launch can then be missed, and the + // read corrects those when it lands. + await processConfig( + config, + isFirstTime: true, + publishingEarlyFrom: shouldFetchAsync ? savedCustomerInfoForEarlyPublish() : nil + ) // Step 7: Schedule background tasks scheduleBackgroundTasks( @@ -211,6 +233,53 @@ class ConfigManager { // MARK: - Config Fetch Helpers + /// Whether the cached subscription state indicates an active subscriber. + /// + /// The persisted status is the assigned value, before developer-granted + /// entitlements are merged in, so those are checked from their own storage. + private func hasActiveCachedSubscription() -> Bool { + if storage.get(IsTestModeActiveSubscription.self) ?? false { + return false + } + // isActive rather than a bare `case .active` match: the persisted status + // can be .active with no active entitlement (e.g. a developer-assigned + // .active([])). + if storage.get(SubscriptionStatusKey.self)?.isActive == true { + return true + } + return entitlementsInfo.granted.contains { $0.isActive } + } + + /// The saved customer info when it proves the user is still entitled, so + /// config can be published before this launch's StoreKit read. `expiresAt` is + /// a date the store asserted, and a subscription can't lapse before it, so a + /// future date is a guarantee, not a guess. Refunds are the one thing it can't + /// see, and the read catches those seconds later. A purchase controller + /// changes nothing here: the read never sets the status in that setup, and + /// the saved entitlements it preserves carry the same expiry. + private func savedCustomerInfoForEarlyPublish() -> CustomerInfo? { + // StoreKit 2 only: its read is the slow one, and it is the only one that + // saves the device rows the restore rebuilds from. A StoreKit 1 receipt is + // parsed locally and saves no rows, so there would be nothing to restore. + guard #available(iOS 15.0, *), options.storeKitVersion == .storeKit2 else { + return nil + } + guard let customerInfo = storage.get(LatestCustomerInfo.self) else { + return nil + } + // A developer-granted entitlement is the developer's own verdict. The read + // merges it back in on every load, so nothing it learns can change it. + if entitlementsInfo.granted.contains(where: { $0.isActive }) { + return customerInfo + } + // A lifetime purchase has no expiry to check and can only be refunded, + // which is the same risk the expiry case already accepts. + let isStillEntitled = customerInfo.entitlements.contains { + $0.isActive && ($0.isLifetime == true || ($0.expiresAt ?? .distantPast) > Date()) + } + return isStillEntitled ? customerInfo : nil + } + private struct ConfigFetchResult { let config: Config let isUsingCached: Bool @@ -240,13 +309,7 @@ class ConfigManager { // Fetch config synchronously let enableConfigRefresh = cachedConfig?.featureFlags.enableConfigRefresh ?? false - let isActiveSubscription: Bool - if case .active = storage.get(SubscriptionStatusKey.self), - !(storage.get(IsTestModeActiveSubscription.self) ?? false) { - isActiveSubscription = true - } else { - isActiveSubscription = false - } + let isActiveSubscription = hasActiveCachedSubscription() let timeout: TimeInterval = isActiveSubscription ? 0.5 : 1 if let cachedConfig = cachedConfig, @@ -294,13 +357,7 @@ class ConfigManager { // Fetch enrichment with timeout for sync path let enableConfigRefresh = cachedConfig?.featureFlags.enableConfigRefresh ?? false - let isActiveSubscription: Bool - if case .active = storage.get(SubscriptionStatusKey.self), - !(storage.get(IsTestModeActiveSubscription.self) ?? false) { - isActiveSubscription = true - } else { - isActiveSubscription = false - } + let isActiveSubscription = hasActiveCachedSubscription() let timeout: TimeInterval = isActiveSubscription ? 0.5 : 1 let cachedEnrichment = storage.get(LatestEnrichment.self) @@ -390,9 +447,15 @@ class ConfigManager { ) } + /// Applies `config`, loads purchases from StoreKit, and sends `configState`. + /// + /// - Parameter savedCustomerInfo: When given, `configState` is sent before the + /// StoreKit read starts, with the in-memory purchase state rebuilt from this + /// saved copy. Ignored in test mode, where products come from the API. private func processConfig( _ config: Config, - isFirstTime: Bool + isFirstTime: Bool, + publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil ) async { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) @@ -407,6 +470,7 @@ class ConfigManager { let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode + var hasPublishedConfig = false if testModeManager.isTestMode { // In test mode, fetch products from API instead of StoreKit await fetchTestModeProducts(testModeManager: testModeManager) @@ -417,14 +481,35 @@ class ConfigManager { // loadPurchasedProducts / the controller itself will restore it. if testModeJustDeactivated, !factory.makeHasExternalPurchaseController() { - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) + // Granted entitlements survive test mode, so the customer info has + // to keep carrying them while the status does. Superwall.shared.customerInfo = CustomerInfo( subscriptions: [], nonSubscriptions: [], entitlements: [] + ).merging(with: .blank(), granting: entitlementsInfo.granted) + } + // The saved copy from a test-mode launch can still hold test + // entitlements, so it isn't restored when test mode just turned off. + if let savedCustomerInfo = savedCustomerInfo, !testModeJustDeactivated { + await factory.restorePurchases( + from: savedCustomerInfo, + grantedEntitlements: entitlementsInfo.granted, + config: config ) + // Stored before the send so anything that presents on this config can + // wait for the load through `initialPurchasesLoad`. + let purchasesLoad = Task { [factory] in + await factory.loadPurchasedProducts(config: config) + } + setInitialPurchasesLoad(purchasesLoad) + configState.send(.retrieved(config)) + hasPublishedConfig = true + await purchasesLoad.value + } else { + await factory.loadPurchasedProducts(config: config) } - await factory.loadPurchasedProducts(config: config) } if !testModeManager.isTestMode { @@ -437,11 +522,20 @@ class ConfigManager { } // Show test mode alert if it's the first time OR if test mode just became active - let shouldShowTestModeAlert = isFirstTime || testModeJustActivated - if shouldShowTestModeAlert, + if isFirstTime || testModeJustActivated, testModeManager.isTestMode, let reason = testModeManager.testModeReason { - await presentTestModeModal(reason: reason, config: config) + if DevMode.isActive(options) { + await applyDefaultTestModeState(testModeManager: testModeManager) + } else { + await presentTestModeModal(reason: reason, config: config) + } + } + + // Last on purpose: the test mode modal above waits for the tester to pick + // entitlements, and anything waiting on config must see that choice. + if !hasPublishedConfig { + configState.send(.retrieved(config)) } } @@ -558,7 +652,10 @@ class ConfigManager { /// /// A developer can disable preloading of paywalls by setting ``SuperwallOptions/shouldPreloadPaywalls``. private func preloadPaywalls() async { - guard Superwall.shared.options.paywalls.shouldPreload else { + guard + Superwall.shared.options.paywalls.shouldPreload, + !DevMode.isActive(Superwall.shared.options) + else { return } await preloadAllPaywalls() @@ -566,13 +663,10 @@ class ConfigManager { /// Preloads paywalls referenced by triggers. func preloadAllPaywalls() async { - // Chain onto any in-flight preload through the coordinator. The coordinator - // is an actor, so swapping in the new task is serialized. Previously this was - // `self.currentPreloadingTask = Task { ... }` on a non-isolated class, so - // concurrent callers (config refresh, retry, reset, public API) raced on the - // task reference and over-released it, crashing in `swift_release` during - // task teardown. - await preloadingCoordinator.enqueue { [weak self] in + // Queue the preload behind any that's already in flight and return. It's + // kicked off from several places (config refresh, retry, reset, public + // API), so the queue has to be safe to add to from any thread. + preloadingCoordinator.enqueue { [weak self] in guard let self = self else { return } @@ -613,23 +707,6 @@ class ConfigManager { } } - /// Serializes the read-modify-write of the preloading task so it can't be - /// mutated from multiple tasks at once. Each enqueued operation runs only - /// after the previously enqueued one finishes, preserving the original - /// chaining behavior while making the swap data-race free. - private actor PreloadingTaskCoordinator { - private var currentTask: Task? - - /// Atomically chains `operation` after any in-flight preloading task. - func enqueue(_ operation: @escaping @Sendable () async -> Void) { - let previous = currentTask - currentTask = Task { - await previous?.value - await operation() - } - } - } - /// Preloads paywalls referenced by the provided triggers. func preloadPaywalls(for placementNames: Set) async { guard @@ -681,7 +758,6 @@ class ConfigManager { for: paywall, isDebuggerLaunched: request.isDebuggerLaunched, isForPresentation: true, - isPreloading: true, delegate: nil ) } @@ -724,6 +800,24 @@ class ConfigManager { } } + /// Seeds the state the test mode modal would otherwise collect, without + /// presenting it. Used when a dev server drives the SDK: every entitlement + /// starts inactive so paywalls present, and purchases flip them for real. + @MainActor + private func applyDefaultTestModeState(testModeManager: TestModeManager) async { + testModeManager.setEntitlements([]) + let testModeCustomerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [] + ) + testModeManager.overriddenCustomerInfo = testModeCustomerInfo + Superwall.shared.customerInfo = testModeCustomerInfo + testModeManager.overriddenSubscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) + storage.save(false, forType: IsTestModeActiveSubscription.self) + } + @MainActor private func presentTestModeModal(reason: TestModeReason, config: Config) async { guard @@ -783,11 +877,11 @@ class ConfigManager { if hasActiveEntitlements { let status = SubscriptionStatus.active(result.entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(assigned: status) storage.save(true, forType: IsTestModeActiveSubscription.self) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) storage.save(false, forType: IsTestModeActiveSubscription.self) } } diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c39e7ab20..e0a0bf34f 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -388,6 +388,78 @@ public final class SuperwallOptions: NSObject, Encodable { /// - `.always`: Test mode is always activated, regardless of configuration. public var testModeBehavior: TestModeBehavior = .automatic + /// A running `superwall dev` server for ``SuperwallOptions/devServer`` to connect to. + public enum DevServer: Equatable { + /// Finds the dev server on `localhost` ports 6100–6104, which reaches a server + /// running on the same machine from a simulator. + case `default` + + /// The dev server at an exact address β€” the `Device` URL's origin that + /// `superwall dev` prints, e.g. `http://192.168.1.10:6100`. Use this on a + /// physical device, which can't reach your machine via `localhost`. + case url(URL) + } + + /// Connects this SDK instance to a running `superwall dev` server, for development builds only. + /// + /// Paywalls with a local counterpart on the dev server then render from your live, local + /// paywall code instead of their published versions, while configuration, placements, + /// audience evaluation, assignment and the enforcement of feature gating all stay real. + /// Paywalls without a local counterpart still load their published versions. + /// + /// What the local surface owns is what it renders and the settings its `config.ts` + /// declares: content, products, presentation style, feature gating, intro offer + /// eligibility, scrolling and background colours all come from your local code, so + /// changing them shows on the next presentation without a push. What `config.ts` + /// cannot express β€” surveys, computed properties β€” comes from the published paywall + /// the surface stands in for, and so does any setting your `config.ts` leaves out. + /// + /// A dev server older than this SDK sends no settings at all, in which case every + /// setting comes from the published paywall. + /// + /// Two exceptions. Local notifications are always the local paywall's: the published + /// ones are ignored and the ones your `config.ts` declares fire without a push, because + /// they reach the SDK from the paywall itself rather than from the dashboard. And the + /// on-device cache stays off however either side configures it, so a page you are + /// editing is never served from the web view's cache. + /// + /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` + /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. + /// + /// The dev server also activates test mode (simulated purchases, product data from the + /// dashboard), disables paywall preloading, and skips the test mode intro sheet. + /// + /// The host app must allow local networking in its `Info.plist` + /// (`NSAppTransportSecurity` β†’ `NSAllowsLocalNetworking`). That one key covers + /// every address `superwall dev` prints: `localhost`, `.local` hosts, and the + /// plain IP address in the `Device` URL, which App Transport Security stopped + /// permitting by default in iOS 17. + @nonobjc public var devServer: DevServer? + + /// Objective-C only: connects to a `superwall dev` server found on `localhost`. + @available(swift, obsoleted: 1.0) + public func enableDevServer() { + devServer = .default + } + + /// Objective-C only: connects to the `superwall dev` server at this address. + @available(swift, obsoleted: 1.0) + public func enableDevServer(url: URL) { + devServer = .url(url) + } + + var isDevServerEnabled: Bool { + return devServer != nil + } + + /// Where ``devServer``'s ``DevServer/url(_:)`` case points, if that's what is set. + var devServerURL: URL? { + if case .url(let url) = devServer { + return url + } + return nil + } + /// Determines the number of times the SDK will attempt to get the Superwall configuration after a network /// failure before it times out. Defaults to 6. /// diff --git a/Sources/SuperwallKit/Debug/DebugManager.swift b/Sources/SuperwallKit/Debug/DebugManager.swift index bb2ebe140..9b1109285 100644 --- a/Sources/SuperwallKit/Debug/DebugManager.swift +++ b/Sources/SuperwallKit/Debug/DebugManager.swift @@ -12,6 +12,10 @@ final class DebugManager { @MainActor var viewController: DebugViewController? var isDebuggerLaunched = false + /// The surfaces a running `superwall dev` server exposes, and where it lives. + /// Set when the debugger is opened from a `superwall_dev` deep link. + @MainActor var devServer: (base: URL, surfaces: [DevServerSurface])? + private unowned let storage: Storage private unowned let factory: ViewControllerFactory struct DeepLinkOutcome { @@ -68,31 +72,39 @@ final class DebugManager { /// /// Remember to add your URL scheme in settings for QR code scanning to work. @MainActor - func launchDebugger(withPaywallId paywallDatabaseId: String? = nil) async { + func launchDebugger( + withPaywallId paywallDatabaseId: String? = nil, + devSurfaceId: String? = nil + ) async { if Superwall.shared.isPaywallPresented { await Superwall.shared.dismiss() - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } else { if viewController == nil { let milliseconds = 200 let nanoseconds = UInt64(milliseconds * 1_000_000) try? await Task.sleep(nanoseconds: nanoseconds) - await presentDebugger(withPaywallId: paywallDatabaseId) + await presentDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } else { await closeDebugger(animated: true) - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } } } @MainActor - func presentDebugger(withPaywallId paywallDatabaseId: String? = nil) async { + func presentDebugger( + withPaywallId paywallDatabaseId: String? = nil, + devSurfaceId: String? = nil + ) async { isDebuggerLaunched = true if let viewController = viewController { if viewController.isBeingPresented { return } viewController.paywallDatabaseId = paywallDatabaseId + viewController.devServer = devServer + viewController.selectDevSurface(id: devSurfaceId) await viewController.loadPreview() await UIViewController.topMostViewController?.present( viewController, @@ -100,6 +112,8 @@ final class DebugManager { ) } else { let viewController = factory.makeDebugViewController(withDatabaseId: paywallDatabaseId) + viewController.devServer = devServer + viewController.selectDevSurface(id: devSurfaceId) UIViewController.topMostViewController?.present( viewController, animated: true, diff --git a/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift new file mode 100644 index 000000000..29285c7a5 --- /dev/null +++ b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift @@ -0,0 +1,172 @@ +// +// DebugPaywallPickerViewController.swift +// SuperwallKit +// +// The debugger's paywall list: a searchable, sectioned table of the local +// surfaces a `superwall dev` server serves and the app's published paywalls. +// + +import UIKit + +@MainActor +final class DebugPaywallPickerViewController: UIViewController { + private let localSurfaceIds: [String] + private let publishedNames: [String] + private let selectedLocalId: String? + private let selectedPublishedIndex: Int? + private let onSelect: (DebugPickerLogic.Kind) -> Void + + private var sections: [DebugPickerLogic.Section] = [] + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.backgroundColor = darkBackgroundColor + table.separatorColor = UIColor.white.withAlphaComponent(0.1) + table.dataSource = self + table.delegate = self + table.keyboardDismissMode = .onDrag + table.translatesAutoresizingMaskIntoConstraints = false + return table + }() + + private lazy var searchController: UISearchController = { + let controller = UISearchController(searchResultsController: nil) + controller.searchResultsUpdater = self + controller.obscuresBackgroundDuringPresentation = false + controller.searchBar.placeholder = "Search paywalls" + controller.searchBar.tintColor = primaryColor + controller.searchBar.searchTextField.textColor = .white + return controller + }() + + init( + localSurfaceIds: [String], + publishedNames: [String], + selectedLocalId: String?, + selectedPublishedIndex: Int?, + onSelect: @escaping (DebugPickerLogic.Kind) -> Void + ) { + self.localSurfaceIds = localSurfaceIds + self.publishedNames = publishedNames + self.selectedLocalId = selectedLocalId + self.selectedPublishedIndex = selectedPublishedIndex + self.onSelect = onSelect + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = darkBackgroundColor + title = "Paywalls" + + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(pressedClose) + ) + navigationItem.rightBarButtonItem?.tintColor = primaryColor + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + reload(query: "") + } + + private func reload(query: String) { + sections = DebugPickerLogic.sections( + localSurfaceIds: localSurfaceIds, + publishedNames: publishedNames, + selectedLocalId: selectedLocalId, + selectedPublishedIndex: selectedPublishedIndex, + query: query + ) + tableView.reloadData() + } + + @objc private func pressedClose() { + dismiss(animated: true) + } +} + +// MARK: - Table + +extension DebugPaywallPickerViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].rows.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].title + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = sections[indexPath.section].rows[indexPath.row] + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.backgroundColor = lightBackgroundColor + cell.textLabel?.text = row.title + cell.textLabel?.textColor = .white + cell.textLabel?.font = .systemFont(ofSize: 16, weight: row.isSelected ? .semibold : .regular) + cell.accessoryType = row.isSelected ? .checkmark : .none + cell.tintColor = primaryColor + let selected = UIView() + selected.backgroundColor = UIColor.white.withAlphaComponent(0.08) + cell.selectedBackgroundView = selected + return cell + } + + func tableView( + _ tableView: UITableView, + willDisplayHeaderView view: UIView, + forSection section: Int + ) { + guard let header = view as? UITableViewHeaderFooterView else { + return + } + // A grouped header renders through its content configuration on iOS 14+, + // which ignores `textLabel` β€” the default grey is unreadable on the + // debugger's near-black sheet. + if #available(iOS 14.0, *) { + var configuration = header.defaultContentConfiguration() + configuration.text = sections[section].title + configuration.textProperties.color = UIColor.white.withAlphaComponent(0.5) + configuration.textProperties.font = .systemFont(ofSize: 13, weight: .semibold) + header.contentConfiguration = configuration + } else { + header.textLabel?.textColor = UIColor.white.withAlphaComponent(0.5) + header.textLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = sections[indexPath.section].rows[indexPath.row] + let onSelect = self.onSelect + dismiss(animated: true) { + onSelect(row.kind) + } + } +} + +// MARK: - Search + +extension DebugPaywallPickerViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + reload(query: searchController.searchBar.text ?? "") + } +} diff --git a/Sources/SuperwallKit/Debug/DebugPickerLogic.swift b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift new file mode 100644 index 000000000..6dd064651 --- /dev/null +++ b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift @@ -0,0 +1,63 @@ +// +// DebugPickerLogic.swift +// SuperwallKit +// +// Builds the debugger's paywall list: the surfaces a running +// `superwall dev` server serves, then the paywalls the app has published. +// + +import Foundation + +enum DebugPickerLogic { + enum Kind: Equatable { + case local(index: Int) + case published(index: Int) + } + + struct Row: Equatable { + let title: String + let kind: Kind + let isSelected: Bool + } + + struct Section: Equatable { + let title: String + let rows: [Row] + } + + static let localTitle = "Local Β· superwall dev" + static let publishedTitle = "Published" + + static func sections( + localSurfaceIds: [String], + publishedNames: [String], + selectedLocalId: String?, + selectedPublishedIndex: Int?, + query: String = "" + ) -> [Section] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + func matches(_ title: String) -> Bool { + return needle.isEmpty || title.lowercased().contains(needle) + } + + let local = localSurfaceIds.enumerated() + .filter { matches($0.element) } + .map { index, id in + Row(title: id, kind: .local(index: index), isSelected: id == selectedLocalId) + } + let published = publishedNames.enumerated() + .filter { matches($0.element) } + .map { index, name in + Row( + title: name, + kind: .published(index: index), + isSelected: index == selectedPublishedIndex && selectedLocalId == nil + ) + } + + return [ + Section(title: localTitle, rows: local), + Section(title: publishedTitle, rows: published) + ].filter { !$0.rows.isEmpty } + } +} diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index e02936f7a..fbba1dfc3 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -118,6 +118,14 @@ final class DebugViewController: UIViewController { var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? + + /// Set when the debugger is opened from a `superwall dev` link: the surfaces + /// that server exposes, and where to load them from. + var devServer: (base: URL, surfaces: [DevServerSurface])? + + /// The dev-server surface to render instead of fetching a published paywall. + private var devSurface: DevServerSurface? + /// Backs the "Your Paywalls" picker. /// /// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app @@ -209,10 +217,68 @@ final class DebugViewController: UIViewController { func loadPreview() async { activityIndicator.startAnimating() previewViewContent?.removeFromSuperview() + await ensureDevServer() await finishLoadingPreview() } + /// Dev mode's local surfaces belong in the debugger however it was opened β€” + /// a dashboard preview link should list them too, not just a dev link. + /// + /// The manifest is fetched again on every load, not only the first: a + /// surface's products and presentation settings come from its config.ts, + /// and edits made since the debugger opened must show up in the preview. + /// When the server can't be reached the last snapshot stands; a surface the + /// server no longer lists is dropped, since presenting it would fail too. + private func ensureDevServer() async { + guard + DevMode.isActive(Superwall.shared.options), + let location = await DevServerLocator.shared.locate( + devServerURL: Superwall.shared.options.devServerURL + ) + else { + return + } + let located = (base: location.base, surfaces: location.manifest.surfaces) + devServer = located + // Presenting a `dev:` surface resolves it from the debug manager's copy, + // so both stores have to agree however the debugger was opened. + debugManager.devServer = located + // The selected surface was picked from the previous manifest; carry the + // selection over to the fresh one so the preview renders what it says now. + // Resolved the same way Present resolves it, so the two can't disagree + // about whether the surface still exists. + guard let devSurface = devSurface else { + return + } + if let resolved = DevServerPreview.resolveSurface( + previewIdentifier: devSurface.previewIdentifier, + fresh: location, + snapshot: nil + ) { + self.devSurface = resolved.surface + return + } + Logger.debug( + logLevel: .warn, + scope: .debugViewController, + message: "The dev server no longer lists the surface \(devSurface.id). " + + "Pick another paywall to preview." + ) + self.devSurface = nil + if paywallIdentifier == devSurface.previewIdentifier { + paywallIdentifier = nil + paywallDatabaseId = nil + } + // The title still named the dropped surface over an empty preview. + previewPickerButton.setTitle("Choose a paywall", for: .normal) + } + func finishLoadingPreview() async { + if let devSurface = devSurface { + await loadDevServerPreview(surface: devSurface) + return + } + var paywallId: String? if let paywallIdentifier = paywallIdentifier { @@ -234,6 +300,9 @@ final class DebugViewController: UIViewController { return } } else { + // Nothing selected: leave the picker for the developer rather than a + // spinner with nothing behind it. + activityIndicator.stopAnimating() return } @@ -247,8 +316,7 @@ final class DebugViewController: UIViewController { ) var paywall = try await paywallRequestManager.getPaywall(from: request) - let productVariables = await storeKitManager.getProductVariables(for: paywall) - paywall.productVariables = productVariables + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) @@ -335,41 +403,111 @@ final class DebugViewController: UIViewController { } } + /// Picks the dev-server surface the debugger opens with, if any. + func selectDevSurface(id: String?) { + guard let id = id else { + return + } + devSurface = devServer?.surfaces.first { $0.id == id } + } + + /// Renders a surface straight from the dev server, synthesised from the + /// manifest (local URL + the products its `config.ts` declares). Nothing is + /// fetched from the dashboard, so a paywall that has never been pushed β€” + /// or whose app lives in another environment β€” still previews. + /// + /// `ensureDevServer` has just refreshed `devServer`, so the surface passed + /// in is the one the manifest describes now. + private func loadDevServerPreview(surface: DevServerSurface) async { + guard + let devServer = devServer, + let url = DevServerManifest(surfaces: devServer.surfaces) + .mountURL(for: surface, base: devServer.base) + else { + activityIndicator.stopAnimating() + return + } + + var paywall = Paywall.devServer(surface: surface, url: url) + // Product variables are best-effort here: a surface can name products the + // store has no record of yet, and the preview must still render. + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) + self.paywall = paywall + paywallIdentifier = surface.previewIdentifier + paywallDatabaseId = paywall.databaseId + previewPickerButton.setTitle("\(surface.id) (local)", for: .normal) + activityIndicator.stopAnimating() + addPaywallPreview() + } + + /// The published paywalls to offer. The debugger's preview list needs the + /// token a dashboard preview link carries; the downloaded config carries the + /// same paywalls for free, which is what a `superwall dev` link relies on. + private var publishedPaywalls: [PaywallSummary] { + if !previewPaywalls.isEmpty { + return previewPaywalls + } + let config = Superwall.shared.dependencyContainer.configManager?.config + return (config?.paywalls ?? []).map { + PaywallSummary(id: $0.databaseId, identifier: $0.identifier, name: $0.name) + } + } + + /// Whether the picker has anything to offer: local surfaces, a choice of + /// published paywalls, or no paywall selected yet. + private var canOpenPicker: Bool { + if devServer?.surfaces.isEmpty == false { + return true + } + if publishedPaywalls.count > 1 { + return true + } + if paywallDatabaseId == nil { + return true + } + return false + } + @objc func pressedPreview() { - // Open whenever there is something to switch *to*. That covers an empty list - // (the request failed) and a single-entry list whose one paywall is already - // on screen, without gating on `paywallDatabaseId` β€” which is nil when the - // deep link carried no `paywall_id` and nothing rendered. That is precisely - // when the picker is most useful, so it must not be inert then. - guard previewPaywalls.contains(where: { $0.id != paywallDatabaseId }) else { return } - - let options: [AlertOption] = previewPaywalls.map { paywall in - var name = paywall.name - - // Optional comparison: with no paywall on screen nothing is marked, which - // is correct rather than a case to guard against. - if paywall.id == paywallDatabaseId { - name = "\(name) βœ“" + guard canOpenPicker else { + return + } + let devSurfaces = devServer?.surfaces ?? [] + let published = publishedPaywalls + + let picker = DebugPaywallPickerViewController( + localSurfaceIds: devSurfaces.map { $0.id }, + publishedNames: published.map { $0.name }, + selectedLocalId: devSurface?.id, + selectedPublishedIndex: published.firstIndex { $0.id == paywallDatabaseId } + ) { [weak self] kind in + guard let self = self else { + return } - - let alert = AlertOption( - title: name, - action: { [weak self] in - self?.paywallDatabaseId = paywall.id - self?.paywallIdentifier = paywall.identifier - Task { await self?.loadPreview() } - }, - style: .default - ) - return alert + switch kind { + case .local(let index): + self.devSurface = devSurfaces[index] + case .published(let index): + self.devSurface = nil + self.paywallDatabaseId = published[index].id + self.paywallIdentifier = published[index].identifier + } + Task { await self.loadPreview() } } - presentAlert( - title: nil, - message: "Your Paywalls", - options: options, - on: previewPickerButton - ) + let navigationController = UINavigationController(rootViewController: picker) + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] + navigationController.modalPresentationStyle = .pageSheet + #if !os(visionOS) + if #available(iOS 15.0, *) { + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + } + #endif + present(navigationController, animated: true) } @objc func pressedExitButton() { diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 39191cf33..8564bc1cc 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -63,6 +63,10 @@ final class DeepLinkRouter { return true } + if DevServerPreview.handle(url: deepLinkUrl) { + return true + } + // Return true for Superwall deep links (we handled it above) if isSuperwallDeepLink { return true @@ -135,6 +139,15 @@ final class DeepLinkRouter { return true } + // Dev links count as Superwall's only while dev mode is verifiably on. + // Before initialization there are no options to check (and touching + // `Superwall.shared` would assert), so don't claim the link β€” it is + // stored above and routed again once config arrives. + if Superwall.isInitialized, + DevServerPreview.canHandle(url: url, options: Superwall.shared.options) { + return true + } + // Check cached config for deepLink_open trigger let cache = Cache() if let config = cache.read(LatestConfig.self) { diff --git a/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift b/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift index 2b1ceb936..d9e22acdc 100644 --- a/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift +++ b/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift @@ -16,7 +16,14 @@ import Foundation /// To learn how to conform to the delegate in your app and best practices, see /// [our docs](https://docs.superwall.com/docs/3rd-party-analytics). public protocol SuperwallDelegate: AnyObject { - /// Called when the ``Superwall/subscriptionStatus`` changes. + /// Called when the logical state of ``Superwall/subscriptionStatus`` changes: + /// the status moves between unknown, inactive, and active, an entitlement is + /// gained or lost, or an entitlement's `isActive` flag changes. + /// + /// Updates that only change transaction metadata β€” such as expiry dates, + /// renewal state, or the store an entitlement came from β€” don't trigger this + /// callback. Use ``SuperwallDelegate/customerInfoDidChange(from:to:)`` to + /// react to those. /// /// You can use this function to update the state of your application. Alternatively, you can /// use the published property ``Superwall/subscriptionStatus`` to react to diff --git a/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift b/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift index 632478d54..f67171242 100644 --- a/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift +++ b/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift @@ -78,7 +78,14 @@ public protocol SuperwallDelegateObjc: AnyObject { @MainActor @objc optional func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) - /// Called when the ``Superwall/subscriptionStatusObjc`` changes. + /// Called when the logical state of ``Superwall/subscriptionStatusObjc`` changes: + /// the status moves between unknown, inactive, and active, an entitlement is + /// gained or lost, or an entitlement's `isActive` flag changes. + /// + /// Updates that only change transaction metadata β€” such as expiry dates, + /// renewal state, or the store an entitlement came from β€” don't trigger this + /// callback. Use ``SuperwallDelegateObjc/customerInfoDidChange(from:to:)`` to + /// react to those. /// /// You can use this function to update the state of your application. /// diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba9622719..b33de6578 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -51,10 +51,11 @@ final class DependencyContainer { init( apiKey: String = "", purchaseController controller: PurchaseController? = nil, - options: SuperwallOptions? = nil + options: SuperwallOptions? = nil, + cache: Cache = Cache() ) { delegateAdapter = SuperwallDelegateAdapter() - storage = Storage(factory: self) + storage = Storage(factory: self, cache: cache) storage.configure(apiKey: apiKey) entitlementsInfo = EntitlementsInfo( storage: storage, @@ -469,7 +470,8 @@ extension DependencyContainer: AudienceFilterAttributesFactory { let deviceAttributes = await deviceHelper.getDeviceAttributes( since: placement, - computedPropertyRequests: computedPropertyRequests + computedPropertyRequests: computedPropertyRequests, + reportingUnknownFieldsAsNull: true ) return [ "user": userAttributes, @@ -496,6 +498,23 @@ extension DependencyContainer: ConfigManagerFactory { deviceLocale: deviceInfo.locale ) } + + func purchasesLoadCouldChange(entitlementIds: Set) -> Bool { + if entitlementIds.isEmpty { + return false + } + guard let config = configManager.config else { + // Without config we can't rule the load out, so say yes and let the + // caller wait. + return true + } + return config.products.contains { product in + guard case .appStore = product.type else { + return false + } + return product.entitlements.contains { entitlementIds.contains($0.id) } + } + } } // MARK: - StoreTransactionFactory @@ -596,6 +615,26 @@ extension DependencyContainer: ReceiptFactory { await receiptManager.loadPurchasedProducts(config: config) } + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async { + await receiptManager.restorePurchases( + from: customerInfo, + grantedEntitlements: grantedEntitlements, + config: config + ) + } + + /// nil means the load already finished, or config was never published early. + /// It is also nil for the moment before `processConfig` stores the task; a + /// purchase started that early skips the wait, which only affects whether the + /// transaction is reported as a trial start. + var initialPurchasesLoad: Task? { + configManager.initialPurchasesLoad + } + func refreshSK1Receipt() async { return await receiptManager.refreshSK1Receipt() } @@ -612,7 +651,13 @@ extension DependencyContainer: ReceiptFactory { return false } } - return await receiptManager.isFreeTrialAvailable(for: product) + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`). Only the upgrade check inside reads + // what that load computes, so it waits there rather than here. + return await receiptManager.isFreeTrialAvailable( + for: product, + waitingFor: initialPurchasesLoad + ) } var isTestMode: Bool { diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index e7748fcb6..f0ea1dce2 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -78,6 +78,17 @@ protocol ConfigManagerFactory: AnyObject { withId paywallId: String?, isDebuggerLaunched: Bool ) -> Paywall? + + /// Whether the purchases load could change what the SDK knows about + /// `entitlementIds`, and so whether callers have to wait for it. + /// + /// The load re-merges the web and granted sources when it lands, so it isn't + /// only the App Store half that can move. But both of those assign + /// `customerInfo` themselves as they change β€” the redeemer when web + /// entitlements arrive, and the granted-entitlements setter on both its + /// paths β€” so the copy read during the window is never behind on them. That + /// leaves the device half, which is why only App Store products count. + func purchasesLoadCouldChange(entitlementIds: Set) -> Bool } protocol IdentityFactory: AnyObject { @@ -157,6 +168,13 @@ protocol UserAttributesPlacementFactory: AnyObject { protocol ReceiptFactory: AnyObject { func loadPurchasedProducts(config: Config?) async + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async + /// The purchases load that config was published ahead of, if any. + var initialPurchasesLoad: Task? { get } func refreshSK1Receipt() async func isFreeTrialAvailable(for product: StoreProduct) async -> Bool var isTestMode: Bool { get } diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift new file mode 100644 index 000000000..c8f87a9dc --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -0,0 +1,46 @@ +// +// DevMode.swift +// SuperwallKit +// +// Dev mode is a development-only facility: it serves paywalls from a local +// `superwall dev` server and simulates purchases. Shipping it to the App +// Store would mean nobody could buy anything, so it is inert in production +// no matter how the SDK was configured. +// + +import Foundation + +enum DevMode { + private static var hasWarnedAboutProduction = false + + /// Whether this build is running somewhere dev mode is allowed. Overridable + /// so tests can exercise the production path, which no simulator can produce. + static var isSandboxEnvironment: () -> Bool = { DeviceHelper.isSandboxEnvironment } + + /// Whether dev mode should actually do anything right now: asked for, and + /// running somewhere it is safe to (simulator, TestFlight, development). + static func isActive(_ options: SuperwallOptions) -> Bool { + guard options.isDevServerEnabled else { + return false + } + guard isSandboxEnvironment() else { + warnAboutProduction() + return false + } + return true + } + + private static func warnAboutProduction() { + if hasWarnedAboutProduction { + return + } + hasWarnedAboutProduction = true + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "SuperwallOptions.devServer is set in a production build, so it is being ignored: " + + "paywalls load their published versions and purchases are real. " + + "Remove devServer before shipping." + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerCandidates.swift b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift new file mode 100644 index 000000000..02197b1e2 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift @@ -0,0 +1,22 @@ +// +// DevServerCandidates.swift +// SuperwallKit +// +// Where dev mode looks for a running `superwall dev` server. +// + +import Foundation + +enum DevServerCandidates { + static let defaultPorts = 6100...6104 + + /// The bases dev mode tries, in order: an explicit URL wins, otherwise + /// localhost across the default port range `superwall dev` walks when + /// its preferred port is taken. + static func bases(devServerURL: URL?) -> [URL] { + if let devServerURL = devServerURL { + return [devServerURL] + } + return defaultPorts.compactMap { URL(string: "http://localhost:\($0)") } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerLocation.swift b/Sources/SuperwallKit/DevServer/DevServerLocation.swift new file mode 100644 index 000000000..699529de1 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerLocation.swift @@ -0,0 +1,14 @@ +// +// DevServerLocation.swift +// SuperwallKit +// +// A found `superwall dev` server: the base it answered on and the +// manifest it served from there. +// + +import Foundation + +struct DevServerLocation: Equatable { + let base: URL + let manifest: DevServerManifest +} diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift new file mode 100644 index 000000000..e7d6bcd17 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -0,0 +1,201 @@ +// +// DevServerLocator.swift +// SuperwallKit +// +// Finds the running `superwall dev` server by probing the candidate +// bases for /device/manifest.json, with short-lived caching of both +// hits and misses so paywall requests don't hammer the network. +// + +import Foundation + +actor DevServerLocator { + static let shared = DevServerLocator() + + /// Loads a request. Injectable so tests can drive the probing and caching + /// without a server on the other end. + typealias Load = (URLRequest) async throws -> (Data, URLResponse?) + + private let load: Load + private var cached: (location: DevServerLocation, fetchedAt: Date)? + private var lastMissAt: Date? + private var pinnedBase: URL? + private var requestedURL: URL? + private var hasBeenAsked = false + /// Bumped whenever what this is looking for changes. A walk that suspended + /// before the change is answering about a server nobody is asking for now. + private var generation = 0 + private var hasWarnedAboutTransportSecurity = false + + init(load: @escaping Load = DevServerLocator.loadWithURLSession) { + self.load = load + } + + func pin(base: URL) { + pinnedBase = base + cached = nil + lastMissAt = nil + generation += 1 + } + + /// Drops everything this knows about the server it was last pointed at: the + /// cached hit, the cached miss, and any base a deep link pinned. + func forget() { + cached = nil + lastMissAt = nil + pinnedBase = nil + generation += 1 + } + + func locate(devServerURL: URL?) async -> DevServerLocation? { + // The caches and the pin all describe one server, and `devServer` can be + // pointed somewhere else at any time. Answering for the address that used + // to be there would serve another project's paywalls. + // + // A deep link pins its base before the first `locate`, so the first call + // must not count as a change and throw that pin away. + if hasBeenAsked, + devServerURL != requestedURL { + forget() + } + hasBeenAsked = true + requestedURL = devServerURL + let walkGeneration = generation + + if let cached = cached, + Date().timeIntervalSince(cached.fetchedAt) < 2 { + return cached.location + } + if let lastMissAt = lastMissAt, + Date().timeIntervalSince(lastMissAt) < 5 { + return nil + } + + var bases = DevServerCandidates.bases(devServerURL: devServerURL) + if let pinnedBase = pinnedBase { + bases.removeAll { $0 == pinnedBase } + bases.insert(pinnedBase, at: 0) + } + if let cached = cached { + bases.removeAll { $0 == cached.location.base } + bases.insert(cached.location.base, at: 0) + } + + for base in bases { + let manifest = await fetchManifest(from: base) + // Probing suspends the actor for as long as each candidate takes to + // answer, so `devServer` can be repointed or a deep link can pin a base + // while this walk is in flight. What comes back then describes a server + // nobody is asking for, so it neither lands in the cache nor goes back + // to the caller. + if generation != walkGeneration { + return nil + } + if let manifest = manifest { + let location = DevServerLocation(base: base, manifest: manifest) + cached = (location, Date()) + lastMissAt = nil + return location + } + } + + cached = nil + lastMissAt = Date() + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Dev mode is on but no superwall dev server was found at " + + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). " + + "Paywalls will load their published versions. On a physical device, " + + "set SuperwallOptions.devServer to the Device URL superwall dev prints." + ) + return nil + } + + /// App Transport Security blocks plain-http requests to a named host unless + /// the app opts in, and the failure is otherwise indistinguishable from + /// "no server there". + private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { + let code = (error as NSError).code + guard code == NSURLErrorAppTransportSecurityRequiresSecureConnection else { + return + } + if hasWarnedAboutTransportSecurity { + return + } + hasWarnedAboutTransportSecurity = true + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "App Transport Security blocked \(base.absoluteString). Add this to the app's " + + "Info.plist to preview local paywalls:\n" + + "NSAppTransportSecurity\n\n" + + " NSAllowsLocalNetworking\n" + ) + } + + /// Logs a body that carries `surfaces` but wouldn't decode. + /// + /// The probe walks several ports and can't know what is listening on each, + /// so this neither claims the responder is a dev server nor stays silent + /// when it plainly is one whose manifest this SDK can't read. + private func logUnreadableManifest(data: Data, base: URL, error: Error) { + let json = try? JSONSerialization.jsonObject(with: data) + guard let object = json as? [String: Any], + object["surfaces"] != nil + else { + // Not a manifest at all β€” something else answered. The port walk moves + // on, and `locate` reports it if nothing else turns up. + return + } + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "Something at \(base.absoluteString) answered /device/manifest.json with a " + + "manifest this SDK couldn't read. Those paywalls will load their published " + + "versions. Check that superwall dev and SuperwallKit are on compatible versions.", + error: error + ) + } + + private func fetchManifest(from base: URL) async -> DevServerManifest? { + guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { + return nil + } + var request = URLRequest(url: manifestURL) + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let (data, response) = try await load(request) + // Something else on this port may answer an unknown path with a JSON + // error body, so the status has to rule that out before the body does. + if let http = response as? HTTPURLResponse, + !(200..<300).contains(http.statusCode) { + return nil + } + do { + return try JSONDecoder().decode(DevServerManifest.self, from: data) + } catch { + logUnreadableManifest(data: data, base: base, error: error) + return nil + } + } catch { + warnIfBlockedByAppTransportSecurity(error, base: base) + return nil + } + } + + private static func loadWithURLSession(_ request: URLRequest) async throws -> (Data, URLResponse?) { + return try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: error ?? URLError(.badServerResponse)) + } + } + task.resume() + } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift new file mode 100644 index 000000000..43595b054 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -0,0 +1,94 @@ +// +// DevServerManifest.swift +// SuperwallKit +// +// The surface list a running `superwall dev` server exposes at +// /device/manifest.json, used to map dashboard paywalls to locally +// served paywall code when `SuperwallOptions/devServer` is set. +// + +import Foundation + +struct DevServerManifest: Decodable, Equatable { + let surfaces: [DevServerSurface] + + private enum CodingKeys: String, CodingKey { + case surfaces + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Per-element decoding: the CLI writing this manifest versions separately + // from the SDK, so one surface the SDK can't read must not take down the + // surfaces it can. + // Required: `surfaces` is the only thing that tells this JSON apart from + // whatever else might answer on a candidate port, so a body without it + // must fail rather than end the port walk. A project with no surfaces + // still sends `{"surfaces": []}`. + let decoded = try container.decode([Throwable].self, forKey: .surfaces) + surfaces = decoded.compactMap { try? $0.result.get() } + + let dropped = decoded.count - surfaces.count + if dropped > 0 { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Skipped \(dropped) of \(decoded.count) dev server surfaces that couldn't be " + + "read. Those paywalls will load their published versions." + ) + } + } + + init(surfaces: [DevServerSurface]) { + self.surfaces = surfaces + } + + /// Picks the local surface for a dashboard paywall: an explicit + /// `superwall.lock` binding wins, otherwise a project with exactly one + /// paywall serves it for everything. + func surface(forPaywallDatabaseId databaseId: String) -> DevServerSurface? { + let bound = surfaces.first { surface in + if surface.paywallId == databaseId { + return true + } + // superwall.lock can bind one surface to several paywalls; the CLI + // sends the first as `paywallId` and the whole set as `paywallIds`. + return surface.paywallIds?.contains(databaseId) ?? false + } + if let bound = bound { + return bound + } + let paywalls = surfaces.filter { $0.kind == "paywall" } + if paywalls.count == 1 { + return paywalls.first + } + return nil + } + + /// The surface the debugger presents under a `dev:` identifier. + func surface(forPreviewIdentifier previewIdentifier: String) -> DevServerSurface? { + return surfaces.first { $0.previewIdentifier == previewIdentifier } + } + + func mountURL(for surface: DevServerSurface, base: URL) -> URL? { + guard let resolved = URL(string: surface.url, relativeTo: base)?.absoluteURL else { + return nil + } + // An absolute `url` resolves off `base` entirely, so a server reached at a + // trusted address could otherwise name any origin it likes. + guard + resolved.scheme == base.scheme, + resolved.host == base.host, + resolved.port == base.port + else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring dev server surface \(surface.id): its url \(surface.url) resolves to " + + "\(resolved.absoluteString), which is off \(base.absoluteString)'s origin." + ) + return nil + } + return resolved + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift new file mode 100644 index 000000000..22322a8c3 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -0,0 +1,173 @@ +// +// DevServerPaywall.swift +// SuperwallKit +// +// Builds a `Paywall` for a surface that a running `superwall dev` server +// serves, so the debugger can preview local paywall code that has never +// been pushed to the dashboard. +// + +import Foundation +import UIKit + +extension Paywall { + /// Builds the paywall a dev server surface presents. + /// + /// - Parameter published: the dashboard paywall this surface stands in for, + /// if any. The surface owns what renders β€” its bytes, its products, and + /// whatever its `config.ts` settings say, which the manifest carries. The + /// dashboard owns what the manifest cannot express, so `published` fills + /// every silence: a setting the manifest declares wins, one it leaves out + /// is inherited, and with neither the safe default stands. + /// + /// In practice a current dev server declares all of them, so the dashboard's + /// copies go unread β€” including `featureGating`, which means a paywall gated + /// on the dashboard previews non-gated unless its `config.ts` says + /// `featureGating: "gated"`. That is what pushing it would do too, so a + /// preview showing the feature for free is the honest answer, not a bug. + /// `published` is left to fill in for the keys the CLI only sends sometimes + /// (the background colours) and for a dev server too old to send settings. + /// + /// Two fields are never taken from the manifest, both marked below: + /// `localNotifications`, which the local paywall declares itself in messages + /// rather than here, and `onDeviceCache`, which stays `.disabled` so a + /// live-reloading local page is never served from the web view's cache. + /// + /// Anything added to `Paywall` later defaults to the local stub's value, so + /// if the dashboard configures it and the local paywall has no way of its + /// own to say otherwise, it belongs in the inherited list below. + static func devServer( + surface: DevServerSurface, + url: URL, + inheriting published: Paywall? = nil + ) -> Paywall { + let products = productItems(from: surface) + + // Identity comes from the paywall being stood in for, not the surface: + // one surface can serve several dashboard paywalls (an explicit multi-way + // binding, or the single-paywall fallback), and these reach analytics as + // paywall_id/paywall_identifier and key the view controller cache. Taking + // them from the surface would collapse every paywall it serves into one. + let databaseId: String = published?.databaseId + ?? surface.paywallId + ?? "dev:\(surface.kind)/\(surface.id)" + let identifier: String = published?.identifier + ?? surface.identifier + ?? "dev:\(surface.id)" + // The settings are part of the key because a cached PaywallViewController + // is built from them once: `Paywall.update(from:)` carries `featureGating` + // across but not the style, scroll flag or colours, so without this an + // edited config.ts would live-reload the page inside a stale native frame. + let cacheKey = "dev:\(surface.id):\(url.absoluteString):\(fingerprint(of: surface.settings))" + let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() + // What config.ts declares comes off the manifest; what it cannot express + // comes off the published paywall; with neither, the safe default stands. + let settings = surface.settings + let featureGating: FeatureGatingBehavior = settings?.featureGating + ?? published?.featureGating + ?? .nonGated + let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] + let surveys: [Survey] = published?.surveys ?? [] + let introOfferEligibility: IntroOfferEligibility = settings?.introOfferEligibility + ?? published?.introOfferEligibility + ?? .automatic + let presentation = presentationInfo(settings: settings, inheriting: published) + let backgroundColorHex = settings?.backgroundColorHex ?? published?.backgroundColorHex + let darkBackgroundColorHex = settings?.darkBackgroundColorHex + ?? published?.darkBackgroundColorHex + + var paywall = Paywall( + databaseId: databaseId, + identifier: identifier, + name: surface.id, + cacheKey: cacheKey, + buildId: "dev", + url: url, + urlConfig: WebViewURLConfig( + endpoints: [WebViewEndpoint(url: url, timeout: 15, percentage: 100)], + maxAttempts: 1 + ), + htmlSubstitutions: "", + presentation: presentation, + backgroundColorHex: backgroundColorHex ?? "#FFFFFF", + backgroundColor: backgroundColorHex.map { UIColor(hexString: $0) } ?? .white, + darkBackgroundColorHex: darkBackgroundColorHex, + darkBackgroundColor: darkBackgroundColorHex.map { UIColor(hexString: $0) }, + productItems: products, + productIds: products.map { $0.id }, + appStoreProductIds: products.map { $0.id }, + responseLoadingInfo: responseLoadingInfo, + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: featureGating, + // Never taken from either side: a dev server reloads the page on every + // edit, and DependencyContainer feeds this straight into the web view, + // so an enabled cache β€” which the manifest reports, because push stamps + // it β€” could serve a stale copy of the local page. + onDeviceCache: .disabled, + // Deliberately not inherited: a local paywall declares its own + // notifications in config.ts, and they reach the SDK as + // `schedule_notification` messages rather than through this field. + // Inheriting the dashboard's would let a stale copy win the + // paywallId+type dedupe in NotificationScheduler. + localNotifications: [], + // The variables the page reads: without these, a local render silently + // lacks computed properties that production resolves. + computedPropertyRequests: computedPropertyRequests, + surveys: surveys, + isScrollEnabled: settings?.isScrollEnabled ?? published?.isScrollEnabled ?? true, + // Drives displayed trial state and pricing, which is exactly what a + // local preview is checked against, so config.ts gets to force it. + introOfferEligibility: introOfferEligibility + ) + paywall.isLocal = true + paywall.experiment = published?.experiment + return paywall + } + + /// Identifies a settings block, so a changed `config.ts` gets a new cache + /// key. Only the settings the paywall is built from count. + private static func fingerprint(of settings: DevServerSettings?) -> String { + guard let settings = settings else { + return "none" + } + return [ + settings.presentationStyle.map { "\($0)" }, + settings.featureGating.map { "\($0)" }, + settings.introOfferEligibility.map { "\($0)" }, + settings.isScrollEnabled.map { "\($0)" }, + settings.backgroundColorHex, + settings.darkBackgroundColorHex + ] + .map { $0 ?? "-" } + .joined(separator: "|") + } + + /// How the surface presents: config.ts picks the style, and the delay is + /// the dashboard's, since config.ts has no word for it. + private static func presentationInfo( + settings: DevServerSettings?, + inheriting published: Paywall? + ) -> PaywallPresentationInfo { + return PaywallPresentationInfo( + style: settings?.presentationStyle ?? published?.presentation.style ?? .fullscreen, + delay: published?.presentation.delay ?? 0 + ) + } + + /// The products a surface's `config.ts` declares, in a stable order. + private static func productItems(from surface: DevServerSurface) -> [Product] { + return (surface.products ?? [:]) + .sorted { $0.key < $1.key } + .map { reference, identifier in + Product( + name: reference, + type: .appStore(.init(id: identifier)), + id: identifier, + entitlements: [] + ) + } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift new file mode 100644 index 000000000..9ba469a50 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -0,0 +1,170 @@ +// +// DevServerPreview.swift +// SuperwallKit +// +// Handles superwall_dev deep links: scanning the QR that `superwall dev` +// prints opens this in-app picker of the dev server's local surfaces, and +// selecting one presents it through the real paywall pipeline (which the +// dev mode override then points at the local bytes). +// + +import Combine +import Foundation +import UIKit + +enum DevServerPreview { + struct DeepLinkOutcome: Equatable { + let base: URL + let surfaceId: String? + } + + /// Parses a `superwall_dev` deep link: the dev server base carried in the + /// `superwall_dev` query item, which must be a web URL, plus the optional + /// `superwall_dev_surface` to open with. + static func outcomeForDeepLink(url: URL) -> DeepLinkOutcome? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + guard let items = components.queryItems else { + return nil + } + guard let raw = items.first(where: { $0.name == "superwall_dev" })?.value else { + return nil + } + guard let base = URL(string: raw) else { + return nil + } + guard base.scheme == "http" || base.scheme == "https" else { + return nil + } + let surfaceId = items.first { $0.name == "superwall_dev_surface" }?.value + return DeepLinkOutcome(base: base, surfaceId: surfaceId) + } + + /// Whether `handle(url:)` would consume this URL: it parses, dev mode is on, + /// and the base is a host `superwall dev` could actually have printed. + static func canHandle(url: URL, options: SuperwallOptions) -> Bool { + guard let outcome = outcomeForDeepLink(url: url) else { + return false + } + return DevMode.isActive(options) + && isTrustedBase(outcome.base, devServerURL: options.devServerURL) + } + + /// A deep-link-supplied base may only name a host `superwall dev` ever + /// prints β€” loopback, `.local`, or a private-network address β€” or the + /// developer-supplied `devServer` URL, which is trusted input. Anything else + /// is an arbitrary internet host that must not be handed the paywall + /// pipeline's JS bridge. + static func isTrustedBase(_ base: URL, devServerURL: URL?) -> Bool { + if let devServerURL = devServerURL, + base.scheme == devServerURL.scheme, + base.host == devServerURL.host, + base.port == devServerURL.port { + return true + } + guard let host = base.host?.lowercased() else { + return false + } + if host == "localhost" || host == "::1" || host.hasSuffix(".local") { + return true + } + // Every component must be a numeric octet: compactMap alone would let a + // DNS name like 10.0.0.1.evil.example.com pass as a private address. + let components = host.split(separator: ".") + let octets = components.compactMap { UInt8($0) } + if components.count != 4 || octets.count != 4 { + return false + } + switch (octets[0], octets[1]) { + case (127, _), (10, _), (192, 168), (169, 254), (172, 16...31): + return true + default: + return false + } + } + + /// Where a `dev:` surface is served from, and what its config.ts says now. + /// + /// The surface's products and presentation settings live in its config.ts, + /// which the developer can edit at any time after the debugger opened. So + /// the manifest the server is serving now wins, and the snapshot taken when + /// the debugger opened only stands in while the server can't be reached. + static func resolveSurface( + previewIdentifier: String, + fresh: DevServerLocation?, + snapshot: (base: URL, surfaces: [DevServerSurface])? + ) -> (surface: DevServerSurface, url: URL)? { + let location: DevServerLocation + if let fresh = fresh { + location = fresh + } else if let snapshot = snapshot { + location = DevServerLocation( + base: snapshot.base, + manifest: DevServerManifest(surfaces: snapshot.surfaces) + ) + } else { + return nil + } + guard let surface = location.manifest.surface(forPreviewIdentifier: previewIdentifier) else { + return nil + } + guard let url = location.manifest.mountURL(for: surface, base: location.base) else { + return nil + } + return (surface, url) + } + + static func handle(url: URL) -> Bool { + guard let outcome = outcomeForDeepLink(url: url) else { + return false + } + let options = Superwall.shared.options + guard DevMode.isActive(options) else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Scanned a superwall dev link, but SuperwallOptions.devServer is not set " + + "in this build. Set devServer to preview local paywalls in the app." + ) + return false + } + guard isTrustedBase(outcome.base, devServerURL: options.devServerURL) else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring a superwall dev link pointing at \(outcome.base.absoluteString): " + + "dev servers only run on localhost, .local hosts, or private-network addresses. " + + "To use another host, set it as SuperwallOptions.devServer's url." + ) + return false + } + Task { @MainActor in + await open(outcome: outcome) + } + return true + } + + @MainActor + private static func open(outcome: DeepLinkOutcome) async { + await DevServerLocator.shared.pin(base: outcome.base) + guard + let location = await DevServerLocator.shared.locate( + devServerURL: Superwall.shared.options.devServerURL + ) + else { + return + } + + guard let debugManager: DebugManager = Superwall.shared.dependencyContainer.debugManager else { + return + } + debugManager.devServer = (base: location.base, surfaces: location.manifest.surfaces) + await debugManager.launchDebugger( + withPaywallId: nil, + devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first { + $0.kind == "paywall" + }?.id + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerSettings.swift b/Sources/SuperwallKit/DevServer/DevServerSettings.swift new file mode 100644 index 000000000..7328405f2 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerSettings.swift @@ -0,0 +1,162 @@ +// +// DevServerSettings.swift +// SuperwallKit +// +// The settings a `superwall dev` surface declares in its `config.ts`, as the +// manifest carries them. +// +// These are the same values `superwall push` stamps on a paywall: the CLI +// computes them once and serves them here so local code presents the way it +// will present once pushed. The keys are the push API's, not the static +// config's, because the dev server stands in for the push β€” so this type +// does the translation the backend does in production. +// +// Settings the manifest carries that iOS has no per-paywall notion of are +// deliberately absent: `game_controller_enabled` is a SuperwallOption, and +// `web_checkout_destination` only steers the web build. `on_device_cache` is +// read by nothing here on purpose β€” see Paywall.devServer(surface:url:). +// + +import Foundation + +struct DevServerSettings: Decodable, Equatable { + let presentationStyle: PaywallPresentationStyle? + let featureGating: FeatureGatingBehavior? + let introOfferEligibility: IntroOfferEligibility? + let isScrollEnabled: Bool? + let backgroundColorHex: String? + let darkBackgroundColorHex: String? + + private enum CodingKeys: String, CodingKey { + case presentationStyle = "presentation_style" + case featureGating = "feature_gating" + case introOfferEligibility = "introductory_offer_eligibility" + case isScrollEnabled = "scroll_enabled" + case backgroundColorHex = "background_color_hex" + case darkBackgroundColorHex = "dark_background_color_hex" + } + + private enum StyleKeys: String, CodingKey { + case type + case height + case width + case cornerRadius = "corner_radius" + } + + /// The CLI sends the push API's presentation styles, so read them with the + /// push API's own enum rather than a copy that drifts from it. + private typealias WireStyle = PaywallPresentationStyle.InternalPresentationStyle + + private enum WireGating: String { + case gated + case nonGated = "non_gated" + } + + private enum WireEligibility: String { + case automatic + case eligible = "always_eligible" + case ineligible = "always_ineligible" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + presentationStyle = try Self.style(in: container) + let gating = try container.decodeIfPresent(String.self, forKey: .featureGating) + switch gating.flatMap(WireGating.init(rawValue:)) { + case .gated: + featureGating = .gated + case .nonGated: + featureGating = .nonGated + case nil: + if gating != nil { + Self.warnUnreadable("feature gating", gating) + } + featureGating = nil + } + let eligibility = try container.decodeIfPresent(String.self, forKey: .introOfferEligibility) + switch eligibility.flatMap(WireEligibility.init(rawValue:)) { + case .automatic: + introOfferEligibility = .automatic + case .eligible: + introOfferEligibility = .eligible + case .ineligible: + introOfferEligibility = .ineligible + case nil: + if eligibility != nil { + Self.warnUnreadable("intro offer eligibility", eligibility) + } + introOfferEligibility = nil + } + isScrollEnabled = try container.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) + backgroundColorHex = try container.decodeIfPresent(String.self, forKey: .backgroundColorHex) + darkBackgroundColorHex = try container.decodeIfPresent( + String.self, + forKey: .darkBackgroundColorHex + ) + } + + private static func style( + in container: KeyedDecodingContainer + ) throws -> PaywallPresentationStyle? { + guard + let style = try? container.nestedContainer(keyedBy: StyleKeys.self, forKey: .presentationStyle) + else { + return nil + } + + let type = try style.decodeIfPresent(String.self, forKey: .type) + let height = try? style.decode(Double.self, forKey: .height) + let width = try? style.decode(Double.self, forKey: .width) + let cornerRadius = try? style.decode(Double.self, forKey: .cornerRadius) + + guard let wireStyle = type.flatMap(WireStyle.init(rawValue:)) else { + if type == nil { + return nil + } + return unreadable(type) + } + + switch wireStyle { + case .fullscreen: + return .fullscreen + case .modal: + return .modal + case .push: + return .push + case .fullscreenNoAnimation: + return .fullscreenNoAnimation + case .drawer: + guard let height = height, let cornerRadius = cornerRadius else { + return unreadable(type) + } + return .drawer(height: height, cornerRadius: cornerRadius) + case .popup: + guard let height = height, let width = width, let cornerRadius = cornerRadius else { + return unreadable(type) + } + return .popup(height: height, width: width, cornerRadius: cornerRadius) + case .none: + // `NONE` is the push API's "use the dashboard's", which is what a missing + // style already means here, so it inherits rather than being unreadable. + return nil + } + } + + private static func unreadable(_ type: String?) -> PaywallPresentationStyle? { + warnUnreadable("presentation style", type) + return nil + } + + /// Reports one setting the CLI names in a way this SDK can't read. The CLI + /// versions separately from the SDK, so a value added after this SDK shipped + /// costs that setting alone β€” the rest of the block still stands. + private static func warnUnreadable(_ name: String, _ value: String?) { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring a dev server \(name) this SDK can't read " + + "(\(value ?? "nothing")). The paywall uses its published version's \(name). " + + "Updating SuperwallKit may fix this." + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift new file mode 100644 index 000000000..0ab7c00c7 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -0,0 +1,67 @@ +// +// DevServerSurface.swift +// SuperwallKit +// +// One entry in the surface list a running `superwall dev` server exposes: +// a locally served paywall or funnel, and the dashboard paywall it is +// bound to via `superwall.lock`, if any. +// +// The manifest carries identity, products, and the settings the surface's +// config.ts declares. Anything it leaves out β€” computed properties, surveys β€” +// reaches the SDK only in the pushed snapshot, so those come from the +// published paywall the surface stands in for. +// + +import Foundation + +struct DevServerSurface: Decodable, Equatable { + let kind: String + let id: String + let url: String + + /// The dashboard paywall this surface is bound to via `superwall.lock`. + let paywallId: String? + + /// Every paywall this surface is bound to, when the lock binds it to more + /// than one. The CLI sends the first as `paywallId` and the full set here. + let paywallIds: [String]? + + let identifier: String? + let products: [String: String]? + + /// What the surface's `config.ts` says about presenting it. Absent from a + /// dev server older than the settings block, in which case the published + /// paywall's settings stand. + let settings: DevServerSettings? + + /// The identifier the debugger presents this surface under. Always the + /// synthetic `dev:` form, never the dashboard `identifier` the manifest may + /// carry: only this one routes the presentation back to the local surface + /// instead of fetching the paywall's published version. + var previewIdentifier: String { + return "dev:\(id)" + } + + private enum CodingKeys: String, CodingKey { + case kind + case id + case url + case paywallId + case paywallIds + case identifier + case products + case settings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + id = try container.decode(String.self, forKey: .id) + url = try container.decode(String.self, forKey: .url) + paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) + products = try container.decodeIfPresent([String: String].self, forKey: .products) + settings = try? container.decodeIfPresent(DevServerSettings.self, forKey: .settings) + } +} diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index 174b55907..90b8fdb84 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -31,7 +31,14 @@ extension Superwall { /// attributes you'd like to store for the user. Values can be any JSON encodable value, `URL`s or `Date`s. /// Note: Keys beginning with `$` are reserved for Superwall and will be dropped. Arrays and dictionaries /// as values are not supported at this time, and will be dropped. + /// + /// Note: `idfv`, `idfa` and `attStatus` are owned by the SDK, which keeps them + /// in step with the device. A value you set on one of those keys is replaced + /// the next time the app becomes active or you set an integration attribute. public func setUserAttributes(_ attributes: [String: Any?]) { + dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( + ifChangedBy: attributes + ) mergeAttributes(attributes) } @@ -63,6 +70,9 @@ extension Superwall { swiftDictionary[key] = keyValue } + dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( + ifChangedBy: swiftDictionary + ) mergeAttributes(swiftDictionary) } diff --git a/Sources/SuperwallKit/Logger/LogScope.swift b/Sources/SuperwallKit/Logger/LogScope.swift index 07e74045c..17e1d0fbf 100644 --- a/Sources/SuperwallKit/Logger/LogScope.swift +++ b/Sources/SuperwallKit/Logger/LogScope.swift @@ -34,6 +34,9 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { case cache case webEntitlements case all + // Declared after `all` so existing implicit raw values stay stable β€” they + // reach the backend via SuperwallOptions.toDictionary(). + case grantedEntitlements public var description: String { switch self { @@ -83,6 +86,8 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { return "cache" case .webEntitlements: return "webEntitlements" + case .grantedEntitlements: + return "grantedEntitlements" case .all: return "all" } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index d238a7fd6..7ca78bdad 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.3 +4.17.0 """ diff --git a/Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift b/Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift new file mode 100644 index 000000000..8eb9bdbc3 --- /dev/null +++ b/Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift @@ -0,0 +1,58 @@ +// +// Encoder+ReportsUnknownFieldsAsNull.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 07/09/2026. +// + +import Foundation + +extension CodingUserInfoKey { + /// Set on an encoder to write a field we have no value for as an explicit + /// null instead of leaving the key out. + /// + /// Only the audience filter attributes are encoded this way. A filter gives a + /// missing key the type default, so a dropped `Bool?` reads as `false` and a + /// bare Purchase Controller entitlement matched `willRenew == false`. Every + /// other consumer β€” the enrichment request, paywall template variables, + /// session attributes and `getDeviceAttributes()` β€” keeps the shape it has + /// always had. + // swiftlint:disable:next force_unwrapping + static let reportsUnknownFieldsAsNull = CodingUserInfoKey(rawValue: "reportsUnknownFieldsAsNull")! +} + +extension Encoder { + /// Whether this encoder wants unknown fields written as explicit nulls. + var reportsUnknownFieldsAsNull: Bool { + userInfo[.reportsUnknownFieldsAsNull] as? Bool ?? false + } +} + +extension JSONEncoder { + /// An encoder that writes unknown optional fields as explicit nulls. + static func reportingUnknownFieldsAsNull() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.userInfo[.reportsUnknownFieldsAsNull] = true + return encoder + } +} + +extension KeyedEncodingContainer { + /// Encodes an optional, either leaving the key out when it's `nil` or writing + /// an explicit null, depending on what the encoder asked for. + /// + /// Plain `encode(_:forKey:)` on an optional already writes a null, since + /// `Optional`'s own `Encodable` conformance calls `encodeNil()`. Spelling it + /// out keeps the two behaviours side by side at the call site. + mutating func encode( + _ value: T?, + forKey key: Key, + nilAsNull: Bool + ) throws { + if let value = value { + try encode(value, forKey: key) + } else if nilAsNull { + try encodeNil(forKey: key) + } + } +} diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift new file mode 100644 index 000000000..ce5aff181 --- /dev/null +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -0,0 +1,74 @@ +// +// SerialTaskCoordinator.swift +// +// +// Created by Yusuf TΓΆr on 11/09/2026. +// + +import Foundation + +/// Runs enqueued operations one at a time, in the order they were enqueued. +/// +/// The usual way of doing this is to keep the last task in a property and swap +/// it for a new task that waits on the old one: +/// +/// ```swift +/// previousTask = Task { [previousTask] in +/// await previousTask?.value +/// ... +/// } +/// ``` +/// +/// That read and that write aren't a single step, so when callers arrive on +/// different threads two of them can read the same previous task and both +/// release it. That over-releases the task and crashes in `swift_release` when +/// it's torn down. It also loses one of the two new tasks, so the chaining the +/// code is there to provide silently stops happening. +/// +/// Handing operations to a single long-lived task through a stream avoids the +/// problem rather than guarding it: there's no task reference to swap. The +/// stream keeps them in the order they were handed over, and ``enqueue(_:)`` +/// only hands one over, so no caller ever waits β€” including callers already +/// running on the concurrency pool. +final class SerialTaskCoordinator: Sendable { + typealias Operation = @Sendable () async -> Void + + private let continuation: AsyncStream.Continuation + + init() { + // `AsyncStream` hands over the continuation before its initializer + // returns, so this is always set by the time it's read. + // swiftlint:disable:next implicitly_unwrapped_optional + var continuation: AsyncStream.Continuation! + let operations = AsyncStream(bufferingPolicy: .unbounded) { + continuation = $0 + } + self.continuation = continuation + + Task { + for await operation in operations { + await operation() + } + } + } + + deinit { + continuation.finish() + } + + /// Adds `operation` to the end of the queue. It starts only after everything + /// enqueued before it has finished. + func enqueue(_ operation: @escaping Operation) { + // Run the operation at the priority of whoever enqueued it. The task + // draining the stream takes its priority from whatever made the + // coordinator β€” without this, one made on a low-priority thread would pin + // every later operation to that priority for as long as it lives. + let priority = Task.currentPriority + continuation.yield { + await Task(priority: priority) { + await operation() + } + .value + } + } +} diff --git a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift index 4c009e978..bbc794dea 100644 --- a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift +++ b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift @@ -134,7 +134,10 @@ public final class CustomerInfo: NSObject, Codable { /// /// - Parameter webCustomerInfo: The CustomerInfo from web2app endpoints containing web purchases/redemptions /// - Returns: A new CustomerInfo with merged data from both sources - func merging(with webCustomerInfo: CustomerInfo) -> CustomerInfo { + func merging( + with webCustomerInfo: CustomerInfo, + granting grantedEntitlements: Set = [] + ) -> CustomerInfo { // Merge non-subscription transactions (consumables, non-consumables) // Start with device transactions, then add web transactions that don't already exist var mergedNonSubscriptions = self.nonSubscriptions @@ -155,20 +158,63 @@ public final class CustomerInfo: NSObject, Codable { mergedSubscriptions.append(webSub) } - // Merge entitlements using priority-based merging + // Merge entitlements from device, web, and developer-granted sources + // using priority-based merging. // This uses `Entitlement.mergePrioritized` which intelligently selects the highest // priority entitlement for each ID based on: // - Active status (active > inactive) // - Latest expiration date // - Other priority criteria defined in `shouldTakePriorityOver` - let combinedEntitlements = self.entitlements + webCustomerInfo.entitlements + let combinedEntitlements = + self.entitlements + webCustomerInfo.entitlements + Array(grantedEntitlements) let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) - // Return merged CustomerInfo with sorted transactions and entitlements + // Return merged CustomerInfo with sorted transactions and entitlements. + // It stays a placeholder until real data has arrived from either side β€” + // granted entitlements alone don't mean the device has been read. return CustomerInfo( subscriptions: mergedSubscriptions.sorted { $0.purchaseDate < $1.purchaseDate }, nonSubscriptions: mergedNonSubscriptions.sorted { $0.purchaseDate < $1.purchaseDate }, - entitlements: mergedEntitlements.sorted { $0.id < $1.id } + entitlements: mergedEntitlements.sorted { $0.id < $1.id }, + isPlaceholder: isPlaceholder && webCustomerInfo.isPlaceholder + ) + } + + /// Composes customer info from a fresh device snapshot under an external + /// purchase controller, preserving the entitlements that came from the + /// controller β€” its active entitlements won't necessarily be in device data. + static func preservingExternalControllerEntitlements( + device deviceCustomerInfo: CustomerInfo, + web webCustomerInfo: CustomerInfo?, + current currentCustomerInfo: CustomerInfo, + granted grantedEntitlements: Set + ) -> CustomerInfo { + // Merge with web customer info if available + let baseCustomerInfo = webCustomerInfo.map { + deviceCustomerInfo.merging(with: $0) + } ?? deviceCustomerInfo + + // Get entitlements that are only in current CustomerInfo (i.e., from external controller) + // by filtering out anything that matches device or web entitlements by ID + let deviceAndWebEntitlementIds = Set(baseCustomerInfo.entitlements.map { $0.id }) + let externalOnlyEntitlements = currentCustomerInfo.entitlements.filter { entitlement in + // Keep external entitlement if it's not already in device/web + !deviceAndWebEntitlementIds.contains(entitlement.id) + } + + // Merge external controller entitlements with device + web + granted. + // Granted entitlements are their own source here: the externalOnly + // filter above would drop one whose ID collides with an expired device + // entitlement, leaving customerInfo reporting it inactive while + // subscriptionStatus reports it active. + let allEntitlements = + baseCustomerInfo.entitlements + externalOnlyEntitlements + Array(grantedEntitlements) + let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) + + return CustomerInfo( + subscriptions: baseCustomerInfo.subscriptions, + nonSubscriptions: baseCustomerInfo.nonSubscriptions, + entitlements: finalEntitlements.sorted { $0.id < $1.id } ) } @@ -184,7 +230,8 @@ public final class CustomerInfo: NSObject, Codable { /// - Returns: A new CustomerInfo with all sources merged static func forExternalPurchaseController( storage: Storage, - subscriptionStatus: SubscriptionStatus + subscriptionStatus: SubscriptionStatus, + granted grantedEntitlements: Set ) -> CustomerInfo { // Get web CustomerInfo let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() @@ -211,9 +258,18 @@ public final class CustomerInfo: NSObject, Codable { externalEntitlements = [] } - // Merge: active from external controller + all web + inactive device + // Developer-granted entitlements come in as their own source rather than + // recovered from subscriptionStatus β€” that's an already-merged value + // where sources are no longer distinguishable. They also can't ride in via + // the appStore filter above: widening it to admit them would resurrect + // revoked web entitlements, whose revocation is signalled only by their + // absence from webCustomerInfo. + + // Merge: active from external controller + all web + inactive device + granted // This gives us complete history while respecting external controller as source of truth for active status - let allEntitlements = externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements + let allEntitlements = + externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements + + Array(grantedEntitlements) let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) return CustomerInfo( diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 208268048..f1caa3273 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -28,7 +28,7 @@ struct Paywall: Codable { var url: URL /// An array of potential URLs to load the paywall from. - let urlConfig: WebViewURLConfig + var urlConfig: WebViewURLConfig /// Contains the website modifications that are made on the paywall editor to be accepted /// by the webview. @@ -87,6 +87,10 @@ struct Paywall: Codable { /// Indicates whether scrolling is enabled on the webview. var isScrollEnabled: Bool + /// Whether this paywall was synthesized from a `superwall dev` server + /// surface rather than fetched from the dashboard. + var isLocal = false + /// Indicates how intro offer eligiblity should be treat on products. Defaults to /// `.automatic`. let introOfferEligibility: IntroOfferEligibility @@ -151,7 +155,7 @@ struct Paywall: Codable { /// A listing of all the files referenced in a paywall to be able to preload the whole /// paywall into a web archive. - let manifest: ArchiveManifest? + var manifest: ArchiveManifest? /// The state of the paywall, updated on paywall did dismiss. var state: [String: Any] = [:] @@ -189,6 +193,7 @@ struct Paywall: Codable { case surveys case manifest case isScrollEnabled + case isLocal case introductoryOfferEligibility case responseLoadStartTime @@ -305,6 +310,7 @@ struct Paywall: Codable { manifest = try values.decodeIfPresent(ArchiveManifest.self, forKey: .manifest) isScrollEnabled = try values.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) ?? true + isLocal = try values.decodeIfPresent(Bool.self, forKey: .isLocal) ?? false introOfferEligibility = try values .decodeIfPresent(IntroOfferEligibility.self, forKey: .introductoryOfferEligibility) ?? .automatic } @@ -363,11 +369,12 @@ struct Paywall: Codable { try container.encodeIfPresent(manifest, forKey: .manifest) try container.encodeIfPresent(isScrollEnabled, forKey: .isScrollEnabled) + try container.encode(isLocal, forKey: .isLocal) try container.encodeIfPresent(introOfferEligibility, forKey: .introductoryOfferEligibility) } - // Only used in stub - private init( + // Used by the stub and by `Paywall.devServer(surface:url:)`. + init( databaseId: String, identifier: String, name: String, @@ -471,6 +478,7 @@ struct Paywall: Codable { surveys: surveys, presentation: presentation, isScrollEnabled: isScrollEnabled, + isLocal: isLocal, state: state, introOfferEligibility: introOfferEligibility ) diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift index fb351afc9..30e886ff2 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift @@ -93,6 +93,15 @@ public enum RedemptionResult: Codable { /// The entitlements array public let entitlements: Set + /// The attributes collected on the web paywall funnel that led to the + /// purchase, merged across every checkout the code redeems. + /// + /// This is `nil` when the funnel collected no attributes, which is always + /// the case for purchases made before the web paywall started recording + /// them. Values are strings, numbers, booleans, `NSNull` for an answer that + /// was left blank, or arrays of those. + public let userAttributes: [String: Any]? + /// Enum specifiying code ownership. public enum Ownership: Codable { /// The code belongs to the identified user. @@ -428,23 +437,49 @@ public enum RedemptionResult: Codable { } } + enum CodingKeys: String, CodingKey { + case ownership + case purchaserInfo + case paywallInfo + case entitlements + case userAttributes + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.ownership = try container.decode(Ownership.self, forKey: .ownership) self.purchaserInfo = try container.decode(PurchaserInfo.self, forKey: .purchaserInfo) self.paywallInfo = try container.decodeIfPresent(PaywallInfo.self, forKey: .paywallInfo) self.entitlements = try container.decode(Set.self, forKey: .entitlements) + + let userAttributesJSON = try container.decodeIfPresent(JSON.self, forKey: .userAttributes) + let userAttributes = userAttributesJSON?.dictionaryObject + self.userAttributes = userAttributes?.isEmpty == true ? nil : userAttributes + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ownership, forKey: .ownership) + try container.encode(purchaserInfo, forKey: .purchaserInfo) + try container.encodeIfPresent(paywallInfo, forKey: .paywallInfo) + try container.encode(entitlements, forKey: .entitlements) + + if let userAttributes = userAttributes { + try container.encode(JSON(userAttributes), forKey: .userAttributes) + } } init( ownership: Ownership, purchaserInfo: PurchaserInfo, - entitlements: Set + entitlements: Set, + userAttributes: [String: Any]? = nil ) { self.ownership = ownership self.purchaserInfo = purchaserInfo self.entitlements = entitlements self.paywallInfo = nil + self.userAttributes = userAttributes } public func toObjc() -> RedemptionResultObjc.RedemptionInfo { @@ -455,7 +490,8 @@ public enum RedemptionResult: Codable { ownership: objcOwnership, purchaserInfo: objcPurchaserInfo, paywallInfo: objcPaywallInfo, - entitlements: entitlements + entitlements: entitlements, + userAttributes: userAttributes ) } } diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift index 383f21b5e..443ce16a1 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift @@ -127,18 +127,44 @@ public class RedemptionResultObjc: NSObject { /// The entitlements array. public let entitlements: Set + /// The attributes collected on the web paywall funnel that led to the + /// purchase, merged across every checkout the code redeems. + /// + /// This is `nil` when the funnel collected no attributes, which is always + /// the case for purchases made before the web paywall started recording + /// them. Values are strings, numbers, booleans, `NSNull` for an answer that + /// was left blank, or arrays of those. + public let userAttributes: [String: Any]? + public init( ownership: Ownership, purchaserInfo: PurchaserInfo, paywallInfo: PaywallInfo?, - entitlements: Set + entitlements: Set, + userAttributes: [String: Any]? ) { self.ownership = ownership self.purchaserInfo = purchaserInfo self.paywallInfo = paywallInfo self.entitlements = entitlements + self.userAttributes = userAttributes super.init() } + + public convenience init( + ownership: Ownership, + purchaserInfo: PurchaserInfo, + paywallInfo: PaywallInfo?, + entitlements: Set + ) { + self.init( + ownership: ownership, + purchaserInfo: purchaserInfo, + paywallInfo: paywallInfo, + entitlements: entitlements, + userAttributes: nil + ) + } } /// Enum specifying code ownership. diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index c59f84159..fad0807d7 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -64,9 +64,33 @@ class DeviceHelper { UIDevice.modelName }() - let vendorId: String = { - UIDevice.current.identifierForVendor?.uuidString ?? "" - }() + private let vendorIdLock = NSLock() + private var cachedVendorId = "" + + /// `identifierForVendor` is `nil` until the device has been unlocked once, so + /// an app launched in the background before first unlock would be stuck with + /// an empty id for the whole process if this were read only at init. Latch the + /// first non-empty read instead, so a later read picks the id up once it + /// exists and every read after that is a compare behind an uncontended lock. + /// It has to stay that cheap: the `X-Vendor-ID` header reads it on every + /// request, and so do `makeDeviceId()` and `getTemplateDevice()`. + /// + /// This also settles `makeDeviceId()`, so the `$SuperwallDevice:` identity can + /// change once mid-process in that window. That's the point: the value it + /// replaces is the empty suffix, which isn't a per-device identity at all β€” + /// every device in this state shares it β€” so there's nothing there worth + /// keeping stable or reconciling against. The window closes at first unlock, + /// and the repeated `UIDevice` read inside it is cheap. + var vendorId: String { + vendorIdLock.lock() + defer { vendorIdLock.unlock() } + + if !cachedVendorId.isEmpty { + return cachedVendorId + } + cachedVendorId = UIDevice.current.identifierForVendor?.uuidString ?? "" + return cachedVendorId + } var languageCode: String { if #available(iOS 16, *) { @@ -180,9 +204,9 @@ class DeviceHelper { /// Every appearance-adjacent read in this file β€” `UIScreen`, `UIFontMetrics`, /// trait collections β€” must sit behind this check, and the check must come /// first: even `UIFontMetrics.default.scaledValue(for:)` alone trips it. The - /// unguarded `UIDevice` reads at init (`model`, `vendorId`, `interfaceType`) - /// are exempt: they don't touch the trait system, and the sample-app repro - /// keeps its tint with them in place. + /// unguarded `UIDevice` reads (`model` and `interfaceType` at init, `vendorId` + /// on first use) are exempt: they don't touch the trait system, and the + /// sample-app repro keeps its tint with them in place. /// /// A missing application object doesn't always mean that window, though: some /// processes never create one (unit-test runners, app extensions) yet can read @@ -592,6 +616,13 @@ class DeviceHelper { return Self.detectSandbox() } + /// Whether the app is running outside App Store production: simulator, + /// TestFlight, or a development build. Unlike ``isSandbox`` this ignores + /// test mode, so it can be used to decide whether test mode may activate. + static var isSandboxEnvironment: Bool { + return detectSandbox() == "true" + } + private static func detectSandbox() -> String { #if targetEnvironment(simulator) return "true" @@ -794,11 +825,16 @@ class DeviceHelper { ) } + /// - Parameter reportingUnknownFieldsAsNull: Only the audience filter + /// attributes pass `true`. See ``CodingUserInfoKey/reportsUnknownFieldsAsNull``. func getDeviceAttributes( since placement: PlacementData?, - computedPropertyRequests: [ComputedPropertyRequest] + computedPropertyRequests: [ComputedPropertyRequest], + reportingUnknownFieldsAsNull: Bool = false ) async -> [String: Any] { - var dictionary = await getTemplateDevice() + var dictionary = await getTemplateDevice( + reportingUnknownFieldsAsNull: reportingUnknownFieldsAsNull + ) let computedProperties = await getComputedDevicePropertiesSincePlacement( placement, @@ -952,7 +988,7 @@ class DeviceHelper { } } - func getTemplateDevice() async -> [String: Any] { + func getTemplateDevice(reportingUnknownFieldsAsNull: Bool = false) async -> [String: Any] { let identityInfo = await factory.makeIdentityInfo() let aliases = [identityInfo.aliasId] @@ -1028,7 +1064,11 @@ class DeviceHelper { deviceId: factory.makeDeviceId() ) - var deviceDictionary = template.toDictionary() + var deviceDictionary = template.toDictionary( + encoder: reportingUnknownFieldsAsNull + ? .reportingUnknownFieldsAsNull() + : JSONEncoder() + ) let enrichmentDict: [String: Any] = $enrichment.withSnapshot { enrichment in enrichment?.device.dictionaryObject ?? [:] diff --git a/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift b/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift index 273d0492a..4cb666527 100644 --- a/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift +++ b/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift @@ -10,32 +10,20 @@ import Foundation enum PaywallManagerLogic { enum Outcome { case loadWebView - case setDelegate case replacePaywall - case updatePaywall } static func handleCachedPaywall( newPaywall: Paywall, oldPaywall: Paywall, - isPreloading: Bool, isForPresentation: Bool ) -> [Outcome] { - var outcome: [Outcome] = [] - guard isForPresentation else { - return outcome + return [] } if newPaywall.cacheKey != oldPaywall.cacheKey { - outcome.append(.replacePaywall) - outcome.append(.loadWebView) - if !isPreloading { - outcome.append(.setDelegate) - } - } else if !isPreloading { - outcome.append(.setDelegate) - outcome.append(.updatePaywall) + return [.replacePaywall, .loadWebView] } - return outcome + return [] } } diff --git a/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift b/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift index 9fbfd2d12..2873e1e59 100644 --- a/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift +++ b/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift @@ -89,7 +89,6 @@ class PaywallManager { for paywall: Paywall, isDebuggerLaunched: Bool, isForPresentation: Bool, - isPreloading: Bool, delegate: PaywallViewControllerDelegateAdapter? ) async throws -> PaywallViewController { let deviceInfo = factory.makeDeviceInfo() @@ -100,10 +99,17 @@ class PaywallManager { if !isDebuggerLaunched, let viewController = self.cache.getPaywallViewController(forKey: cacheKey) { + // There is one view controller per paywall. While it's on screen it + // belongs to that presentation, so leave it alone. The experiment, + // products and delegate for a request are applied when the request + // claims the view controller, in `set(request:paywall:...)`. + if viewController.isActive { + return viewController + } + let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: paywall, oldPaywall: viewController.paywall, - isPreloading: isPreloading, isForPresentation: isForPresentation ) @@ -113,10 +119,6 @@ class PaywallManager { viewController.loadWebView() case .replacePaywall: viewController.paywall = paywall - case .setDelegate: - viewController.delegate = delegate - case .updatePaywall: - viewController.paywall.update(from: paywall) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift index 24152722d..a1f996b00 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift @@ -124,6 +124,11 @@ func toPassableValue(from anyValue: Any) -> PassableValue { } switch anyValue { + case is NSNull: + // A field the SDK explicitly reported as unknown. Without this it would fall + // through to the `default` case and come out as an empty map, which can't be + // compared to anything. + return .null case let value as Int: return .int(value) case let value as UInt64: diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift index 3ce86077d..f6c9dabe2 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift @@ -12,6 +12,8 @@ import UIKit extension Superwall { struct PaywallComponents { let viewController: PaywallViewController + /// The paywall resolved for the request, including its experiment. + let paywall: Paywall let presenter: UIViewController? let audienceOutcome: AudienceFilterEvaluationOutcome let debugInfo: [String: Any] @@ -33,6 +35,7 @@ extension Superwall { await paywallComponents.viewController.set( request: request, + paywall: paywallComponents.paywall, paywallStatePublisher: publisher, unsavedOccurrence: paywallComponents.audienceOutcome.unsavedOccurrence ) diff --git a/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift b/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift index 6e9ddf2a0..54f89444a 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift @@ -37,7 +37,7 @@ extension Superwall { from: audienceOutcome ) - let paywallViewController = try await getPaywallViewController( + let (paywallViewController, paywall) = try await getPaywallViewController( request: request, audienceOutcome: audienceOutcome, debugInfo: debugInfo, @@ -61,6 +61,7 @@ extension Superwall { return PaywallComponents( viewController: paywallViewController, + paywall: paywall, presenter: presenter, audienceOutcome: audienceOutcome, debugInfo: debugInfo diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift index 0b40b873c..0a7854907 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift @@ -38,6 +38,7 @@ extension Superwall { try await presentPaywallViewController( paywallComponents.viewController, + paywall: paywallComponents.paywall, on: presenter, unsavedOccurrence: paywallComponents.audienceOutcome.unsavedOccurrence, debugInfo: paywallComponents.debugInfo, diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift index 0958c2bad..050ced950 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift @@ -18,7 +18,9 @@ extension Superwall { /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. /// - dependencyContainer: Used with testing only. /// - /// - Returns: A ``PaywallViewController``. + /// - Returns: A ``PaywallViewController`` and the paywall resolved for this + /// request. The paywall is applied to the view controller when the request + /// claims it, so that an earlier request that's on screen isn't changed. /// - throws: An error if unable to retrieve paywall or a paywall is /// already presented. func getPaywallViewController( @@ -27,7 +29,7 @@ extension Superwall { debugInfo: [String: Any], paywallStatePublisher: PassthroughSubject? = nil, dependencyContainer: DependencyContainer - ) async throws -> PaywallViewController { + ) async throws -> (viewController: PaywallViewController, paywall: Paywall) { let experiment = try await getExperiment( request: request, audienceOutcome: audienceOutcome, @@ -70,11 +72,10 @@ extension Superwall { for: paywall, isDebuggerLaunched: paywallRequest.isDebuggerLaunched, isForPresentation: isForPresentation, - isPreloading: false, delegate: delegate ) - return paywallViewController + return (paywallViewController, paywall) } catch { throw await presentationFailure(error, request, debugInfo, paywallStatePublisher) } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift index 5beb61b75..b06a094a0 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift @@ -13,17 +13,19 @@ extension Superwall { /// and sends back a `presented` state to the paywall state publisher. /// /// - Parameters: - /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. + /// - paywallViewController: The view controller to present. + /// - paywall: The paywall resolved for the request. /// - presenter: The view controller to present that paywall on. /// - unsavedOccurrence: The audience occurrence to save, if available. /// - debugInfo: Information to help with debugging. /// - request: The request to present the paywall. /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. /// - /// - Returns: A publisher that contains info for the next pipeline operator. + /// - Throws: `PresentationPipelineError.paywallAlreadyPresented` if another paywall is on screen. @MainActor func presentPaywallViewController( _ paywallViewController: PaywallViewController, + paywall: Paywall, on presenter: UIViewController, unsavedOccurrence: TriggerAudienceOccurrence?, debugInfo: [String: Any], @@ -43,6 +45,7 @@ extension Superwall { paywallViewController.present( on: presenter, request: request, + paywall: paywall, unsavedOccurrence: unsavedOccurrence, presentationStyleOverride: request.paywallOverrides?.presentationStyle, paywallStatePublisher: paywallStatePublisher diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index dac01c42d..4c3f91bb6 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -128,6 +128,9 @@ public final class PaywallInfo: NSObject { /// Indicates whether scrolling of the webview is enabled. public let isScrollEnabled: Bool + /// Whether the paywall was served from a local `superwall dev` server. + public let isLocal: Bool + /// The state of the paywall, updated on paywall did dismiss. public let state: [String: Any] @@ -172,6 +175,7 @@ public final class PaywallInfo: NSObject { surveys: [Survey], presentation: PaywallPresentationInfo, isScrollEnabled: Bool, + isLocal: Bool = false, state: [String: Any], introOfferEligibility: IntroOfferEligibility ) { @@ -241,6 +245,7 @@ public final class PaywallInfo: NSObject { self.closeReason = closeReason self.isScrollEnabled = isScrollEnabled + self.isLocal = isLocal self.state = state self.introOfferEligibility = introOfferEligibility } @@ -325,6 +330,7 @@ public final class PaywallInfo: NSObject { "paywall_product_ids": productIds.joined(separator: ","), "is_free_trial_available": isFreeTrialAvailable as Any, "feature_gating": featureGatingBehavior.description as Any, + "is_local": isLocal, "presented_by": presentedBy as Any ] diff --git a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift index 4bc5d51c1..3618e4653 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift @@ -196,11 +196,10 @@ extension Superwall { } )) - // Assign the current register task while capturing the previous one. - previousRegisterTask = Task { [weak self, previousRegisterTask] in - // Wait until the previous task is finished before continuing. - await previousRegisterTask?.value - + // Queue the work behind any register call already in flight and return. + // `register` can be called from any thread, so the queue has to be safe to + // add to from any thread. + registerTaskCoordinator.enqueue { [weak self] in await self?.trackAndPresentPaywall( forPlacement: placement, params: params, diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 4f4bb3b96..bc7ad3776 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -333,6 +333,13 @@ extension PaywallRequestManager { return false } + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`), and until it does `customerInfo` is + // the copy restored from disk. A trial started in this group since the last + // launch is only in the read, and advertising a trial Apple won't grant is + // the thing this check exists to prevent, so wait for it. + await factory.initialPurchasesLoad?.value + let subscriptions = await MainActor.run { Superwall.shared.customerInfo.subscriptions } @@ -360,6 +367,18 @@ extension PaywallRequestManager { if productEntitlementIds.isEmpty { return false } + // Same reason as `hasActiveIntroOffer`: config can be published before the + // purchases load finishes, and until it does `customerInfo` is the copy + // restored from disk. The `isPlaceholder` check below can't stand in for + // this β€” the early publish only happens when a non-blank copy is saved, + // which is exactly when `isPlaceholder` is false. + // + // An entitlement the load can't move is answered without waiting, which is + // what keeps a web-only paywall quick. + if factory.purchasesLoadCouldChange(entitlementIds: productEntitlementIds) { + await factory.initialPurchasesLoad?.value + } + let customerInfo = await MainActor.run { Superwall.shared.customerInfo } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index e4fce9a23..27a0c9114 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -16,6 +16,9 @@ extension PaywallRequestManager { placement: request.placementData ) var paywall = try await getPaywallResponse(from: request) + if !request.isDebuggerLaunched { + paywall = await applyDevServerOverrideIfNeeded(to: paywall) + } paywall.presentationId = UUID().uuidString let paywallInfo = paywall.getInfo(fromPlacement: request.placementData) @@ -27,6 +30,87 @@ extension PaywallRequestManager { return paywall } + func applyDevServerOverrideIfNeeded(to paywall: Paywall) async -> Paywall { + let options = factory.makeSuperwallOptions() + guard DevMode.isActive(options) else { + return paywall + } + guard + let location = await DevServerLocator.shared.locate(devServerURL: options.devServerURL), + let surface = location.manifest.surface(forPaywallDatabaseId: paywall.databaseId), + let mountURL = location.manifest.mountURL(for: surface, base: location.base) + else { + return paywall + } + + // The local surface replaces the published paywall's bytes, products and + // whatever settings its config.ts declares, and inherits the rest from the + // paywall it stands in for β€” see Paywall.devServer(surface:url:inheriting:). + // The synthesized cacheKey embeds the mount URL, so a moved dev server or + // a published fallback reloads the web view instead of presenting the + // stale page. + let devPaywall = Paywall.devServer( + surface: surface, + url: mountURL, + inheriting: paywall + ) + + Logger.debug( + logLevel: .info, + scope: .superwallCore, + message: "Dev server override: paywall \(paywall.identifier) is served as local surface " + + "\(surface.id) from \(mountURL.absoluteString)." + ) + return devPaywall + } + + /// Resolves a synthetic `dev:` identifier β€” a dev-server surface the + /// debugger selected that has never been pushed to the dashboard β€” from the + /// dev server's manifest, since the backend has nothing to fetch for it. + /// + /// The manifest is fetched again here rather than read from the copy the + /// debugger took when it opened: the surface's products and presentation + /// settings come from its config.ts, and edits made since must reach the + /// next presentation. + private func devServerPaywall(forId paywallId: String?) async -> Paywall? { + guard let paywallId = paywallId else { + return nil + } + guard paywallId.hasPrefix("dev:") else { + return nil + } + let options = factory.makeSuperwallOptions() + guard DevMode.isActive(options) else { + return nil + } + // `dev:` identifiers only come from the debugger, so without its snapshot + // there is nothing to present and no reason to probe for a server. + guard let snapshot = await MainActor.run(body: { + Superwall.shared.dependencyContainer.debugManager.devServer + }) else { + return nil + } + let fresh = await DevServerLocator.shared.locate(devServerURL: options.devServerURL) + if let fresh = fresh { + // The debugger's picker and any later presentation read this copy, so + // it has to describe the same server the presentation does. + await MainActor.run { + Superwall.shared.dependencyContainer.debugManager.devServer = ( + base: fresh.base, + surfaces: fresh.manifest.surfaces + ) + } + } + guard let resolved = DevServerPreview.resolveSurface( + previewIdentifier: paywallId, + fresh: fresh, + snapshot: snapshot + ) else { + return nil + } + return Paywall.devServer(surface: resolved.surface, url: resolved.url) + } + private func getPaywallResponse( from request: PaywallRequest ) async throws -> Paywall { @@ -36,7 +120,9 @@ extension PaywallRequestManager { var paywall: Paywall do { - if let staticPaywall = factory.makeStaticPaywall( + if let devPaywall = await devServerPaywall(forId: paywallId) { + paywall = devPaywall + } else if let staticPaywall = factory.makeStaticPaywall( withId: paywallId, isDebuggerLaunched: request.isDebuggerLaunched ) { diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 4a2ed4026..c177f12e6 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -19,6 +19,7 @@ actor PaywallRequestManager { typealias Factory = DeviceHelperFactory & ConfigManagerFactory & ReceiptFactory + & OptionsFactory init( storeKitManager: StoreKitManager, @@ -125,8 +126,16 @@ actor PaywallRequestManager { isDebuggerLaunched: Bool ) { activeTasks[requestHash] = nil - if !isDebuggerLaunched { - paywallsByHash[requestHash] = paywall + if isDebuggerLaunched { + return } + // The request hash carries no dev-server component, so memoising in dev + // mode would freeze whatever the dev server's state was at first fetch β€” + // a transient miss would pin the published paywall for the whole process. + // Preloading is off in dev mode, so this caching buys nothing there. + if DevMode.isActive(factory.makeSuperwallOptions()) { + return + } + paywallsByHash[requestHash] = paywall } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 9353a7b2b..df6182126 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -41,6 +41,47 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// The cache key for the view controller. var cacheKey: String + /// What a request applies when it claims the view controller. + /// + /// The occurrence and publisher are cleared once used, so restoring the + /// claim later doesn't save the occurrence again or reinstall a publisher + /// that has already completed. + private final class Claim { + let request: PresentationRequest + let paywall: Paywall + var paywallStatePublisher: PassthroughSubject? + var unsavedOccurrence: TriggerAudienceOccurrence? + + init( + request: PresentationRequest, + paywall: Paywall, + paywallStatePublisher: PassthroughSubject, + unsavedOccurrence: TriggerAudienceOccurrence? + ) { + self.request = request + self.paywall = paywall + self.paywallStatePublisher = paywallStatePublisher + self.unsavedOccurrence = unsavedOccurrence + } + } + + /// The claim currently applied to the view controller. + private var currentClaim: Claim? + + /// The claim made through `getPaywall`. Kept so that a view controller the + /// app is holding reports that placement when the app shows it, even if the + /// SDK presented this paywall for another placement in the meantime. + /// + /// This is held for the life of the cached view controller on purpose: the + /// app can show the handle it was given at any time. + private var handedOutClaim: Claim? + + /// Whether the SDK claimed the view controller after it was handed out. + private var handedOutClaimNeedsRestoring = false + + /// Whether the SDK started the current presentation via ``present(on:request:paywall:unsavedOccurrence:presentationStyleOverride:paywallStatePublisher:completion:)``. + private var isPresentedBySDK = false + /// Determines whether the paywall is presented or not. var isActive: Bool { return isPresented || isBeingPresented @@ -119,7 +160,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private var didReceiveStripeCheckoutAbandonMessage = false /// Ensures Stripe checkout callbacks are forwarded to WebEntitlementRedeemer in order. - private var previousStripeCheckoutTask: Task? + private let stripeCheckoutCoordinator = SerialTaskCoordinator() /// Manages intro offer eligibility tokens for SK2 purchases on iOS 18.2+ let introOfferTokenManager: IntroOfferTokenManager @@ -735,20 +776,83 @@ public class PaywallViewController: UIViewController, LoadingDelegate { // MARK: - Presentation Logic - /// Sets data before presenting the paywall. + /// Claims the view controller for a request: binds the request and applies + /// the paywall resolved for it, so the placement, experiment and products + /// reported for this presentation all come from the same request. + /// + /// A view controller that's on screen stays with the request that put it + /// there, so a claim made while it's active is ignored. func set( request: PresentationRequest, + paywall: Paywall, paywallStatePublisher: PassthroughSubject, unsavedOccurrence: TriggerAudienceOccurrence? ) { - self.request = request - self.paywallStateSubject = paywallStatePublisher - self.unsavedOccurrence = unsavedOccurrence + let claim = Claim( + request: request, + paywall: paywall, + paywallStatePublisher: paywallStatePublisher, + unsavedOccurrence: unsavedOccurrence + ) + let isHandedOut: Bool + if case .getPaywall = request.flags.type { + isHandedOut = true + } else { + isHandedOut = false + } + + if isActive { + // The app still gets this view controller back from `getPaywall`, so + // keep its claim for when the app shows it after the current + // presentation ends. + if isHandedOut { + handedOutClaim = claim + handedOutClaimNeedsRestoring = true + } + return + } + + apply(claim) + isPresentedBySDK = false + + if isHandedOut { + handedOutClaim = claim + handedOutClaimNeedsRestoring = false + } else if handedOutClaim != nil { + handedOutClaimNeedsRestoring = true + } + } + + /// Applies a claim. Returns whether it started a web view load. + @discardableResult + private func apply(_ claim: Claim) -> Bool { + // Install the claim's delegate and request first, so anything the + // reload below announces goes to the claim that owns it. + delegate = claim.request.flags.type.getPaywallVcDelegateAdapter() + request = claim.request + if claim.paywallStatePublisher == nil { + // The previous one completed when the paywall was last dismissed. + claim.paywallStatePublisher = PassthroughSubject() + } + paywallStateSubject = claim.paywallStatePublisher + unsavedOccurrence = claim.unsavedOccurrence + currentClaim = claim + + if claim.paywall.cacheKey == paywall.cacheKey { + paywall.update(from: claim.paywall) + return false + } + // The request resolved a different version of the paywall from the one + // loaded, so show that version rather than only its products. + paywall = claim.paywall + loadWebView() + return true } func present( on presenter: UIViewController, request: PresentationRequest, + paywall: Paywall, unsavedOccurrence: TriggerAudienceOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: PassthroughSubject, @@ -763,11 +867,13 @@ public class PaywallViewController: UIViewController, LoadingDelegate { set( request: request, + paywall: paywall, paywallStatePublisher: paywallStatePublisher, unsavedOccurrence: unsavedOccurrence ) setPresentationStyle(withOverride: presentationStyleOverride) + isPresentedBySDK = true presenter.present( self, @@ -1266,10 +1372,9 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { private func enqueueStripeCheckoutTask( _ operation: @escaping (PaywallViewController) async -> Void ) { - // Assign the current Stripe task while capturing the previous one. - previousStripeCheckoutTask = Task { [weak self, previousStripeCheckoutTask] in - // Wait until the previous task is finished before continuing. - await previousStripeCheckoutTask?.value + // Queue the callback behind any already in flight and return. The closure + // stays on the main actor so the callbacks run where they always have. + stripeCheckoutCoordinator.enqueue { @MainActor [weak self] in guard let self else { return } @@ -1402,6 +1507,18 @@ extension PaywallViewController { override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + // The app is showing a view controller it got from `getPaywall`, and + // another request has claimed it since. Report the app's placement. Only + // on an appearance that starts a presentation: `viewWillAppear` also fires + // when the paywall is already on screen, such as after Safari closes. + var didStartLoadForClaim = false + if presentationWillPrepare, + !isPresentedBySDK, + handedOutClaimNeedsRestoring, + let claim = handedOutClaim { + didStartLoadForClaim = apply(claim) + handedOutClaimNeedsRestoring = false + } cache?.activePaywallVcKey = cacheKey if isSafariVCPresented { @@ -1413,7 +1530,8 @@ extension PaywallViewController { webView.setAllMediaPlaybackSuspended(false) // ignore-xcode-12 } - if webView.loadingHandler.didFailToLoad { + if webView.loadingHandler.didFailToLoad, + !didStartLoadForClaim { loadWebView() } @@ -1516,6 +1634,7 @@ extension PaywallViewController { if let unsavedOccurrence = unsavedOccurrence { storage.coreDataManager.save(triggerAudienceOccurrence: unsavedOccurrence) self.unsavedOccurrence = nil + currentClaim?.unsavedOccurrence = nil } isPresented = true Superwall.shared.dependencyContainer.delegateAdapter.didPresentPaywall(withInfo: info) @@ -1669,6 +1788,7 @@ extension PaywallViewController { paywallResult = nil cache?.activePaywallVcKey = nil isPresented = false + isPresentedBySDK = false dismissCompletionBlock?() dismissCompletionBlock = nil @@ -1686,6 +1806,7 @@ extension PaywallViewController { if paywall.closeReason.stateShouldComplete { paywallStateSubject?.send(completion: .finished) paywallStateSubject = nil + currentClaim?.paywallStatePublisher = nil } Superwall.shared.dependencyContainer.delegateAdapter.didDismissPaywall(withInfo: info) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index f39f6c79e..fe946a2dd 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -86,7 +86,7 @@ enum PaywallMessage: Decodable, Equatable { case transactionRestore case transactionStart - case transactionComplete(trialEndDate: Date?, productIdentifier: String) + case transactionComplete(trialEndDate: Date?, productIdentifier: String, didStartFreeTrial: Bool) case transactionFail case transactionAbandon case transactionTimeout diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index c72828669..ec15c5d49 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -135,7 +135,7 @@ final class PaywallMessageHandler: WebEventDelegate { Task { await self.pass(placement: transactionStart, from: paywall) } - case let .transactionComplete(trialEndDate, productIdentifier): + case let .transactionComplete(trialEndDate, productIdentifier, didStartFreeTrial): Task { // Send transaction_complete to trigger post-purchase actions let transactionComplete = SuperwallEventObjc.transactionComplete.description @@ -145,6 +145,13 @@ final class PaywallMessageHandler: WebEventDelegate { payload: ["product_identifier": productIdentifier] ) + // Only tell the paywall a trial started when one actually did. The paywall + // schedules its trial reminder off this message whenever a trial_end_date is + // present, and StoreKit exposes the intro offer even to ineligible users. + guard didStartFreeTrial else { + return + } + // Send freeTrial_start for notification scheduling let freeTrialStart = SuperwallEventObjc.freeTrialStart.description var freeTrialPayload: [String: Any] = ["product_identifier": productIdentifier] 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 8a77c694b..31427cb16 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 @@ -70,8 +70,8 @@ struct DeviceTemplate: Codable { var localResourceIds: String var deviceId: String - func toDictionary() -> [String: Any] { - guard let data = try? JSONEncoder().encode(self) else { + func toDictionary(encoder: JSONEncoder = JSONEncoder()) -> [String: Any] { + guard let data = try? encoder.encode(self) else { return [:] } let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index daab59c39..ed0774387 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -201,6 +201,16 @@ enum SubscriptionStatusKey: Storable { typealias Value = SubscriptionStatus } +enum GrantedEntitlements: Storable { + static var key: String { + "store.grantedEntitlements" + } + // App-specific so that `Storage.reset()` doesn't wipe it: the developer owns + // this bucket and its lifecycle, including across `reset()`/`identify()`. + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Set +} + enum SurveyAssignmentKey: Storable { static var key: String { "store.surveyAssignmentKey" diff --git a/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift b/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift index 18075dee5..f01443278 100644 --- a/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift +++ b/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift @@ -51,6 +51,30 @@ public final class EntitlementsInfo: NSObject, ObservableObject, @unchecked Send return Set(entitlements) } + /// The entitlements granted via ``Superwall/grantedEntitlements``. + /// + /// Loaded from storage once and kept in memory: it's read on every status + /// publish, and a storage miss β€” the common case, since most apps never + /// grant β€” isn't memoized by the cache, so reading through would hit disk + /// every time. + var granted: Set { + return queue.sync { + if let granted = backingGranted { + return granted + } + let granted = storage.get(GrantedEntitlements.self) ?? [] + backingGranted = granted + return granted + } + } + + func setGranted(_ granted: Set) { + queue.sync { + backingGranted = granted + storage.save(granted, forType: GrantedEntitlements.self) + } + } + // MARK: - Internal vars /// The entitlements that belong to each product ID. var entitlementsByProductId: [String: Set] = [:] { @@ -64,6 +88,9 @@ public final class EntitlementsInfo: NSObject, ObservableObject, @unchecked Send /// The backing variable for ``EntitlementsInfo/active``. private var backingActive: Set = [] + /// The backing variable for `granted`; `nil` until first read. + private var backingGranted: Set? + /// The backing variable for ``EntitlementsInfo/all``. private var backingAll: Set = [] diff --git a/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift b/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift index ef8c9750a..cf337028e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift @@ -54,6 +54,26 @@ public enum SubscriptionStatus: Equatable, Codable { } } +// MARK: - Logical Equality +extension SubscriptionStatus { + /// Whether both statuses represent the same logical subscription state. + /// + /// Two `.active` statuses are logically equal when their entitlements have the + /// same identities (`id`, `type`, and `isActive`), even if transaction metadata + /// like expiry dates or renewal state differs. `==` compares that metadata too, + /// so repeat writes of the same logical status can read as changes. + func isLogicallyEqual(to other: SubscriptionStatus) -> Bool { + switch (self, other) { + case (.unknown, .unknown), (.inactive, .inactive): + return true + case let (.active(lhsEntitlements), .active(rhsEntitlements)): + return Set(lhsEntitlements.map(\.identity)) == Set(rhsEntitlements.map(\.identity)) + default: + return false + } + } +} + // MARK: - CustomStringConvertible extension SubscriptionStatus: CustomStringConvertible { public var description: String { diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift new file mode 100644 index 000000000..d4ee2188c --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -0,0 +1,71 @@ +// +// PublishedSubscriptionStatus.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-03. +// + +import Combine +import Foundation + +/// The property wrapper behind ``Superwall/subscriptionStatus``. +/// +/// Every assignment is routed through `publishSubscriptionStatus`, so the +/// stored and published value is always the merged one β€” subscribers never +/// see the value a writer assigned before granted entitlements and test-mode +/// overrides were applied. The projected value (`$subscriptionStatus`) replays +/// the latest emitted value to new subscribers, like `@Published`; while +/// emissions are queued behind a slow subscriber, that replay can trail the +/// stored value until its turn comes. Reads take the same lock as writes, +/// so a read on one thread never races an assignment on another. +/// +/// Public only because a public property's wrapper type has to be; nothing +/// but the projection is meant to be used. The enclosing-instance subscript +/// is the same mechanism `@Published` itself uses to reach its owner. +@propertyWrapper +public struct PublishedSubscriptionStatus { + private var storage: SubscriptionStatus + private let subject: CurrentValueSubject + + public init(wrappedValue: SubscriptionStatus) { + storage = wrappedValue + subject = CurrentValueSubject(wrappedValue) + } + + @available(*, unavailable, message: "Only available on Superwall.") + public var wrappedValue: SubscriptionStatus { + get { fatalError("subscriptionStatus is only readable on Superwall.") } + set { fatalError("\(newValue) can only be assigned on Superwall.") } + } + + public var projectedValue: AnyPublisher { + return subject.eraseToAnyPublisher() + } + + public static subscript( + _enclosingInstance superwall: Superwall, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> SubscriptionStatus { + get { + superwall.subscriptionStatusLock.lock() + defer { + superwall.subscriptionStatusLock.unlock() + } + return superwall[keyPath: storageKeyPath].storage + } + set { + superwall.publishSubscriptionStatus(.developerAssignment(newValue)) + } + } + + /// Stores a merged status without emitting it. + mutating func store(_ merged: SubscriptionStatus) { + storage = merged + } + + /// Emits a stored status to subscribers. + func emit(_ status: SubscriptionStatus) { + subject.send(status) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift new file mode 100644 index 000000000..02246434c --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -0,0 +1,183 @@ +// +// ReceiptManager+Restore.swift +// SuperwallKit +// + +import Foundation + +// MARK: - Restoring from the previous launch + +extension ReceiptManager { + /// Rebuilds the in-memory purchase state from the customer info saved by the + /// previous launch, so config can be published before this launch's StoreKit + /// read finishes. `loadPurchasedProducts` overwrites all of it when it lands. + /// + /// Each saved item carries its own expiry, which the store asserted, so + /// anything past it has lapsed since the last launch and is restored inactive. + /// A nil expiry keeps its saved state, except on a subscription: nothing can + /// show one is still current, so it isn't restored active. + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async { + let now = Date() + // The saved copy is the merged one, with web subscriptions appended. The + // purchases the StoreKit 2 read produces are device-only, so only App Store + // rows are seeded; otherwise `activeProducts` would change shape when the + // read lands. (The fast path is StoreKit 2 only; see `ConfigManager`.) + // + // A subscription with no expiry can't be shown to be current, and the read + // doesn't count one as active either, so it isn't restored active. + let activeSubscriptions = customerInfo.subscriptions.filter { + guard let expiresAt = $0.expirationDate else { + return false + } + return $0.store == .appStore && $0.isActive && expiresAt > now + } + let activeTransactionIds = Set(activeSubscriptions.map { $0.transactionId }) + let subscriptionPurchases = customerInfo.subscriptions + .filter { $0.store == .appStore } + .map { + Purchase( + id: $0.productId, + isActive: activeTransactionIds.contains($0.transactionId), + purchaseDate: $0.purchaseDate + ) + } + // Same rule as the read: with no expiry, only a non-consumable stays active. + let nonSubscriptionPurchases = customerInfo.nonSubscriptions + .filter { $0.store == .appStore } + .map { + Purchase( + id: $0.productId, + isActive: !$0.isRevoked && !$0.isConsumable, + purchaseDate: $0.purchaseDate + ) + } + let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) + await manager.seedPurchases(purchases) + + // Config knows every product and the entitlements it unlocks. The saved + // customer info knows which of those were active, and carries fields like + // willRenew that audience filters read, so its copy wins where both have one. + // Lapsed rows are deactivated before the grants go in, so a saved row that + // shares an id with a grant can't drag the grant down with it: once it's + // inactive, `mergePrioritized` prefers the active grant. + let saved = customerInfo.entitlements.map { entitlement in + let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) + return lapsed ? deactivated(entitlement) : entitlement + } + // Every load merges the developer's grants back in, so the restore does too. + // Otherwise a grant made since the last launch, which the saved copy + // predates, would look inactive until the read lands. Merged the way the + // load merges them, so the richer copy wins field by field. + let merged = Entitlement.mergePrioritized(saved + Array(grantedEntitlements)) + let savedById = Dictionary(uniqueKeysWithValues: merged.map { ($0.id, $0) }) + let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) + .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } + Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) + + activeSubscriptionGroupIds = Set(activeSubscriptions.compactMap { $0.subscriptionGroupId }) + + // The customer info and status were restored from disk exactly as saved, so + // a row that lapsed since the last launch still reads active through them. + // Publish both lapse-corrected, the way the read does when it lands: the + // status via the same delegate call, from the seeded purchases and the + // corrected entitlement map. Audience filters read these while config is + // already published, so they see the same state as `entitlementsByProductId`. + // + // Not with an external purchase controller. There the status is the + // controller's and the customer info is rebuilt from it on every publish, + // from the device rows the read saves. The saved copy is merged, so a device + // copy carved out of it would be mis-sourced, and a write here would race + // the controller's own assignments. The rows stay as saved until the read + // lands, which is what happened before the early publish existed. + if factory.makeHasExternalPurchaseController() { + return + } + await publishRestoredCustomerInfo( + from: customerInfo, + activeTransactionIds: activeTransactionIds, + mergedEntitlements: merged, + at: now + ) + await receiptDelegate?.syncSubscriptionStatus(purchases: purchases) + } + + /// Assigns the saved customer info with rows that lapsed since the last + /// launch marked inactive. + /// + /// Rows follow the seeded purchases: an App Store subscription with no + /// expiry can't be shown to be current, so it's inactive here even though + /// its entitlement, which follows the entitlement rule in `restorePurchases`, + /// keeps its saved state. + private func publishRestoredCustomerInfo( + from customerInfo: CustomerInfo, + activeTransactionIds: Set, + mergedEntitlements: Set, + at now: Date + ) async { + let subscriptions = customerInfo.subscriptions.map { subscription -> SubscriptionTransaction in + let stillActive: Bool + if subscription.store == .appStore { + stillActive = activeTransactionIds.contains(subscription.transactionId) + } else { + stillActive = subscription.isActive && !hasExpired(subscription.expirationDate, at: now) + } + return subscription.isActive && !stillActive ? deactivated(subscription) : subscription + } + let restoredCustomerInfo = CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: customerInfo.nonSubscriptions, + entitlements: mergedEntitlements.sorted { $0.id < $1.id }, + isPlaceholder: customerInfo.isPlaceholder + ) + await MainActor.run { + Superwall.shared.customerInfo = restoredCustomerInfo + } + } + + /// A nil expiry never lapses: lifetime purchases and web entitlements without one. + private func hasExpired(_ expiresAt: Date?, at now: Date) -> Bool { + guard let expiresAt = expiresAt else { + return false + } + return expiresAt <= now + } + + private func deactivated(_ subscription: SubscriptionTransaction) -> SubscriptionTransaction { + return SubscriptionTransaction( + transactionId: subscription.transactionId, + productId: subscription.productId, + purchaseDate: subscription.purchaseDate, + willRenew: subscription.willRenew, + isRevoked: subscription.isRevoked, + isInGracePeriod: subscription.isInGracePeriod, + isInBillingRetryPeriod: subscription.isInBillingRetryPeriod, + isActive: false, + expirationDate: subscription.expirationDate, + offerType: subscription.offerType, + subscriptionGroupId: subscription.subscriptionGroupId, + store: subscription.store + ) + } + + private func deactivated(_ entitlement: Entitlement) -> Entitlement { + return Entitlement( + id: entitlement.id, + type: entitlement.type, + isActive: false, + productIds: entitlement.productIds, + latestProductId: entitlement.latestProductId, + store: entitlement.store, + startsAt: entitlement.startsAt, + renewedAt: entitlement.renewedAt, + expiresAt: entitlement.expiresAt, + isLifetime: entitlement.isLifetime, + willRenew: entitlement.willRenew, + state: entitlement.state, + offerType: entitlement.offerType + ) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 8e6fcf6b6..0d266f332 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -23,18 +23,18 @@ actor ReceiptManager { private var receiptRefreshCompletion: ((Bool) -> Void)? private unowned let productsManager: ProductsManager - private weak var receiptDelegate: ReceiptDelegate? + weak var receiptDelegate: ReceiptDelegate? private let storeKitVersion: SuperwallOptions.StoreKitVersion private let shouldBypassAppTransactionCheck: Bool - private let manager: ReceiptManagerType + let manager: ReceiptManagerType private let delegateWrapper: ReceiptRefreshDelegateWrapper - private unowned let factory: Factory + unowned let factory: Factory private unowned let storage: Storage /// Subscription group IDs the user currently has an active subscription in. Computed - /// during `loadPurchasedProducts` from the active purchases and their fetched products, - /// so it works for both StoreKit 1 and StoreKit 2. Used to suppress free trials on - /// upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. - private var activeSubscriptionGroupIds: Set + /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which + /// carry the group ID; on StoreKit 1 from the fetched purchased products. Used to suppress + /// free trials on upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. + var activeSubscriptionGroupIds: Set static var appTransactionId: String? static var appId: UInt64? /// Set from `AppTransaction.shared` when available (iOS 16+). @@ -187,39 +187,24 @@ actor ReceiptManager { // Save device-only CustomerInfo to storage for use when merging with web entitlements storage.save(onDeviceSnapshot.customerInfo, forType: LatestDeviceCustomerInfo.self) - // Merge with web customer info if available - let baseCustomerInfo: CustomerInfo - if let latestRedeemResponse = storage.get(LatestRedeemResponse.self) { - baseCustomerInfo = onDeviceSnapshot.customerInfo.merging(with: latestRedeemResponse.customerInfo) - } else { - baseCustomerInfo = onDeviceSnapshot.customerInfo - } + // The other sources that merge with the device snapshot. + let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo + let grantedEntitlements = Superwall.shared.entitlements.granted - // If using an external purchase controller, preserve entitlements that came from it - // (The external controller's active entitlements won't necessarily be in device data) let mergedCustomerInfo: CustomerInfo if factory.makeHasExternalPurchaseController() { let currentCustomerInfo = await MainActor.run { Superwall.shared.customerInfo } - - // Get entitlements that are only in current CustomerInfo (i.e., from external controller) - // by filtering out anything that matches device or web entitlements by ID - let deviceAndWebEntitlementIds = Set(baseCustomerInfo.entitlements.map { $0.id }) - let externalOnlyEntitlements = currentCustomerInfo.entitlements.filter { entitlement in - // Keep external entitlement if it's not already in device/web - !deviceAndWebEntitlementIds.contains(entitlement.id) - } - - // Merge external controller entitlements with device + web - let allEntitlements = baseCustomerInfo.entitlements + externalOnlyEntitlements - let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) - - mergedCustomerInfo = CustomerInfo( - subscriptions: baseCustomerInfo.subscriptions, - nonSubscriptions: baseCustomerInfo.nonSubscriptions, - entitlements: finalEntitlements.sorted { $0.id < $1.id } + mergedCustomerInfo = CustomerInfo.preservingExternalControllerEntitlements( + device: onDeviceSnapshot.customerInfo, + web: webCustomerInfo, + current: currentCustomerInfo, + granted: grantedEntitlements ) } else { - mergedCustomerInfo = baseCustomerInfo + mergedCustomerInfo = onDeviceSnapshot.customerInfo.merging( + with: webCustomerInfo ?? .blank(), + granting: grantedEntitlements + ) } await MainActor.run { @@ -230,16 +215,20 @@ actor ReceiptManager { await receiptDelegate?.syncSubscriptionStatus(purchases: onDeviceSnapshot.purchases) - let purchasedProductIds = Set(onDeviceSnapshot.purchases.map { $0.id }) - - guard let storeProducts = try? await productsManager.products( - identifiers: purchasedProductIds, - forPaywall: nil, - placement: nil - ) else { - // Fetch failed: refresh from the snapshot alone so the set still reflects this load. - // We assign only *after* the await (here and below), never before, so a re-entrant - // `isFreeTrialAvailable` during the suspension can't observe a half-built set. + // StoreKit 2 transactions carry their subscription group ID, so the active + // groups come straight from the snapshot. Only StoreKit 1 has to fetch the + // purchased products to find them. Skipping the fetch keeps a network round + // trip off the cold-launch path, which `configState` waits on. + guard manager.loadsSubscriptionGroupsFromProducts, + let storeProducts = try? await productsManager.products( + identifiers: Set(onDeviceSnapshot.purchases.map { $0.id }), + forPaywall: nil, + placement: nil + ) + else { + // Fetch skipped or failed: refresh from the snapshot alone so the set still reflects + // this load. We assign only *after* the await (here and below), never before, so a + // re-entrant `isFreeTrialAvailable` during the suspension can't observe a half-built set. activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: []) return } @@ -255,7 +244,17 @@ actor ReceiptManager { /// *consumed* an intro in the group): Apple doesn't apply intro offers to upgrades, crossgrades, /// or downgrades, so we also require no active subscription in the product's group. Once the /// existing subscription lapses, a fresh purchase is eligible again. - func isFreeTrialAvailable(for storeProduct: StoreProduct) async -> Bool { + /// - Parameter purchasesLoad: The load that config was published ahead of, if + /// any. Awaited only in the branch that reads the active subscription groups, + /// so a paywall whose answer can't depend on the load isn't held up by it. + /// This is only non-nil on StoreKit 2 (see `ConfigManager`), which matters + /// for the placement: StoreKit 2 resolves intro eligibility live from Apple, + /// while StoreKit 1 answers it from state the load fills in, so on StoreKit 1 + /// the eligibility check above would have to wait too. + func isFreeTrialAvailable( + for storeProduct: StoreProduct, + waitingFor purchasesLoad: Task? = nil + ) async -> Bool { let isEligibleForIntroOffer = await manager.isEligibleForIntroOffer(storeProduct) if !isEligibleForIntroOffer { return false @@ -267,9 +266,12 @@ actor ReceiptManager { return true } - // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`, which always - // completes before a paywall opens (config is only marked retrieved after it runs, - // and presentation waits for config), so this reflects current subscription state. + // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`. On the + // sync config path that load completes before config is published. On the + // early-publish path it's still running, so wait for it here. The actor is + // free during that wait, which is what lets the load finish. + await purchasesLoad?.value + return !activeSubscriptionGroupIds.contains(subscriptionGroupId) } @@ -370,22 +372,3 @@ func computeActiveSubscriptionGroupIds( return Set(transactionGroupIds).union(productGroupIds) } - -final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { - weak var receiptManager: ReceiptManager? - - func requestDidFinish(_ request: SKRequest) { - Task { - await receiptManager?.receiptRefreshDidFinish(request: request) - } - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - Task { - await receiptManager?.receiptRefreshDidFail( - request: request, - error: error - ) - } - } -} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift new file mode 100644 index 000000000..0d61ede35 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift @@ -0,0 +1,26 @@ +// +// ReceiptRefreshDelegateWrapper.swift +// SuperwallKit +// + +import Foundation +import StoreKit + +final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { + weak var receiptManager: ReceiptManager? + + func requestDidFinish(_ request: SKRequest) { + Task { + await receiptManager?.receiptRefreshDidFinish(request: request) + } + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + Task { + await receiptManager?.receiptRefreshDidFail( + request: request, + error: error + ) + } + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift index 3478cd8a1..2073b6119 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift @@ -8,6 +8,7 @@ import Foundation final class SK1ReceiptManager: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts = true private let receiptData: () -> Data? var purchasedSubscriptionGroupIds: Set? var purchases: Set = [] @@ -25,6 +26,10 @@ final class SK1ReceiptManager: ReceiptManagerType { self.receiptData = receiptData } + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + func loadIntroOfferEligibility(forProducts storeProducts: Set) async { var purchasedSubscriptionGroupIds: Set = [] for storeProduct in storeProducts { @@ -72,6 +77,7 @@ final class SK1ReceiptManager: ReceiptManagerType { // Build map of active product IDs for quick lookup let activeProductIds = Set(purchases.filter { $0.isActive }.map { $0.id }) + let purchasedProductIds = Set(purchases.map { $0.id }) // Process all entitlements from config, enhancing them with active status // For SK1, we collect all product IDs per entitlement, then mark as active if ANY product is active @@ -92,12 +98,22 @@ final class SK1ReceiptManager: ReceiptManagerType { // Entitlement is active if ANY of its products is active let isActive = productIds.contains { activeProductIds.contains($0) } + // A receipt transaction for any of the entitlement's products makes + // it an App Store entitlement, matching the StoreKit 2 path. Without + // one the store stays nil, per the `Entitlement.store` contract. The + // anti-downgrade guard relies on active device-derived entitlements + // carrying `.appStore`: a device read may only refute those, and a + // nil store marks a grant from outside the App Store (web, manual). + let store: EntitlementStore? = + productIds.contains { purchasedProductIds.contains($0) } ? .appStore : nil + entitlements.append( Entitlement( id: entitlementId, type: entitlementTypes[entitlementId] ?? .serviceLevel, isActive: isActive, - productIds: productIds + productIds: productIds, + store: store ) ) } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift index ebac7ac9b..e616304fa 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -15,8 +15,16 @@ protocol ReceiptManagerType: AnyObject { var latestSubscriptionPeriodType: LatestSubscription.PeriodType? { get async } var latestSubscriptionWillAutoRenew: Bool? { get async } var latestSubscriptionState: LatestSubscription.State? { get async } + /// Whether the active subscription groups have to be read off fetched products. + /// StoreKit 1 receipts don't carry a subscription group ID, so SK1 fetches the + /// purchased products to find them (and to seed its intro-offer eligibility). + /// StoreKit 2 transactions carry the group ID, so SK2 skips that fetch. + var loadsSubscriptionGroupsFromProducts: Bool { get } func loadIntroOfferEligibility(forProducts storeProducts: Set) async + /// Replaces the in-memory purchases with ones rebuilt from the previous launch's + /// customer info. The next `loadPurchases` overwrites them. + func seedPurchases(_ purchases: Set) async func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool } @@ -28,6 +36,7 @@ struct PurchaseSnapshot { @available(iOS 15.0, *) actor SK2ReceiptManager: ReceiptManagerType { + nonisolated let loadsSubscriptionGroupsFromProducts = false /// Resolves intro-offer eligibility live from StoreKit. Injectable so tests can /// verify eligibility is re-evaluated on every call rather than cached. private let resolveIntroOfferEligibility: @Sendable (StoreProduct) async -> Bool @@ -62,6 +71,10 @@ actor SK2ReceiptManager: ReceiptManagerType { /// which could surface a free trial that Apple would not actually grant. func loadIntroOfferEligibility(forProducts _: Set) async {} + func seedPurchases(_ purchases: Set) { + self.purchases = purchases + } + func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot { var purchases: Set = [] var originalTransactionIds: Set = [] diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index 9e2b0c319..1cc50e3fe 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -244,18 +244,30 @@ public final class Entitlement: NSObject, Codable, Sendable { try container.encode(type, forKey: .type) try container.encodeIfPresent(isActive, forKey: .isActive) try container.encodeIfPresent(productIds, forKey: .productIds) - try container.encodeIfPresent(latestProductId, forKey: .latestProductId) - try container.encodeIfPresent(store, forKey: .store) try container.encodeIfPresent(startsAt, forKey: .startsAt) try container.encodeIfPresent(renewedAt, forKey: .renewedAt) try container.encodeIfPresent(expiresAt, forKey: .expiresAt) - try container.encodeIfPresent(isLifetime, forKey: .isLifetime) - try container.encodeIfPresent(willRenew, forKey: .willRenew) - try container.encodeIfPresent(state, forKey: .state) - try container.encodeIfPresent(offerType, forKey: .offerType) + // When the encoder asks for it, a detail we have no value for is written as + // an explicit null rather than left out, so an audience filter can tell "we + // don't know" from "no". A Purchase Controller can't fill these in, and a + // filter gives a missing key the type default, which made a bare entitlement + // match `willRenew == false`. Only the filter attributes are encoded that + // way; every other consumer sees the keys omitted as before. + // + // The dates above are never nulled: filters compare them with `<` and `>`, + // and a null on either side of an ordering comparison makes the whole filter + // evaluate to null, taking unrelated parts of the filter with it. + let nilAsNull = encoder.reportsUnknownFieldsAsNull + try container.encode(latestProductId, forKey: .latestProductId, nilAsNull: nilAsNull) + try container.encode(store, forKey: .store, nilAsNull: nilAsNull) + try container.encode(isLifetime, forKey: .isLifetime, nilAsNull: nilAsNull) + try container.encode(willRenew, forKey: .willRenew, nilAsNull: nilAsNull) + try container.encode(state, forKey: .state, nilAsNull: nilAsNull) + try container.encode(offerType, forKey: .offerType, nilAsNull: nilAsNull) } - // Override isEqual to define equality based on `id` and `type` + // Deep equality across all fields. For detecting logical status changes, + // use `identity` instead, which ignores transaction metadata. public override func isEqual(_ object: Any?) -> Bool { guard let other = object as? Entitlement else { return false @@ -294,6 +306,50 @@ public final class Entitlement: NSObject, Codable, Sendable { } } +// MARK: - Logical Identity +extension Entitlement { + /// The fields that define which entitlement this is and whether it grants + /// access. Transaction metadata like dates, product IDs, and renewal state + /// can differ between writes of the same logical status, so it's excluded. + struct Identity: Hashable { + let id: String + let type: EntitlementType + let isActive: Bool + } + + var identity: Identity { + return Identity( + id: id, + type: type, + isActive: isActive + ) + } +} + +// MARK: - Comparison +extension Entitlement { + /// Whether `other` is this entitlement with possibly different product IDs + /// β€” the shape a merge leaves behind, since it unions product IDs across a + /// shared ID regardless of which record wins. + func isEqualIgnoringProductIds(to other: Entitlement) -> Bool { + return Entitlement( + id: id, + type: type, + isActive: isActive, + productIds: other.productIds, + latestProductId: latestProductId, + store: store, + startsAt: startsAt, + renewedAt: renewedAt, + expiresAt: expiresAt, + isLifetime: isLifetime, + willRenew: willRenew, + state: state, + offerType: offerType + ) == other + } +} + // MARK: - Entitlement Merging extension Entitlement { /// Determines which entitlement should take priority when merging entitlements with the same ID. diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift new file mode 100644 index 000000000..ecacc2412 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift @@ -0,0 +1,27 @@ +// +// SubscriptionStatusChange.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-03. +// + +import Foundation + +/// What prompted a publish of ``Superwall/subscriptionStatus``. +/// +/// The three cases are the only ways the published status can change, and +/// each carries exactly what that path knows: the two assignments carry a +/// new status, a grant change carries nothing because it leaves the assigned +/// status alone and only changes what merges into it. +enum SubscriptionStatusChange { + /// The developer assigned ``Superwall/subscriptionStatus`` directly. + case developerAssignment(SubscriptionStatus) + + /// The SDK assigned a status it worked out itself β€” device + web + /// entitlements, the cold-launch restore, or test mode. + case sdkAssignment(SubscriptionStatus) + + /// ``Superwall/grantedEntitlements`` changed, so the status is republished + /// from the assigned value it already had. + case grantChange +} diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift new file mode 100644 index 000000000..d82abc06e --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -0,0 +1,350 @@ +// +// SubscriptionStatusPublishing.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-02. +// + +import Combine +import Foundation + +/// How ``Superwall/subscriptionStatus`` is produced. +/// +/// Two values are involved: +/// - The **assigned** status: what a writer handed the SDK β€” device + web +/// entitlements on the automatic path, or the developer's own value when +/// using a purchase controller. Kept in `assignedSubscriptionStatus` and +/// persisted. +/// - The **published** status: the assigned status with +/// ``Superwall/grantedEntitlements`` merged in, any test-mode override +/// applied, and an empty `.active` collapsed to `.inactive`. This is what +/// ``Superwall/subscriptionStatus`` holds. +/// +/// The assigned value is kept because the published one can't be un-merged: +/// clearing granted entitlements recomputes the published status from it. +/// +/// Every writer β€” the public setter included, via ``PublishedSubscriptionStatus`` +/// β€” ends up in `publishSubscriptionStatus`, the one critical section under +/// `subscriptionStatusLock`. The merged value is stored under the lock and +/// emitted to subscribers after it's released. Every read of either value +/// takes the same lock, so readers never race a writer. +extension Superwall { + // MARK: - Granted entitlements + + /// Entitlements granted by your own backend that Superwall can't observe, + /// which the SDK merges into ``subscriptionStatus`` alongside device and web + /// entitlements. + /// + /// Use this when access is granted outside of the App Store and the web β€” + /// for example, a promotional grant or an account comped by your backend. + /// Don't use this if you compute the full subscription picture in a + /// `PurchaseController` β€” in that case set ``subscriptionStatus`` directly, + /// otherwise two writers feed one value and the merge will surprise you. + /// + /// Each assignment replaces the previous value outright β€” granting `[a]` + /// and then `[b]` leaves only `b`. Assign an empty set to revoke. + /// + /// - Warning: This persists across app launches, ``reset()``, and + /// ``identify(userId:options:)``. You must set it again immediately after + /// calling `identify()` or `reset()` if the new user shouldn't inherit the + /// previous user's granted entitlements. + public var grantedEntitlements: Set { + get { + return entitlements.granted + } + set { + entitlements.setGranted(newValue) + Logger.debug( + logLevel: .debug, + scope: .grantedEntitlements, + message: newValue.isEmpty + ? "Granted entitlements cleared." + : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" + ) + publishSubscriptionStatus(.grantChange) + refreshAutomaticCustomerInfoAfterGrantChange() + } + } + + // MARK: - Assigning + + /// Assigns a new status and publishes the merged result. + /// + /// SDK writers use this rather than assigning ``subscriptionStatus``, which + /// is the developer's entry point and gets the developer-assignment + /// treatment described on `publishSubscriptionStatus`. + func setSubscriptionStatus(assigned status: SubscriptionStatus) { + publishSubscriptionStatus(.sdkAssignment(status)) + } + + /// Merges web entitlements into the device status and assigns the result, + /// if there's no external purchase controller. + @MainActor + func internallySetSubscriptionStatus( + to status: SubscriptionStatus, + superwall: Superwall? = nil + ) { + if dependencyContainer.makeHasExternalPurchaseController() { + return + } + let activeWebEntitlements = dependencyContainer.entitlementsInfo.web + let superwall = superwall ?? Superwall.shared + let deviceAndWebStatus: SubscriptionStatus + switch status { + case .active(let entitlements): + // Use mergePrioritized to intelligently merge device and web entitlements + // This ensures the highest priority version is kept for each entitlement ID + let combinedEntitlements = Array(entitlements) + Array(activeWebEntitlements) + let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) + deviceAndWebStatus = mergedEntitlements.isEmpty ? .inactive : .active(mergedEntitlements) + case .inactive: + deviceAndWebStatus = activeWebEntitlements.isEmpty ? .inactive : .active(activeWebEntitlements) + case .unknown: + // Neither web nor granted entitlements turn .unknown into a decision + // (see mergedSubscriptionStatus). This branch only runs without an + // external purchase controller, where the AutomaticPurchaseController + // replaces .unknown after loading purchased products, so it's always + // transient here anyway. + deviceAndWebStatus = .unknown + } + superwall.setSubscriptionStatus(assigned: deviceAndWebStatus) + } + + // MARK: - Publishing + + /// Records the assigned status, stores the merged status and persists the + /// assigned value under the lock, then emits the merged status and runs the + /// remaining side effects outside it. + /// + /// A developer assignment gets two extra treatments: records identical to a + /// current grant are stripped, because a developer who reads the published + /// status and writes it back would otherwise hand the grants back as their + /// own and clearing them could never revoke; and an `.inactive` assignment + /// while active grants exist logs a one-time warning, since the status + /// publishes as active and otherwise looks ignored. + func publishSubscriptionStatus(_ change: SubscriptionStatusChange) { + subscriptionStatusLock.lock() + + // Snapshot once, under the lock, so a concurrent grant write can't slip + // between the read and the store. Active grants are merged here so two + // grants sharing an ID collapse to one record on every path. + let granted = entitlements.granted + let activeGrants = Entitlement.mergePrioritized(Array(granted.filter(\.isActive))) + + var assigned: SubscriptionStatus + let isDeveloperAssignment: Bool + switch change { + case .developerAssignment(let status): + assigned = status + isDeveloperAssignment = true + case .sdkAssignment(let status): + assigned = status + isDeveloperAssignment = false + case .grantChange: + // The assigned status is unchanged; only the grants merged into it are. + assigned = assignedSubscriptionStatus + isDeveloperAssignment = false + } + + if isDeveloperAssignment { + if case .active(let assignedEntitlements) = assigned { + // Compared ignoring product IDs: the merge unions those across a + // shared ID, so a grant that won a collision comes back carrying the + // loser's product IDs. + assigned = .active( + assignedEntitlements.filter { record in + !granted.contains { $0.isEqualIgnoringProductIds(to: record) } + } + ) + } + if case .inactive = assigned, + !activeGrants.isEmpty, + !hasLoggedGrantedEntitlementsWarning { + hasLoggedGrantedEntitlementsWarning = true + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "subscriptionStatus was set to .inactive but grantedEntitlements " + + "is not empty, so the status remains active. Assigning subscriptionStatus " + + "doesn't clear granted entitlements β€” set grantedEntitlements to an empty set to revoke them." + ) + } + } + + let previouslyAssigned = assignedSubscriptionStatus + assignedSubscriptionStatus = assigned + + let merged = mergedSubscriptionStatus(assigned: assigned, activeGrants: activeGrants) + let statusChanged = merged != subscriptionStatus + if statusChanged { + storeMergedSubscriptionStatus(merged) + } + + // Persist the assigned value, not the merged one: restoring a merged + // value would bake granted entitlements into the assigned status for + // good, making them impossible to clear after a relaunch. + // + // Saved here rather than in the status listener so that metadata-only + // updates, which the listener dedupes, still refresh the cache. Identical + // writes are skipped so they don't re-encode and rewrite the file. + if previouslyAssigned != assigned { + dependencyContainer.storage.save(assigned, forType: SubscriptionStatusKey.self) + } + // Hands the merged value to the entitlements queue while the lock is + // still held, so two publishes reach it in the order they were stored. + // It only enqueues, so nothing waits on it. + entitlements.subscriptionStatusDidSet(merged) + subscriptionStatusLock.unlock() + + // Subscribers and customer info observers run outside the lock, so a + // subscriber that blocks on another thread can't deadlock a writer. + if statusChanged { + emitSubscriptionStatus() + } + + // When using an external purchase controller, update CustomerInfo.entitlements + // to reflect the entitlements from the purchase controller. + // Skip this in test mode β€” test mode manages its own CustomerInfo. + if dependencyContainer.makeHasExternalPurchaseController(), + dependencyContainer.testModeManager?.isTestMode != true { + refreshExternalControllerCustomerInfo() + } + } + + /// A consistent pair of the published status and the granted set, read + /// under the lock. + private struct PublishedSnapshot: Equatable { + let status: SubscriptionStatus + let granted: Set + } + + private func publishedSnapshot() -> PublishedSnapshot { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } + return PublishedSnapshot(status: subscriptionStatus, granted: entitlements.granted) + } + + /// Recomputes `customerInfo` for the external purchase controller path + /// outside the lock, so `$customerInfo` subscribers don't run inside the + /// SDK's critical section. + /// + /// It's built from a consistent snapshot and dropped if a concurrent + /// publish moved either input while it ran: that publish runs its own + /// refresh with the newer values, so assigning ours would put the older + /// customer info last. Uncontended β€” the normal case, and the only one a + /// synchronous read after a status write depends on β€” the snapshot is + /// unchanged and the value is assigned before this returns. + private func refreshExternalControllerCustomerInfo() { + let snapshot = publishedSnapshot() + let info = CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: snapshot.status, + granted: snapshot.granted + ) + if publishedSnapshot() == snapshot { + customerInfo = info + } + } + + /// The status the SDK reports for `assigned`: active granted entitlements + /// merged in, any test-mode override applied, and an empty `.active` + /// collapsed to `.inactive`. + private func mergedSubscriptionStatus( + assigned: SubscriptionStatus, + activeGrants: Set + ) -> SubscriptionStatus { + if let testModeManager = dependencyContainer.testModeManager, + testModeManager.isTestMode, + let override = testModeManager.overriddenSubscriptionStatus { + return override + } + var status = assigned + // Only active grants merge into the status, mirroring how web + // entitlements merge (EntitlementsInfo.web filters to active). + // Inactive grants still reach customerInfo for round-trip visibility. + if !activeGrants.isEmpty { + switch status { + case .active(let entitlements): + // Set.union merges by priority, keeping one record per ID. + status = .active(entitlements.union(activeGrants)) + case .inactive: + status = .active(activeGrants) + case .unknown: + // Left alone, exactly like web entitlements: .unknown means the + // status hasn't been determined yet, and grants don't determine it + // β€” they merge as soon as it is. Reporting active here would open + // the paywall presentation gate, which waits for a status other + // than .unknown, before a purchase controller has answered. + break + } + } + // This must run after the granted merge, or a developer-assigned + // .active([]) would collapse to .inactive before granted is applied. + if case .active(let entitlements) = status, + entitlements.isEmpty { + return .inactive + } + return status + } + + /// Recomputes `customerInfo` on the automatic path after a grant change, + /// rebuilding from the stored device and web sources so cleared grants + /// actually disappear. The external purchase controller path is recomputed + /// inside `publishSubscriptionStatus`; test mode manages its own customer + /// info. + private func refreshAutomaticCustomerInfoAfterGrantChange() { + if dependencyContainer.makeHasExternalPurchaseController() { + return + } + if dependencyContainer.testModeManager?.isTestMode == true { + return + } + let storage: Storage = dependencyContainer.storage + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() + customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: entitlements.granted) + } + + // MARK: - Listening + + func listenToSubscriptionStatus() { + $subscriptionStatus + .removeDuplicates { $0.isLogicallyEqual(to: $1) } + .dropFirst() + .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in + // Shift the current value to previous, and set the new status as the current value + (previous: previousPair.current, current: newStatus) + } + .receive(on: DispatchQueue.main) + .subscribe( + Subscribers.Sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] statusPair in + guard let self = self else { + return + } + let oldStatus = statusPair.previous + let newStatus = statusPair.current + + Task { + await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( + from: oldStatus, to: newStatus) + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: newStatus, + grantedEntitlements: self.grantedEntitlements + ) + await self.track(event) + } + Task { + let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() + let deviceAttributesPlacement = InternalSuperwallEvent.DeviceAttributes( + deviceAttributes: deviceAttributes) + await self.track(deviceAttributesPlacement) + } + } + ) + ) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index cf6b92ad4..56ad7573f 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -20,7 +20,10 @@ final class AutomaticPurchaseController { self.entitlementsInfo = entitlementsInfo } - func syncSubscriptionStatus(withPurchases purchases: Set) async { + func syncSubscriptionStatus( + withPurchases purchases: Set, + superwall: Superwall? = nil + ) async { let activePurchases = purchases.filter { $0.isActive } var entitlements: Set = [] @@ -29,11 +32,98 @@ final class AutomaticPurchaseController { entitlements = entitlements.union(purchaseEntitlements) } + let activeProductIds = Set(activePurchases.map { $0.id }) + await MainActor.run { [entitlements] in + let superwall = superwall ?? Superwall.shared if entitlements.isEmpty { - Superwall.shared.internallySetSubscriptionStatus(to: .inactive) + // A device read with no entitlements can mean different things, + // and only one of them may demote a subscriber: + // + // - Purchases exist but none is active: an authoritative answer. + // Refunded and expired transactions stay in the set as inactive + // (SK2 reads `Transaction.all`; the SK1 receipt keeps cancelled + // purchases), so this downgrades immediately. + // - The purchases set is completely empty: a non-answer. StoreKit + // returns nothing at cold launch before it hydrates, the SK1 + // receipt can be missing, and web/Stripe subscribers have no App + // Store purchases at all. + // - A purchase is still active but maps to no entitlement: a + // mapping failure (the config no longer knows the product), not + // an authoritative answer for the entitlement it unlocks. + // + // On a non-answer, keep an `.active` status while one of its + // entitlements is within its expiry date. A device read also has no + // authority over entitlements not granted by the App Store, so those + // hold the status even when unrelated inactive purchases exist. A + // nil store means no App Store transaction unlocks the entitlement + // (web or manual grant β€” both receipt managers stamp `.appStore` on + // entitlements a receipt transaction unlocks, so active + // device-derived entitlements always carry it), so nil is protected + // too. Entitlements with no expiry date never hold the status, so a + // revoked lifetime purchase can still deactivate here. + // + // The check reads the assigned status (device + web), not the + // published one: that also carries developer-granted entitlements, + // which would hold a lapsed App Store entitlement in place. + // + // Two separate questions. Whether an entitlement holds the status up + // needs an expiry the read can be bounded by. Whether it stays in + // the status needs two things: the read had no authority over it or + // confirmed it, and its own expiry hasn't passed. A lifetime App + // Store unlock has no expiry, so it can't hold, but an empty read + // said nothing about it either, so it stays while another + // entitlement holds. A refunded subscription next to a live web one + // is dropped because the read refuted it, and an App Store + // subscription whose cached expiry is already behind us is dropped + // because the clock did β€” time passing needs no read to confirm it. + // + // The clock only settles App Store and nil-store records here. Web + // entitlements are merged back in from the redeem cache by + // `internallySetSubscriptionStatus`, which is authoritative for + // them, so a lapsed web record comes straight back until the web + // poll says otherwise. + if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { + func isLapsed(_ entitlement: Entitlement) -> Bool { + guard let expiresAt = entitlement.expiresAt else { + return false + } + return expiresAt <= Date() + } + func isRefuted(_ entitlement: Entitlement) -> Bool { + if purchases.isEmpty || entitlement.store != .appStore { + return false + } + // A still-active purchase that unlocks this entitlement means + // the empty entitlement set is a mapping failure. With the + // mapping missing, SK2's subscription-level correction of + // `Purchase.isActive` is disabled too, so this is the raw + // transaction-level value and can miss a revocation that sets + // no `revocationDate`. The hold is still bounded by the expiry + // gate below, which beats locking out a paying subscriber over + // a lost product mapping. + return !entitlement.productIds.contains { activeProductIds.contains($0) } + } + let holdsStatus = currentEntitlements.contains { entitlement in + entitlement.isActive + && entitlement.expiresAt != nil + && !isLapsed(entitlement) + && !isRefuted(entitlement) + } + if holdsStatus { + let survivors = currentEntitlements.filter { !isRefuted($0) && !isLapsed($0) } + if survivors != currentEntitlements { + superwall.internallySetSubscriptionStatus( + to: .active(survivors), + superwall: superwall + ) + } + return + } + } + superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) } else { - Superwall.shared.internallySetSubscriptionStatus(to: .active(entitlements)) + superwall.internallySetSubscriptionStatus(to: .active(entitlements), superwall: superwall) } } } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 4ee53ed3e..33e7b22fc 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -26,6 +26,8 @@ final class TransactionManager { & HasExternalPurchaseControllerFactory & RestoreAccessFactory & TestModeManagerFactory + & ReceiptFactory + & ConfigManagerFactory enum State { case observing case purchasing(PurchaseSource) @@ -731,7 +733,12 @@ final class TransactionManager { return await isCustomProductFreeTrialAvailable(for: product) } - return await receiptManager.isFreeTrialAvailable(for: product) + // Same wait as `DependencyContainer.isFreeTrialAvailable`: the buy button + // can be tapped while the first purchases load is still running. + return await receiptManager.isFreeTrialAvailable( + for: product, + waitingFor: factory.initialPurchasesLoad + ) } /// Custom products don't have StoreKit intro-offer state, so use entitlement history @@ -745,6 +752,19 @@ final class TransactionManager { return false } + // Same reason as the App Store branch above: config can be published before + // the purchases load finishes, and until it does `customerInfo` is the copy + // restored from disk. The `isPlaceholder` check below can't stand in for + // this β€” the early publish only happens when a non-blank copy is saved, + // which is exactly when `isPlaceholder` is false. + // + // Waiting on the same rule the paywall used, so the trial it advertised + // and the transaction it produces are decided from the same state. + let productEntitlementIds = Set(product.entitlements.map(\.id)) + if factory.purchasesLoadCouldChange(entitlementIds: productEntitlementIds) { + await factory.initialPurchasesLoad?.value + } + let customerInfo = await MainActor.run { Superwall.shared.customerInfo } @@ -755,7 +775,6 @@ final class TransactionManager { return false } - let productEntitlementIds = Set(product.entitlements.map(\.id)) let userEntitlementIds = Set( customerInfo.entitlements .filter { $0.latestProductId != nil || $0.store == .superwall || $0.isActive } @@ -1109,13 +1128,22 @@ final class TransactionManager { let paywallInfo: PaywallInfo let eventSource: InternalSuperwallEvent.Transaction.Source - let trialEndDate = product.trialPeriodEndDate + // The product's intro offer is visible even when the user isn't eligible for it, so only + // report a trial (and its end date) to the paywall when this transaction started one. + let didStartFreeTrial = type == .freeTrialStart + let trialEndDate = didStartFreeTrial ? product.trialPeriodEndDate : nil switch source { case .internal(_, let paywallViewController, _): paywallInfo = await paywallViewController.info eventSource = .internal await paywallViewController.webView.messageHandler - .handle(.transactionComplete(trialEndDate: trialEndDate, productIdentifier: product.productIdentifier)) + .handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: product.productIdentifier, + didStartFreeTrial: didStartFreeTrial + ) + ) case .purchaseFunc, .observeFunc: paywallInfo = .empty() diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6148926d6..08d4f46bc 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -206,31 +206,106 @@ public final class Superwall: NSObject, ObservableObject { /// `PurchaseController`, you must set this. /// /// If you're using Combine or SwiftUI, you can subscribe or bind to it to get - /// notified whenever it changes. + /// notified whenever it changes. The publisher only ever emits the merged + /// status – never a value you assigned before granted entitlements were + /// applied. Changes are delivered in the order they were made. Subscribers + /// are usually called on the thread that changed the status, which is often + /// a background one; when several threads change it at once, one of them + /// delivers the others' changes too, and a slow subscriber holds those up. + /// Use `receive(on:)` before updating UI, and don't block in a subscriber. /// /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` - /// to receive a callback every time it changes. - @Published - public var subscriptionStatus: SubscriptionStatus = .unknown { - didSet { - let resolved = resolvedSubscriptionStatus(subscriptionStatus) - if resolved != subscriptionStatus { - subscriptionStatus = resolved - return + /// to receive a callback whenever the logical status changes. + /// + /// - Warning: If you've set ``grantedEntitlements``, they are merged into + /// every value you assign here. Assigning `.inactive` does **not** remove + /// them β€” the status stays active for as long as ``grantedEntitlements`` + /// contains an active entitlement. To revoke them, set + /// ``grantedEntitlements`` to an empty set. + @PublishedSubscriptionStatus + public var subscriptionStatus: SubscriptionStatus = .unknown + + // MARK: - Subscription status state + + /// The status last handed to the SDK β€” device + web entitlements on the + /// automatic path, or the developer's value with a purchase controller β€” + /// before granted entitlements and test-mode overrides are merged in. + /// ``subscriptionStatus`` is always derived from this, never the reverse, + /// so clearing granted entitlements can recompute it. + /// + /// Read and written under `subscriptionStatusLock`, like ``subscriptionStatus``. + var assignedSubscriptionStatus: SubscriptionStatus { + get { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() } - entitlements.subscriptionStatusDidSet(subscriptionStatus) - - // When using an external purchase controller, update CustomerInfo.entitlements - // to reflect the entitlements from the purchase controller. - // Skip this in test mode β€” test mode manages its own CustomerInfo. - if dependencyContainer.makeHasExternalPurchaseController(), - dependencyContainer.testModeManager?.isTestMode != true { - customerInfo = CustomerInfo.forExternalPurchaseController( - storage: dependencyContainer.storage, - subscriptionStatus: subscriptionStatus - ) + return lockedAssignedSubscriptionStatus + } + set { + subscriptionStatusLock.lock() + lockedAssignedSubscriptionStatus = newValue + subscriptionStatusLock.unlock() + } + } + private var lockedAssignedSubscriptionStatus: SubscriptionStatus = .unknown + + /// Serializes every read and write of the status across threads. Recursive so + /// a subscriber that assigns the status from within an emission re-enters + /// the pipeline instead of deadlocking. + let subscriptionStatusLock = NSRecursiveLock() + + /// Merged statuses that have been stored but not yet emitted, oldest first. + /// Guarded by `subscriptionStatusLock`. + private var pendingSubscriptionStatusEmissions: [SubscriptionStatus] = [] + + /// Whether a thread is currently emitting the pending statuses. Guarded by + /// `subscriptionStatusLock`. + private var isEmittingSubscriptionStatus = false + + /// Whether the one-time warning about granted entitlements overriding an + /// `.inactive` assignment has been logged. + var hasLoggedGrantedEntitlementsWarning = false + + /// Stores the merged status and queues it for emission. Only + /// `publishSubscriptionStatus` calls this, with `subscriptionStatusLock` + /// held; `emitSubscriptionStatus()` sends it once the lock is released. + func storeMergedSubscriptionStatus(_ merged: SubscriptionStatus) { + _subscriptionStatus.store(merged) + pendingSubscriptionStatusEmissions.append(merged) + } + + /// Emits every stored status to `$subscriptionStatus` subscribers in the + /// order it was stored, with the lock released while subscribers run. + /// + /// One thread drains the queue at a time. A writer that stores while a + /// drain is in progress returns straight away and the draining thread + /// sends its value next, so each value is emitted once, none is emitted + /// behind a newer one, and no writer ever waits on a subscriber. That last + /// point is what stops a subscriber that blocks on another thread from + /// deadlocking a writer on that thread. + func emitSubscriptionStatus() { + subscriptionStatusLock.lock() + if isEmittingSubscriptionStatus { + subscriptionStatusLock.unlock() + return + } + isEmittingSubscriptionStatus = true + subscriptionStatusLock.unlock() + + while true { + subscriptionStatusLock.lock() + if pendingSubscriptionStatusEmissions.isEmpty { + isEmittingSubscriptionStatus = false + subscriptionStatusLock.unlock() + return } + let next = pendingSubscriptionStatusEmissions.removeFirst() + subscriptionStatusLock.unlock() + + objectWillChange.send() + _subscriptionStatus.emit(next) } } @@ -279,40 +354,6 @@ public final class Superwall: NSObject, ObservableObject { return await dependencyContainer.deviceHelper.getTemplateDevice() } - /// Gets web entitlements and merges them with device entitlements before - /// setting the status if no external purchase controller. - @MainActor - func internallySetSubscriptionStatus( - to status: SubscriptionStatus, - superwall: Superwall? = nil - ) { - if dependencyContainer.makeHasExternalPurchaseController() { - return - } - let activeWebEntitlements = dependencyContainer.entitlementsInfo.web - let superwall = superwall ?? Superwall.shared - switch status { - case .active(let entitlements): - // Use mergePrioritized to intelligently merge device and web entitlements - // This ensures the highest priority version is kept for each entitlement ID - let combinedEntitlements = Array(entitlements) + Array(activeWebEntitlements) - let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) - if mergedEntitlements.isEmpty { - superwall.subscriptionStatus = .inactive - } else { - superwall.subscriptionStatus = .active(mergedEntitlements) - } - case .inactive: - if activeWebEntitlements.isEmpty { - superwall.subscriptionStatus = .inactive - } else { - superwall.subscriptionStatus = .active(activeWebEntitlements) - } - case .unknown: - superwall.subscriptionStatus = .unknown - } - } - /// Returns the subscription status of the user. /// /// Check the delegate function @@ -389,8 +430,11 @@ public final class Superwall: NSObject, ObservableObject { /// Handles all dependencies. let dependencyContainer: DependencyContainer - /// Used to serially execute register calls. - var previousRegisterTask: Task? + /// Runs register calls one at a time, in the order they came in. + /// + /// `register(placement:)` can be called from any thread, so the queue of + /// register tasks has to be safe to add to from any thread. + let registerTaskCoordinator = SerialTaskCoordinator() /// The integration attributes to send to the server when `appTransactionId` /// is available. Protected by a queue for thread safety. @@ -419,23 +463,25 @@ public final class Superwall: NSObject, ObservableObject { } } - // MARK: - Value Resolution - - private func resolvedSubscriptionStatus( - _ status: SubscriptionStatus - ) -> SubscriptionStatus { - if let testModeManager = dependencyContainer.testModeManager, - testModeManager.isTestMode, - let override = testModeManager.overriddenSubscriptionStatus { - return override - } - if case .active(let entitlements) = status, - entitlements.isEmpty { - return .inactive + /// Drops the attributes waiting on the app transaction id that identify the + /// person rather than the device. + /// + /// They were set for the user that `reset()` just replaced, so replaying them + /// when the id arrives would hand someone else's identity to the new user β€” + /// the same reason `AttributionFetcher.resetIntegrationAttributes()` drops + /// them from the attributes it already holds. + func resetEnqueuedIntegrationAttributes() { + enqueuedAttributesQueue.sync { + guard let enqueued = _enqueuedIntegrationAttributes else { + return + } + let kept = enqueued.filter { $0.key.isInstallScoped } + _enqueuedIntegrationAttributes = kept.isEmpty ? nil : kept } - return status } + // MARK: - Value Resolution + private func resolvedCustomerInfo( _ info: CustomerInfo ) -> CustomerInfo { @@ -468,11 +514,16 @@ public final class Superwall: NSObject, ObservableObject { customerInfo = dependencyContainer.storage.get(LatestCustomerInfo.self) ?? .blank() - subscriptionStatus = dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + setSubscriptionStatus(assigned: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown) dependencyContainer.entitlementsInfo.subscriptionStatusDidSet(subscriptionStatus) addListeners() + // Recover any Stripe checkout that was pending when the app was killed. + // Kicked off here rather than inside an initializer so the redeemer's + // background poll can only ever see a fully-built dependency container. + dependencyContainer.webEntitlementRedeemer.pollPendingStripeCheckoutOnColdLaunch() + // This task runs on a background thread, even if called from a main thread. // This is because the function isn't marked to run on the main thread, // therefore, we don't need to make this detached. @@ -561,44 +612,6 @@ public final class Superwall: NSObject, ObservableObject { )) } - private func listenToSubscriptionStatus() { - $subscriptionStatus - .removeDuplicates() - .dropFirst() - .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in - // Shift the current value to previous, and set the new status as the current value - (previous: previousPair.current, current: newStatus) - } - .receive(on: DispatchQueue.main) - .subscribe( - Subscribers.Sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] statusPair in - guard let self = self else { - return - } - let oldStatus = statusPair.previous - let newStatus = statusPair.current - - self.dependencyContainer.storage.save(newStatus, forType: SubscriptionStatusKey.self) - - Task { - await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( - from: oldStatus, to: newStatus) - let event = InternalSuperwallEvent.SubscriptionStatusDidChange(status: newStatus) - await self.track(event) - } - Task { - let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() - let deviceAttributesPlacement = InternalSuperwallEvent.DeviceAttributes( - deviceAttributes: deviceAttributes) - await self.track(deviceAttributesPlacement) - } - } - ) - ) - } - private func listenToCustomerInfo() { $customerInfo .removeDuplicates() @@ -971,7 +984,10 @@ public final class Superwall: NSObject, ObservableObject { /// - Parameter props: A dictionary keyed by ``IntegrationAttribute`` specifying /// properties to associate with the user or events for the given provider. public func setIntegrationAttributes(_ props: [IntegrationAttribute: String?]) { - guard let appTransactionId = ReceiptManager.appTransactionId else { + // The fetcher doesn't take the app transaction id, but it still has to wait + // for one: setting attributes debounces a redeem, and the redeemer reads the + // id for itself. + guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes(props) return @@ -982,10 +998,7 @@ public final class Superwall: NSObject, ObservableObject { result[pair.key.description] = pair.value } - dependencyContainer.attributionFetcher.mergeIntegrationAttributes( - attributes: props, - appTransactionId: appTransactionId - ) + dependencyContainer.attributionFetcher.mergeIntegrationAttributes(attributes: props) setUserAttributes(props) } @@ -995,7 +1008,9 @@ public final class Superwall: NSObject, ObservableObject { /// - attribute: The ``IntegrationAttribute`` key specifying the integration provider. /// - value: The value to associate with the attribute. Pass `nil` to remove the attribute. public func setIntegrationAttribute(_ attribute: IntegrationAttribute, _ value: String?) { - guard let appTransactionId = ReceiptManager.appTransactionId else { + // Waits for the app transaction id for the same reason as + // `setIntegrationAttributes(_:)` above. + guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes([attribute: value]) return @@ -1004,8 +1019,7 @@ public final class Superwall: NSObject, ObservableObject { dependencyContainer.attributionFetcher.setIntegrationAttribute( attribute: attribute, - value: value, - appTransactionId: appTransactionId + value: value ) setUserAttributes([attribute.description: value]) } @@ -1088,12 +1102,30 @@ public final class Superwall: NSObject, ObservableObject { // MARK: - Reset /// Resets the `userId`, on-device paywall assignments, and data stored /// by Superwall. + /// + /// - Note: ``grantedEntitlements`` are not reset β€” you own that bucket and + /// its lifecycle. Set it again immediately after calling this if the next + /// user shouldn't inherit the previous user's granted entitlements. public func reset() { reset(duringIdentify: false) } /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. + /// + /// When `duringIdentify` is true this runs on the identity manager's queue, + /// so nothing it calls may wait on that queue, such as reading + /// `identityManager.userAttributes`, or the queue hangs for good. func reset(duringIdentify: Bool) { + // Warn here rather than in the public reset() so the identify-triggered + // reset β€” the actual user-switch moment β€” warns too. + if !grantedEntitlements.isEmpty { + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "The user was reset but grantedEntitlements persist across reset() " + + "and identify(). Set grantedEntitlements again if the new user shouldn't inherit them." + ) + } dependencyContainer.identityManager.reset(duringIdentify: duringIdentify) // Cancel any in-flight attribution post before wiping its storage, so a // late-completing post can't race the new user's state. @@ -1111,6 +1143,12 @@ public final class Superwall: NSObject, ObservableObject { // logout after that would otherwise leave the new user without attributes. dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() + // Integration attributes are part install-scoped, part tied to the person + // signing out, and the reset just wiped them out of the user's attributes + // and off disk. Keep the install-scoped half for the new user, drop the rest. + dependencyContainer.attributionFetcher.resetIntegrationAttributes() + resetEnqueuedIntegrationAttributes() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() Task { diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 9f5db3044..ab59ab08c 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -109,6 +109,11 @@ final class TestModeManager { /// Evaluates whether the current user should be in test mode based on the config /// and the `testModeBehavior` option. Called on every config refresh. func evaluateTestMode(config: Config, options: SuperwallOptions) { + if DevMode.isActive(options) { + isTestMode = true + testModeReason = .testModeOption + return + } switch options.testModeBehavior { case .never: isTestMode = false diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 8ca275948..b422adec9 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -78,10 +78,10 @@ final class TestModeTransactionHandler { if hasActiveEntitlements { let status = SubscriptionStatus.active(entitlementSet) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(assigned: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } // Track free trial start if free trial is shown (respecting override) @@ -147,7 +147,7 @@ final class TestModeTransactionHandler { testModeManager.overriddenCustomerInfo = customerInfo Superwall.shared.customerInfo = customerInfo testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } else { // Update test mode manager with selected entitlement IDs let activeIds = Set(entitlements.map { $0.id }) @@ -164,10 +164,10 @@ final class TestModeTransactionHandler { if hasActive { let status = SubscriptionStatus.active(entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(assigned: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 2ee581b25..c101f00df 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -112,12 +112,17 @@ actor WebEntitlementRedeemer { name: UIApplication.willEnterForegroundNotification, object: nil ) + } - // Also check once on SDK initialization so pending Stripe checkouts can be - // recovered on cold launch. Guard on factory readiness to avoid accessing - // dependencies (e.g. deviceHelper) before the container is fully set up. + /// Checks once on SDK initialization so pending Stripe checkouts can be + /// recovered on cold launch. + /// + /// Called by `Superwall` after the dependency container is fully built, + /// rather than from this actor's own `init`: the poll reads container state + /// from a background task, so it must not start while the container is + /// still being set up. + nonisolated func pollPendingStripeCheckoutOnColdLaunch() { Task { - guard factory.makeIsContainerReady() else { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() } } @@ -443,7 +448,7 @@ actor WebEntitlementRedeemer { storage.save(response, forType: LatestRedeemResponse.self) - _ = await mergeAndApplyCustomerInfo( + await mergeAndApplyCustomerInfo( webCustomerInfo: response.customerInfo, superwall: superwall ) @@ -511,24 +516,26 @@ actor WebEntitlementRedeemer { private func mergeAndApplyCustomerInfo( webCustomerInfo: CustomerInfo, superwall: Superwall - ) async -> CustomerInfo { + ) async { let mergedCustomerInfo: CustomerInfo if factory.makeHasExternalPurchaseController() { let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } mergedCustomerInfo = CustomerInfo.forExternalPurchaseController( storage: storage, - subscriptionStatus: subscriptionStatus + subscriptionStatus: subscriptionStatus, + granted: entitlementsInfo.granted ) } else { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - mergedCustomerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) + mergedCustomerInfo = deviceCustomerInfo.merging( + with: webCustomerInfo, + granting: entitlementsInfo.granted + ) } await MainActor.run { superwall.customerInfo = mergedCustomerInfo } - - return mergedCustomerInfo } private func updateSubscriptionStatus( @@ -920,6 +927,31 @@ actor WebEntitlementRedeemer { deviceId: factory.makeDeviceId() ) + // A response with zero entitlements must not replace cached web + // entitlements that are still within their expiry date. The server + // reports a revocation by returning the entitlement as inactive β€” + // and it enumerates every config-mapped entitlement even for users + // with no purchases β€” so a fully empty array is a backend or config + // artifact (alias mismatch, failed upstream lookup), not a + // revocation. Real revocations arrive non-empty and apply + // immediately through the save below. If an empty response replaced + // the cache, the next cold launch would read the user as inactive + // until a network poll recovered them. Entitlements with no expiry + // date are not protected by this guard. We skip saving the fetch + // date so the next poll retries without waiting out + // `entitlementsMaxAge`. + let hasUnexpiredWebEntitlements = existingWebEntitlements.contains { + $0.isActive && ($0.expiresAt ?? .distantPast) > Date() + } + if response.customerInfo.entitlements.isEmpty && hasUnexpiredWebEntitlements { + Logger.debug( + logLevel: .warn, + scope: .webEntitlements, + message: "Ignoring empty web entitlements response because unexpired web entitlements are cached." + ) + return + } + // Update the latest redeem response with the entitlements and customer info from the response. if var latestRedeemResponse = storage.get(LatestRedeemResponse.self) { latestRedeemResponse.customerInfo = response.customerInfo @@ -944,20 +976,22 @@ actor WebEntitlementRedeemer { return } - let mergedCustomerInfo = await mergeAndApplyCustomerInfo( + await mergeAndApplyCustomerInfo( webCustomerInfo: response.customerInfo, superwall: superwall ) - let activeEntitlements = Set(mergedCustomerInfo.entitlements.filter { $0.isActive }) - if activeEntitlements.isEmpty { - await superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) - } else { - await superwall.internallySetSubscriptionStatus( - to: .active(activeEntitlements), - superwall: superwall - ) - } + // Derive the status from device + web only, as the redeem path does. + // The merged customer info also carries granted entitlements, which + // are applied when the status publishes and must never enter the + // assigned status β€” or clearing them could never revoke them. + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + let activeDeviceEntitlements = Set(deviceCustomerInfo.entitlements.filter { $0.isActive }) + let deviceAndWebEntitlements = activeDeviceEntitlements.union( + Set(response.customerInfo.entitlements) + ) + let activeEntitlements = deviceAndWebEntitlements.filter { $0.isActive } + await updateSubscriptionStatus(with: deviceAndWebEntitlements, superwall: superwall) // If there's a paywall, check if we should dismiss it if let paywallVc = superwall.paywallViewController { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 7e88ffd14..39380da9e 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.3" + s.version = "4.17.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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 16aca1f44..09676e078 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -20,10 +20,12 @@ 03EBC531CDC26957534DE46A /* PurchaseResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96237542E710511C51A39070 /* PurchaseResult.swift */; }; 061A6342D61F14BD286F202C /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCAE23C483138D33A1CF8889 /* ProductsFetcherSK1.swift */; }; 0661E89598C9FF5C1B8B9B13 /* String+SHA256.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */; }; + 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */; }; 070DFAAB357CE1D547E946E1 /* PurchaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BE8AD685B39ACAB331109C /* PurchaseManager.swift */; }; 074EBBBF7E3B2B207B00A275 /* SWWebViewLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */; }; 07862D18809FA5DEA95AE440 /* NSManagedObjectContext+mergeChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */; }; 07CAD1B0A849A7593B1EF6D4 /* FakeContactsAuthorizationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */; }; + 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */; }; 0911B1213899E3C4E1620119 /* Dictionary+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9E1A8F57B5DCA4CD16296F /* Dictionary+Keys.swift */; }; 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */; }; 09AA071B4A67A6256D00BDD6 /* PaywallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187934D3C89220C605299A17 /* PaywallManager.swift */; }; @@ -63,11 +65,13 @@ 18D39CB7BCF324B44197735D /* TaskRetryingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */; }; 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */; }; 1A6C6E6DAD8C0236FA24FF10 /* CheckNoPaywallAlreadyPresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582CE0C5BA6EA57C7FE3EE43 /* CheckNoPaywallAlreadyPresented.swift */; }; + 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */; }; 1BA9EC022F0016E72A5EB01A /* PaywallPresentationRequestStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC211BFF9364E4B10418695B /* PaywallPresentationRequestStatus.swift */; }; 1BDB91B70EAEC10097941381 /* SWBounceButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 299F91895EE88281B5ED8320 /* SWBounceButton.swift */; }; 1E0D00DF75A6779C78145750 /* SurveyPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B66B5A624F8A2E225EACA69 /* SurveyPresentationResult.swift */; }; 1E7EBE0C39AC302D9B5BFF27 /* PublicGameController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F0F8FCE0A86D8F2823A47 /* PublicGameController.swift */; }; 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B78FB9232236AF44369EA92 /* AppSessionManagerMock.swift */; }; + 1EA8A326074CB46980BA7C9C /* Encoder+ReportsUnknownFieldsAsNull.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */; }; 1F20D775BC035F8A75B79323 /* PaywallMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF61DCA9CF170E0AFC507BAE /* PaywallMessageHandler.swift */; }; 1F9AE04458B942D070FC4832 /* FakeTrackingAuthorizationStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FE43B98D847BB6DE291F0B4 /* FakeTrackingAuthorizationStatusTests.swift */; }; 1FB66F276E4FD5AEC37EC14A /* ContactStoreProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */; }; @@ -80,6 +84,7 @@ 213E7FC262106BFBC44462AC /* SWLocalizationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */; }; 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */; }; 2231B31B4B9A25778069B20A /* PaddleProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */; }; + 223E12D4EDBF41D8F5515E53 /* SerialTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */; }; 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339E7D08CDD7B1808AD4069 /* RedeemResponseTests.swift */; }; 22B3763157DF592D4E3B27A0 /* InAppReceipt+ASN1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80C81546752BCF32016A27 /* InAppReceipt+ASN1.swift */; }; 234C1753A4606242CA765CA7 /* ManagedTriggerRuleOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */; }; @@ -113,6 +118,7 @@ 2EC1D279019CD3FB64E4674A /* TriggerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25515131DF0AE67E26BFF462 /* TriggerResult.swift */; }; 2F33D9FC5A40496D6922CEBB /* IARError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE7B1045C0541E66A965FC1 /* IARError.swift */; }; 2F54D64CED54E63F0E7B8711 /* AudioSessionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */; }; + 2F8329A05DF5BFF975FFA8B7 /* ReceiptRefreshDelegateWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */; }; 3002A50E92B640B4E3A98662 /* SuperwallKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 04FB15C76DE3D22CB370AFDB /* SuperwallKit.framework */; }; 30113C71D033ADDF01214C75 /* PreloadingDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */; }; 309EC3675C7EF75050B076E7 /* ComputedPropertyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.swift */; }; @@ -120,9 +126,11 @@ 31E937EB414F62268F6C953C /* TestModeInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3548435BDE49161E3BDFA358 /* TestModeInfoCell.swift */; }; 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1E17A7907C42F27817C958 /* NonSubscriptionTransaction.swift */; }; 3313CD30A969731960FC32BF /* PaywallOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1439A212719AA2EA8BEA357 /* PaywallOverrides.swift */; }; + 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */; }; 339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; }; 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; + 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E37562E243AE6632134D94A /* DevServerSurface.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; @@ -141,7 +149,9 @@ 3CB65E105ADED11AEE69DEAF /* InAppReceiptAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9553EC1E394EF7AE8788291 /* InAppReceiptAttribute.swift */; }; 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */; }; 3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; }; + 3D5684BAF13C86ABED458EB9 /* SerialTaskCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */; }; 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; }; + 3E4694244060F6951B127401 /* PaywallSharedControllerAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */; }; 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; }; 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; }; @@ -156,6 +166,7 @@ 42B707D898A49DCB2B33837C /* CustomCallbackRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */; }; 42FDAFD5AB3EC83713941E32 /* SK2ReceiptManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */; }; 432FB2AE172725B4ED208416 /* DebugManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */; }; + 43555DB889239C0F1FB73AE1 /* ReceiptManager+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */; }; 43EF1A9F46DECE0EECFD0368 /* HiddenListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */; }; 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */; }; 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E439B70BB6190AFF6DDB81F2 /* CustomCallback.swift */; }; @@ -167,9 +178,11 @@ 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; }; 4AA4E2CE223DC7CF1678E83C /* TrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E23B703C00044332FDEBE8 /* TrackTests.swift */; }; 4AB907436D4A84932F09D8B3 /* String+MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449672964023589DA5535E3 /* String+MD5.swift */; }; + 4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */; }; 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */; }; 4B4BCB32699C3A1AF7E2BFE6 /* SK2StoreProductCyclesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */; }; 4B54BA9E52A97C486D808A05 /* IntroOfferToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */; }; + 4B5B618A7FAECA65EF29F2C2 /* IdentifyUserSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */; }; 4D37588A69A4C770C86FC585 /* RedeemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */; }; 4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00C04BE1449BE21E13B61A0 /* LoggerMock.swift */; }; 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */; }; @@ -179,6 +192,7 @@ 4EFA142D3D37B0564BDB52CB /* NetworkMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */; }; 4FE19D26711ECB7B2EE8806D /* TriggerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481D47E5121C521DDA268609 /* TriggerRule.swift */; }; 507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335888285131F832E1C91A8F /* Dictionary+Merging.swift */; }; + 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C76F083AB62E59C5DF7FEE /* DevMode.swift */; }; 519196E0035C17C73C525526 /* V2Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */; }; 53113582D4E7F54236FD493C /* CacheKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 865262BC55BAEA135E51698D /* CacheKeys.swift */; }; 53227994995EA43A8CAFB0DA /* LocalizationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF091A5565631C9F426F304B /* LocalizationManager.swift */; }; @@ -219,6 +233,7 @@ 654A73B0F1E27315DB1AE2D4 /* Redeemable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E232690489360042465DB2 /* Redeemable.swift */; }; 65F02A298EC782E84EE2D1D0 /* EntitlementsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */; }; 666FBAEC100FD378E9EC816D /* EntitlementsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */; }; + 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */; }; 67C020751429B5677D9A0727 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236900A8A8F95CE92E612458 /* IdentityManager.swift */; }; 67DE6918459F0E911D4D2D26 /* LogErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4F7C1AA96162D7C97493E /* LogErrors.swift */; }; 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */; }; @@ -232,8 +247,10 @@ 6B01DA1FF5FE0240089DEB36 /* StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7B78DEECD91AFD89B39F9D /* StoreTransaction.swift */; }; 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */; }; 6C752C1F0F303B77FB243D10 /* LocalFileSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */; }; + 6C8D27EE5DAB83D7F21F45F0 /* DevServerSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */; }; 6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFD245D3CB9044C225E1503 /* WaitForEntitlementsAndConfigTests.swift */; }; 6C9B0FB29EA135B4D9A20705 /* WebViewURLConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */; }; + 6CAB392FECDE6E5F0CA2B476 /* DebugPickerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */; }; 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */; }; 6D60D1CC06D717C764BDE181 /* ProductPurchaserSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */; }; 6DA0EC8307544D09C2478EBB /* ExperimentTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC52CA0CE82A5605AFF7A075 /* ExperimentTemplate.swift */; }; @@ -262,6 +279,7 @@ 77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */; }; 77FF632568317C7745451D67 /* ConfirmPaywallAssignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F7B4D9230BF922AE19A830 /* ConfirmPaywallAssignment.swift */; }; 78113F737BA99A2848850904 /* TrackableSuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */; }; + 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */; }; 7909E7B477A2E2EBF84598F2 /* InternallySetSubscriptionStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */; }; 795E7752217DF07AD7EB8660 /* Trigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2628BCB13E80DC539F35C7B5 /* Trigger.swift */; }; 79E35504745555BC5CA14360 /* SK1ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */; }; @@ -278,6 +296,7 @@ 7F630637D79A1F24CC17A8A5 /* GetPaywallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFB9AEAF72391341B4BDF6CD /* GetPaywallVC.swift */; }; 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */; }; 803BFA630F96B638E3BDE715 /* GameControllerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */; }; + 8088933E13346C69FEE58919 /* DebugPickerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */; }; 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */; }; 81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; }; 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; @@ -291,6 +310,7 @@ 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; 88D22C84ACDED44E3952C786 /* SK2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */; }; + 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */; }; 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */; }; 8ACC4731031DA94C709915CF /* Transaction+LatestSince.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */; }; 8AEB577682D9AB9354CB8EE9 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888B05A273E9EB6E9382332 /* UIColor+Hex.swift */; }; @@ -298,7 +318,10 @@ 8B58E4DC7A49D2DFB3CA1F7F /* ASN1Decoder+SingleValueContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CC04EBE95A64F30F83EA0 /* ASN1Decoder+SingleValueContainer.swift */; }; 8BA210D88B69EA78419354E1 /* InternalPresentationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */; }; 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */; }; + 8BD2DB441D3A8B954B1D13B9 /* ConfigManagerEarlyPublishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */; }; 8C3A81E3D75F027539933310 /* BottomPaddingAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */; }; + 8D0B281D5CB739D6AD5EBC0D /* DebugPaywallPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */; }; + 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7781E6093CC05F1467B431D /* DevServerLocator.swift */; }; 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */; }; 8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */; }; 8F18BFB254E432BFBEAB1324 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3A82C80F89023672D56AD7 /* LogLevel.swift */; }; @@ -374,6 +397,7 @@ AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; }; AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */; }; AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; }; + B002C22F935F04DE75E56183 /* EntitlementUnknownFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; }; @@ -383,6 +407,7 @@ B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */; }; B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; }; + B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */; }; B2AB1E9283FDE2D544C8BCA8 /* MockReceiptData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */; }; B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */; }; B2B5684F46FB49AB9E3C1BE0 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47DA89C9F4FBD7FA038F5 /* Cache.swift */; }; @@ -434,10 +459,12 @@ C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9CC1B947A08633E1C7BAE3 /* CoreDataManagerTests.swift */; }; C7E140466315324E9A1B9407 /* PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB7CF97D0926DCDAB133DB1 /* PermissionStatus.swift */; }; C80ACD3C05345709DAB248FD /* RestoreType.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEBD34CFE62DCFE0AFD80D6 /* RestoreType.swift */; }; + C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */; }; C8671043E6585DE19AAD1DAD /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87AD727C5A8639E704F7BE98 /* PaywallView.swift */; }; C86B9D3992BBD1E8A1105F3C /* SuperwallDelegateObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33A9E0597972B90E3B9DFFE1 /* SuperwallDelegateObjc.swift */; }; C8BCF3C0404622139D002893 /* PushTransitionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */; }; C90E075C3BEE92968BBC4E1B /* LocalizationConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */; }; + C9766A8FAF76F716D5F16BD2 /* AttributionDeviceIdentifiersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */; }; C9BE2675C944542B6282E5B5 /* SWProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AC69B94A568B7E14A391A8 /* SWProductDiscount.swift */; }; C9C5355AE4077E6A021F30FF /* IdentityInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1EB433A4E4342E03BB7744 /* IdentityInfo.swift */; }; C9F6A490E3E09401494E7DCB /* CheckDebuggerPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */; }; @@ -445,6 +472,7 @@ CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */; }; CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */; }; CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */; }; + CC82569EF062EE50C1B06373 /* DevServerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */; }; CD324457E6206D0E303A6B15 /* ArchiveURLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C5A241FC9EF921D4E08FF1 /* ArchiveURLFetcher.swift */; }; CD7A815C87F9AFF406BFE9A0 /* AuthorizationStatus+PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E97A346D7F7C59DF55F62B /* AuthorizationStatus+PermissionStatus.swift */; }; CD9B53E7E4E5E536971CC743 /* IntroOfferTokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAECE41BF5F3184C8D79F1CC /* IntroOfferTokenManagerTests.swift */; }; @@ -462,6 +490,7 @@ CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; }; CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */; }; + D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */; }; D0E19F665C7B230BF3FA122D /* TrackingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */; }; D1F8771E65157D1B0E05D0B9 /* ManifestDataFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2915A802FACB53B6094B011 /* ManifestDataFetcher.swift */; }; D25B3A24CEE42FC90BFA31D2 /* SuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E4096EBF0B8C9693322CD1 /* SuperwallEvent.swift */; }; @@ -474,6 +503,7 @@ D506526569FAA54E3220A02A /* PurchaseSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67A5C0CA15AF645709A2545 /* PurchaseSource.swift */; }; D56F32CB484F74EC7E49E581 /* PaywallViewControllerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */; }; D5A1334579BE0B0A207B0BF7 /* TestModeModalViewController+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D4F9888B5827992153F5F /* TestModeModalViewController+TableView.swift */; }; + D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */; }; D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5A8FFFC23826AD113D525A /* Publisher+Async.swift */; }; D6CA719D79BAA6369D1C01C3 /* AudienceLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71CF4BE5D2A6CEA9F8993C81 /* AudienceLogic.swift */; }; D77DB3187C62B91E3D55DF80 /* ArchiveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4300EBF7463A42D2FB89371 /* ArchiveRequest.swift */; }; @@ -485,8 +515,10 @@ D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */; }; D978EAD4FA4865B5E07BF03B /* Future+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE36D141F461F6E945823FA /* Future+Async.swift */; }; DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */; }; + DB7506D37B2BE86074BFF901 /* DeviceIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */; }; DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */; }; DBF70D987418DD9EB504FBDE /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42956918D4FFA5FBA79F3AA5 /* Constants.swift */; }; + DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */; }; DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; }; DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */; }; DE62F8E261EC7C60FBAAAE1D /* BundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34468E3988E779132CE101A /* BundleHelper.swift */; }; @@ -502,6 +534,7 @@ E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83416F0F1B5294C350D5CF70 /* FeatureFlags.swift */; }; E0F69E406F64A1160FF55BFA /* SWProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460B6F98BADD9EC96A978E40 /* SWProduct.swift */; }; E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */; }; + E20B1214CA9785C0D0C7AD7A /* PublishedSubscriptionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */; }; E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */; }; E315F3C6BBCA8582BF540086 /* GetExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */; }; E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D80A7C5B8A17B83C218656 /* MapSwiftErrors.swift */; }; @@ -523,8 +556,10 @@ E9E0BD599F353CC471B7A546 /* PresentationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CE1D40C874F73A3BEC090 /* PresentationInfo.swift */; }; E9F80BA7BCAB71270E0E1CC1 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */; }; E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; }; + EA21B417513519FEDE6232D4 /* DevServerLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */; }; EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; }; EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; }; + EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */; }; EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E522F5BCABB3A95B97549E /* PurchaseError.swift */; }; EB6540A8E1ECC3548C5E6368 /* PaywallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */; }; ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */; }; @@ -533,6 +568,7 @@ ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */; }; ED66539CDB2C991A812A6CC7 /* PaywallSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12EB4944354482783293010 /* PaywallSummary.swift */; }; EDAEC46845C1DB11CB4C99AE /* SWConsoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */; }; + EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */; }; EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */; }; F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */; }; F14330769F5384B9F4FD726E /* RestorationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF71CC25A340374B0A19295 /* RestorationResult.swift */; }; @@ -560,6 +596,7 @@ FA677CF601A228D5B485FFDE /* PopupTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */; }; FA907E1BC8B68F238C791867 /* SuperwallDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */; }; FAE2C990CFBD7485E4DBA8F5 /* PresentationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C825F0AD231C62462873E51A /* PresentationRequest.swift */; }; + FAF3AF67B749AE55CACAE5F9 /* ReceiptManagerPurchasedProductFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */; }; FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7611B89AB647CB1AB79CA912 /* RawExperiment.swift */; }; FC27E2B772AEEC425ED9944D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9EF9D5F77A002F6F0C03C77F /* PrivacyInfo.xcprivacy */; }; FC7A0F08E317F745C3C46E51 /* PermissionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */; }; @@ -567,6 +604,7 @@ FCEF5A897E34F9B7AB691C56 /* PaywallOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */; }; FCF3B638D0D802202113DCBD /* ArchiveManifestUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */; }; FCF498808FF38EEC5E9895DB /* IdentityOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320AA5349848F696950F441A /* IdentityOptions.swift */; }; + FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577D25646DA26238881BF6AB /* DevModeTests.swift */; }; FFC1A413FF8B96275C4C1649 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C16CBCBF2093DD9C5F3E105 /* Storage.swift */; }; /* End PBXBuildFile section */ @@ -630,8 +668,10 @@ 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ObserverModePurchaseDetector.swift; sourceTree = ""; }; 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2Tests.swift; sourceTree = ""; }; 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; + 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantedEntitlementsTests.swift; sourceTree = ""; }; 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMock.swift; sourceTree = ""; }; 115132479C9C41D57C9E3BA9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReceiptManager+Restore.swift"; sourceTree = ""; }; 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainer.swift; sourceTree = ""; }; 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestManager.swift; sourceTree = ""; }; 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; @@ -644,6 +684,7 @@ 187934D3C89220C605299A17 /* PaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManager.swift; sourceTree = ""; }; 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomPaddingAnimation.swift; sourceTree = ""; }; 18E059F7745769ABCA0F2A99 /* AppStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProduct.swift; sourceTree = ""; }; + 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPaywallPickerViewController.swift; sourceTree = ""; }; 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariantOption.swift; sourceTree = ""; }; 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftyJSON.swift; sourceTree = ""; }; 1A2E8A96B1F4C3C78CFA696C /* GetPresentationResultLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPresentationResultLogic.swift; sourceTree = ""; }; @@ -665,8 +706,10 @@ 2123B84F5C786278E128152D /* OccurrenceLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OccurrenceLogicTests.swift; sourceTree = ""; }; 214622367ACC8F66EB392105 /* AppSessionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionLogic.swift; sourceTree = ""; }; 21903EACCA9AA13BB10917EC /* PermissionHandler+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Camera.swift"; sourceTree = ""; }; + 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocatorTests.swift; sourceTree = ""; }; 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerEvent.swift; sourceTree = ""; }; 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; + 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTaskCoordinatorTests.swift; sourceTree = ""; }; 22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEntitlementRedeemer.swift; sourceTree = ""; }; 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; @@ -676,6 +719,7 @@ 24B8D7F537204EAB13BB7F10 /* FeatureGatingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGatingBehaviour.swift; sourceTree = ""; }; 24EA03270476CD31B906CDC8 /* GetPaywallResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallResult.swift; sourceTree = ""; }; 25515131DF0AE67E26BFF462 /* TriggerResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerResult.swift; sourceTree = ""; }; + 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyUserSwitchTests.swift; sourceTree = ""; }; 258D4AA92CC250CEA27E8A49 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 258FC2DB67022EF3D9B1FB67 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; 25D8461640AE665CF5A54016 /* ArchiveManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifest.swift; sourceTree = ""; }; @@ -714,6 +758,7 @@ 311D187DE8B8A5D0699F31A2 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; 320AA5349848F696950F441A /* IdentityOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityOptions.swift; sourceTree = ""; }; 323B5E3D7E69FB3E718EED02 /* TestModeModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeModal.swift; sourceTree = ""; }; + 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedSubscriptionStatus.swift; sourceTree = ""; }; 335888285131F832E1C91A8F /* Dictionary+Merging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Merging.swift"; sourceTree = ""; }; 337B611696DC32BD227CD020 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 33A9E0597972B90E3B9DFFE1 /* SuperwallDelegateObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegateObjc.swift; sourceTree = ""; }; @@ -733,16 +778,20 @@ 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxyTests.swift; sourceTree = ""; }; 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWConsoleViewController.swift; sourceTree = ""; }; 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackableSuperwallEvent.swift; sourceTree = ""; }; + 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywall.swift; sourceTree = ""; }; 3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; }; 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackMock.swift; sourceTree = ""; }; 405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Notification.swift"; sourceTree = ""; }; + 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerPurchasedProductFetchTests.swift; sourceTree = ""; }; 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityLogicTests.swift; sourceTree = ""; }; 42956918D4FFA5FBA79F3AA5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionDeviceIdentifiersTests.swift; sourceTree = ""; }; 440ABDF6DAE2C15579B93DF1 /* PushTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionDelegate.swift; sourceTree = ""; }; 45AFD6EE9BED296D075A9618 /* ASN1Templates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Templates.swift; sourceTree = ""; }; 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistry.swift; sourceTree = ""; }; 460B6F98BADD9EC96A978E40 /* SWProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProduct.swift; sourceTree = ""; }; 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLogic.swift; sourceTree = ""; }; + 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusPublishing.swift; sourceTree = ""; }; 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadingDisabled.swift; sourceTree = ""; }; 4711FABAB250221629C47688 /* AppStoreProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProductTests.swift; sourceTree = ""; }; 481D47E5121C521DDA268609 /* TriggerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRule.swift; sourceTree = ""; }; @@ -759,6 +808,7 @@ 4D7749FB975F9B2B5B156328 /* es_419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es_419; path = es_419.lproj/Localizable.strings; sourceTree = ""; }; 4D7EED1CCDE71C3CB5F87F84 /* PaywallPresentationStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyleTests.swift; sourceTree = ""; }; 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivalManifestDownloaded.swift; sourceTree = ""; }; + 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreview.swift; sourceTree = ""; }; 4EB1F47410CBC84D9ABD2F14 /* AutomaticPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseController.swift; sourceTree = ""; }; 4EC3DA8E774FBFE31F811FAF /* ConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManager.swift; sourceTree = ""; }; 50458143450675EF205CE2C3 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; @@ -778,8 +828,10 @@ 571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Cache.swift"; sourceTree = ""; }; 57478172574516BD5EDD254A /* LoadingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingInfo.swift; sourceTree = ""; }; 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerManager.swift; sourceTree = ""; }; + 577D25646DA26238881BF6AB /* DevModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevModeTests.swift; sourceTree = ""; }; 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2.swift; sourceTree = ""; }; 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsoStringTests.swift"; sourceTree = ""; }; + 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusChange.swift; sourceTree = ""; }; 582CE0C5BA6EA57C7FE3EE43 /* CheckNoPaywallAlreadyPresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckNoPaywallAlreadyPresented.swift; sourceTree = ""; }; 5836EFACFA00594CE8F9F377 /* Experiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experiment.swift; sourceTree = ""; }; 58466FF38687A9F8715F9B54 /* Array+Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Capability.swift"; sourceTree = ""; }; @@ -793,7 +845,9 @@ 5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = ""; }; 5D8D539E4636D23E549B4520 /* TestModePurchaseDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModePurchaseDrawer.swift; sourceTree = ""; }; 5DD4E7007670C369DD8FF5D9 /* Date+IsWithinAnHourBeforeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsWithinAnHourBeforeTests.swift"; sourceTree = ""; }; + 5E37562E243AE6632134D94A /* DevServerSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSurface.swift; sourceTree = ""; }; 5EB5A772C1F2ECE6D0E0BD69 /* PaywallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallState.swift; sourceTree = ""; }; + 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSettingsTests.swift; sourceTree = ""; }; 60B80BEE0364C0EF86E2084E /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersion.swift; sourceTree = ""; }; 618BF4D10B7D87FAF8FB48CD /* ProductPurchaserSK1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1Tests.swift; sourceTree = ""; }; @@ -817,6 +871,7 @@ 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCacheTests.swift; sourceTree = ""; }; 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmHoldoutAssignment.swift; sourceTree = ""; }; 67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = ""; }; + 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusEmissionTests.swift; sourceTree = ""; }; 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = ""; }; 67D9C2B45E15025BF7D783AD /* zh_Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hans; path = zh_Hans.lproj/Localizable.strings; sourceTree = ""; }; 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacementsQueueTests.swift; sourceTree = ""; }; @@ -824,6 +879,7 @@ 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogicTests.swift; sourceTree = ""; }; 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = ""; }; 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1.swift; sourceTree = ""; }; + 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerCandidates.swift; sourceTree = ""; }; 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegate.swift; sourceTree = ""; }; 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTests.swift; sourceTree = ""; }; 6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; }; @@ -835,6 +891,7 @@ 6D1887F247BF6F770122F257 /* StorageMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMock.swift; sourceTree = ""; }; 6DB09C4AF80761DF4205C4C2 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 6DE89E115B095A63FAC09719 /* StripeProductType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeProductType.swift; sourceTree = ""; }; + 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreviewTests.swift; sourceTree = ""; }; 6EDC14C0D6958144679F149D /* DecodingError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DecodingError+Extensions.swift"; sourceTree = ""; }; 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxy.swift; sourceTree = ""; }; 70D2B0D671B1A1E665B7CCD8 /* UIViewController+AsyncDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncDismiss.swift"; sourceTree = ""; }; @@ -862,6 +919,7 @@ 76CE4D7606C896027C76520E /* SWDebugManagerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWDebugManagerLogic.swift; sourceTree = ""; }; 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceStyle.swift; sourceTree = ""; }; 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPAttributionManager.swift; sourceTree = ""; }; + 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerEarlyPublishTests.swift; sourceTree = ""; }; 787764B249892BBCA1088235 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusResolutionTests.swift; sourceTree = ""; }; 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStripeCheckoutPollState.swift; sourceTree = ""; }; @@ -896,6 +954,7 @@ 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = ""; }; 87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManagerTests.swift; sourceTree = ""; }; + 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainerInitTests.swift; sourceTree = ""; }; 884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifestUsage.swift; sourceTree = ""; }; 887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = ""; }; 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = ""; }; @@ -947,6 +1006,7 @@ 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebView.swift; sourceTree = ""; }; 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1ReceiptManager.swift; sourceTree = ""; }; 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; + 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentifiers.swift; sourceTree = ""; }; 9DC4D23D1EDDA249C928930D /* PaddingListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingListener.swift; sourceTree = ""; }; 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementProcessorTests.swift; sourceTree = ""; }; @@ -961,15 +1021,19 @@ A116633D7246EB7BB7AF7229 /* ExpressionEvaluatorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionEvaluatorMock.swift; sourceTree = ""; }; A12EB4944354482783293010 /* PaywallSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSummary.swift; sourceTree = ""; }; A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogic.swift; sourceTree = ""; }; + A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusLogicalEqualityTests.swift; sourceTree = ""; }; A21ED2D8CB4DDA70E228E8BC /* TaskExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExecutor.swift; sourceTree = ""; }; A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; }; A2D40088A465E104CF5C67CC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifestTests.swift; sourceTree = ""; }; A3781CF21200CD2333F6779A /* GetPaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallManager.swift; sourceTree = ""; }; A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationIdTests.swift; sourceTree = ""; }; A3F4F74393061C17CEB18F90 /* ManagedEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedEventData.swift; sourceTree = ""; }; A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Guarded.swift"; sourceTree = ""; }; A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicIdentity.swift; sourceTree = ""; }; A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerMock.swift; sourceTree = ""; }; + A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSettings.swift; sourceTree = ""; }; + A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywallTests.swift; sourceTree = ""; }; A5110E43405C69969E9DA67B /* PublicGetPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPaywall.swift; sourceTree = ""; }; A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurchaseResult+Internal.swift"; sourceTree = ""; }; A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSubscriptionPeriod.swift; sourceTree = ""; }; @@ -981,6 +1045,7 @@ A7D0B6F781D32B2DCDBE689C /* PermissionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandling.swift; sourceTree = ""; }; A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionTests.swift; sourceTree = ""; }; A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogicTests.swift; sourceTree = ""; }; A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = ""; }; AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutWebViewController.swift; sourceTree = ""; }; AAEBD34CFE62DCFE0AFD80D6 /* RestoreType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreType.swift; sourceTree = ""; }; @@ -990,6 +1055,7 @@ ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophonePermissionTests.swift; sourceTree = ""; }; AC11F0D5B6B8A0F5EC5D200B /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionLogic.swift; sourceTree = ""; }; + ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifest.swift; sourceTree = ""; }; AD23BBF9EA198ED8798A8F62 /* SystemInfo+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemInfo+NotificationName.swift"; sourceTree = ""; }; AE32816F1CA1637897AC87A2 /* UNUserNotificationCenter+SuperwallNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNUserNotificationCenter+SuperwallNotifications.swift"; sourceTree = ""; }; AE406AAED11F2B63E4A5A1FD /* UserInitiatedEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInitiatedEvents.swift; sourceTree = ""; }; @@ -1022,6 +1088,7 @@ B7180900DD0767487E671639 /* AssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentTests.swift; sourceTree = ""; }; B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManager.swift; sourceTree = ""; }; B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProxy.swift; sourceTree = ""; }; + B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptRefreshDelegateWrapper.swift; sourceTree = ""; }; B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalPresentationLogic.swift; sourceTree = ""; }; B88E86C67F934540D846B8BA /* EmptyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyResponse.swift; sourceTree = ""; }; B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPMatchResponseTests.swift; sourceTree = ""; }; @@ -1050,6 +1117,7 @@ BFE42FD8C7F01915D74D0008 /* Trackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trackable.swift; sourceTree = ""; }; C054891709C534F59C93C815 /* URLSessionRetryLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRetryLogicTests.swift; sourceTree = ""; }; C0A53CC571B7BC3F5AAD71BF /* ReceiptLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptLogic.swift; sourceTree = ""; }; + C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogic.swift; sourceTree = ""; }; C10310294FD27EB6A0621341 /* StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProduct.swift; sourceTree = ""; }; C1B0B4A4E57D6B7F63510D07 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C22CA9431D5F791BE7A9BE27 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; @@ -1088,6 +1156,7 @@ CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallMessage.swift; sourceTree = ""; }; CC89718EBDD71E09AB5F41DA /* AppSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManager.swift; sourceTree = ""; }; CCAE23C483138D33A1CF8889 /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK1.swift; sourceTree = ""; }; + CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocation.swift; sourceTree = ""; }; CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedTriggerRuleOccurrence.swift; sourceTree = ""; }; CD8C0C8DA633BE856F5B9EEF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; CD9298A79020030E9A1357A6 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; @@ -1106,14 +1175,18 @@ D38D3A69A26709BFB52C6A3A /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTemplate.swift; sourceTree = ""; }; D449672964023589DA5535E3 /* String+MD5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+MD5.swift"; sourceTree = ""; }; + D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encoder+ReportsUnknownFieldsAsNull.swift"; sourceTree = ""; }; D4F676D3A0F5B540052D36B1 /* PublicGetPresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPresentationResult.swift; sourceTree = ""; }; + D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTaskCoordinator.swift; sourceTree = ""; }; D561728FDB68F572D5E33223 /* PermissionsHandler+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Contacts.swift"; sourceTree = ""; }; D570217FF965FF087585931C /* PaywallPreloadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPreloadingTests.swift; sourceTree = ""; }; D5BF66C3986E287B7B2F5E27 /* SKProductSubscriptionPeriodMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKProductSubscriptionPeriodMock.swift; sourceTree = ""; }; D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyShowCondition.swift; sourceTree = ""; }; D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; }; + D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementUnknownFieldsTests.swift; sourceTree = ""; }; D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; }; D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; }; + D7781E6093CC05F1467B431D /* DevServerLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocator.swift; sourceTree = ""; }; D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; }; D7E232690489360042465DB2 /* Redeemable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redeemable.swift; sourceTree = ""; }; D81D656CEA8B5B86458038D4 /* ms */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ms; path = ms.lproj/Localizable.strings; sourceTree = ""; }; @@ -1121,6 +1194,7 @@ D86D76FB5809C3B8122778A9 /* EventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventData.swift; sourceTree = ""; }; D8749262F8F31B90DA975B26 /* AttributionTypeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTypeFactory.swift; sourceTree = ""; }; D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckDebuggerPresentation.swift; sourceTree = ""; }; + D9C76F083AB62E59C5DF7FEE /* DevMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevMode.swift; sourceTree = ""; }; DA1E17A7907C42F27817C958 /* NonSubscriptionTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonSubscriptionTransaction.swift; sourceTree = ""; }; DA1F5AFA28F20FAE4D0AA3B5 /* LocalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotification.swift; sourceTree = ""; }; DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerMock.swift; sourceTree = ""; }; @@ -1167,6 +1241,7 @@ F13CC9902419E7D68B47C184 /* PopupTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransitionDelegate.swift; sourceTree = ""; }; F16AFE9C93A441CFB6A95F10 /* String+CamelCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CamelCase.swift"; sourceTree = ""; }; F2A2A54314BAEAF65B46D322 /* NetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTests.swift; sourceTree = ""; }; + F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseControllerTests.swift; sourceTree = ""; }; F338AF233A9EF2A20B1AC5A5 /* MockPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPurchaseController.swift; sourceTree = ""; }; F34468E3988E779132CE101A /* BundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleHelper.swift; sourceTree = ""; }; F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Transaction+LatestSince.swift"; sourceTree = ""; }; @@ -1180,6 +1255,7 @@ F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePayment.swift; sourceTree = ""; }; F798D9662212AD1CC75666F3 /* PaywallMessageHandlerDelegateMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallMessageHandlerDelegateMock.swift; sourceTree = ""; }; F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingResult.swift; sourceTree = ""; }; + F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSharedControllerAttributionTests.swift; sourceTree = ""; }; F9098101E599AEB01521FE89 /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; F96ABDCA3686C7F1CAF41B03 /* en_GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_GB; path = en_GB.lproj/Localizable.strings; sourceTree = ""; }; F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductBillingPlanTests.swift; sourceTree = ""; }; @@ -1285,6 +1361,7 @@ children = ( 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */, 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */, + 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */, 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */, A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */, 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */, @@ -1298,6 +1375,7 @@ 054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */, C0D3F44546D33F8AA71A79B9 /* Mocks */, 2C05828E18C52F510F7CDFA2 /* Products */, + 42F8F9824C94A7946BD46450 /* Purchase Controller */, 357496424A62506BB5B92AB9 /* Transactions */, ); path = StoreKit; @@ -1577,9 +1655,12 @@ 2C05828E18C52F510F7CDFA2 /* Products */ = { isa = PBXGroup; children = ( + 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */, BD6BA222CB2EAA4B65F362C5 /* ProductsFetcherSK1.swift */, 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */, B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */, + 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */, + A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */, 0E9A13ACB22951C865722510 /* Receipt Manager */, 9160DE1504D8084C3DF51ADC /* StoreProduct */, ); @@ -1657,6 +1738,7 @@ 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */, 51407421A3CBF7AF0FC76E60 /* Bundle+Helpers.swift */, B2E6016BF483A4C47FF7A7C0 /* Encodable+Dictionary.swift */, + D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */, 9A7FFEA64AF7F4E09F052FCD /* Error+SafeLocalizedDescription.swift */, 8DE36D141F461F6E945823FA /* Future+Async.swift */, E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */, @@ -1711,6 +1793,7 @@ isa = PBXGroup; children = ( 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */, + 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */, 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */, 2714D9FD9F55611B4C5E4E7D /* IdentityManagerMock.swift */, EF6B2C623B994FF663F75719 /* IdentityManagerTests.swift */, @@ -1721,6 +1804,7 @@ 38C02C19ED9C9958A7A61FB1 /* Debug */ = { isa = PBXGroup; children = ( + A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */, C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */, ); path = Debug; @@ -1799,6 +1883,14 @@ path = Certificates; sourceTree = ""; }; + 42F8F9824C94A7946BD46450 /* Purchase Controller */ = { + isa = PBXGroup; + children = ( + F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */, + ); + path = "Purchase Controller"; + sourceTree = ""; + }; 43AE802CA13E36803E2FF13E /* Popup Transition */ = { isa = PBXGroup; children = ( @@ -1875,6 +1967,7 @@ 4D7656D6A565958F58A644AF /* Misc */ = { isa = PBXGroup; children = ( + 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */, F2F75130AF54DFC4C7FA839A /* Extensions */, ); path = Misc; @@ -1905,6 +1998,7 @@ children = ( 58BA95995DE57E811FD65C02 /* PaywallCacheLogicTests.swift */, 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */, + F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */, 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */, A52293BCD414E92F7B54919D /* Logic */, ); @@ -2267,6 +2361,7 @@ 97F6AA52B81B82F72AB80D7C /* Debug */, 9C21AAF80FD220C960FE568F /* Delegate */, A34416D82C5BDBAA82A119C1 /* Dependencies */, + A9BD6565A1CB1609A86DF616 /* DevServer */, 5943C0902B0D5EC30C0C3B8C /* Game Controller */, C36C5C30F60C1DFCDCF25E16 /* Graveyard */, 66F9C998E9BBCFFCF80386FE /* Identity */, @@ -2382,6 +2477,7 @@ isa = PBXGroup; children = ( D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */, + D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */, BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */, F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */, 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */, @@ -2433,6 +2529,8 @@ isa = PBXGroup; children = ( 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */, + 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */, + C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */, F9098101E599AEB01521FE89 /* DebugViewController.swift */, 299F91895EE88281B5ED8320 /* SWBounceButton.swift */, 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */, @@ -2489,6 +2587,9 @@ children = ( C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */, E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */, + 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */, + 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */, + 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */, 0D4F1B49114819E9E6923508 /* Product Fetching */, E36E6C0CB40C3F44423C80CF /* Receipt Manager */, CB4191E8F35374E6FF55C5D0 /* StoreProduct */, @@ -2562,6 +2663,22 @@ path = Logic; sourceTree = ""; }; + A9BD6565A1CB1609A86DF616 /* DevServer */ = { + isa = PBXGroup; + children = ( + D9C76F083AB62E59C5DF7FEE /* DevMode.swift */, + 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */, + CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */, + D7781E6093CC05F1467B431D /* DevServerLocator.swift */, + ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */, + 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */, + 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */, + A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */, + 5E37562E243AE6632134D94A /* DevServerSurface.swift */, + ); + path = DevServer; + sourceTree = ""; + }; AC10C98F9F6D03AD79376ADE /* Capabilities */ = { isa = PBXGroup; children = ( @@ -2627,12 +2744,26 @@ 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */, 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */, D8749262F8F31B90DA975B26 /* AttributionTypeFactory.swift */, + 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */, BB868CF67AB2AE72895F864E /* IntegrationAttribute.swift */, 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */, ); path = Attribution; sourceTree = ""; }; + B2625661B3ACD184CFAF2390 /* DevServer */ = { + isa = PBXGroup; + children = ( + 577D25646DA26238881BF6AB /* DevModeTests.swift */, + 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */, + A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, + A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, + 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, + 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */, + ); + path = DevServer; + sourceTree = ""; + }; B26ABA960333F1BE7CAF7FAF /* Config */ = { isa = PBXGroup; children = ( @@ -2705,6 +2836,8 @@ D554340BB6652F5FA1F21FF8 /* Config */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, + E9FF04AB866B9CCA7DFBE592 /* Dependencies */, + B2625661B3ACD184CFAF2390 /* DevServer */, 373AFF230833A951B6E5DF36 /* Identity */, 8DD7B7C5E111EAB0878886B6 /* Logger */, 4D7656D6A565958F58A644AF /* Misc */, @@ -2773,6 +2906,8 @@ isa = PBXGroup; children = ( B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */, + 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */, + B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */, 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */, 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */, ); @@ -2811,8 +2946,8 @@ children = ( 0E3AC3B23DAAA8C1D125BDD3 /* CoreDataManager.swift */, 50458143450675EF205CE2C3 /* CoreDataStack.swift */, - EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, 2DAF3427CF469F5373C2BFD7 /* Managed Models */, + EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, ); path = "Core Data"; sourceTree = ""; @@ -2885,6 +3020,7 @@ isa = PBXGroup; children = ( 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */, + 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */, DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */, 92A6B82F855E19B9C180C659 /* ConfigManagerTests.swift */, 9C4C1D13B7C7D97BE6ABFEA9 /* Assignments */, @@ -2916,6 +3052,7 @@ isa = PBXGroup; children = ( A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, + 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, ); path = Attribution; @@ -2985,6 +3122,14 @@ path = Options; sourceTree = ""; }; + E9FF04AB866B9CCA7DFBE592 /* Dependencies */ = { + isa = PBXGroup; + children = ( + 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */, + ); + path = Dependencies; + sourceTree = ""; + }; ED389E60F716C417202738F0 /* Misc */ = { isa = PBXGroup; children = ( @@ -2994,6 +3139,7 @@ 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */, E09C238ADC0B019047FAB1DF /* JSONToDict.swift */, 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */, + D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */, 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */, 62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */, 848592ACE8760E53F2163F01 /* Typealiases.swift */, @@ -3148,6 +3294,8 @@ 8F41784FB73AC60BED81E582 /* PBXTargetDependency */, ); name = SuperwallKitTests; + packageProductDependencies = ( + ); productName = SuperwallKitTests; productReference = 92001AC11F099F7B03AF338A /* SuperwallKitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -3164,7 +3312,6 @@ }; }; buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -3212,9 +3359,12 @@ zh_Hant, ); mainGroup = 5CE8CEF97A892FFF3D0D8F06; + minimizedProjectReferenceProxies = 1; packageReferences = ( 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); + preferredProjectObjectVersion = 77; + productRefGroup = 778C04FFAA9840C37CA3C1CA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -3251,12 +3401,15 @@ A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, + C9766A8FAF76F716D5F16BD2 /* AttributionDeviceIdentifiersTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, + D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */, B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, E984458E465D5A834CC52302 /* CacheMock.swift in Sources */, CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */, 2A07A8F4A55E28D13777D03E /* CheckDebuggerPresentationOperatorTests.swift in Sources */, A73497BB3DCD881318A5CD86 /* ConfigLogicTests.swift in Sources */, + 8BD2DB441D3A8B954B1D13B9 /* ConfigManagerEarlyPublishTests.swift in Sources */, 63CED0C62636A9FD03B7315A /* ConfigManagerMock.swift in Sources */, FCD7C16E35F139DCC2386B91 /* ConfigManagerTests.swift in Sources */, BCF808C7AC319C2B1F0AD52D /* ConfigResponseLogicTests.swift in Sources */, @@ -3274,13 +3427,22 @@ 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, + 8088933E13346C69FEE58919 /* DebugPickerLogicTests.swift in Sources */, 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, + 4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */, + FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */, + EA21B417513519FEDE6232D4 /* DevServerLocatorTests.swift in Sources */, + 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */, + 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */, + EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */, + 6C8D27EE5DAB83D7F21F45F0 /* DevServerSettingsTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */, A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */, 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */, + B002C22F935F04DE75E56183 /* EntitlementUnknownFieldsTests.swift in Sources */, 1225B991B40D16B7FB4EF1A5 /* EvaluateRulesOperatorTests.swift in Sources */, 158EBAAC2A96F73B93C48E15 /* ExpressionEvaluatorMock.swift in Sources */, 77ED3D92C176CC6D93D625B2 /* ExpressionLogicTests.swift in Sources */, @@ -3289,7 +3451,9 @@ AC7D527612F631AAADC7D225 /* FileManagerMigratorTests.swift in Sources */, D4B5A9708204AB6D58904733 /* GetPaywallVcOperatorTests.swift in Sources */, 64B962A8B59ACE514B0E48AE /* GetPresenterOperatorTests.swift in Sources */, + D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */, AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */, + 4B5B618A7FAECA65EF29F2C2 /* IdentifyUserSwitchTests.swift in Sources */, 77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */, D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */, 1894723C383155082D7C6287 /* IdentityManagerTests.swift in Sources */, @@ -3327,6 +3491,7 @@ 69F7D9CC12CBC367735DE213 /* PaywallPreloadingTests.swift in Sources */, DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */, 702CEF940451FCB4AF354D90 /* PaywallPresentationStyleTests.swift in Sources */, + 3E4694244060F6951B127401 /* PaywallSharedControllerAttributionTests.swift in Sources */, 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */, 7CC56E289C0A1C93411B68D2 /* PaywallViewControllerDrawerTests.swift in Sources */, 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */, @@ -3340,6 +3505,7 @@ A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, + FAF3AF67B749AE55CACAE5F9 /* ReceiptManagerPurchasedProductFetchTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */, 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */, @@ -3350,6 +3516,7 @@ E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */, C77A626D379969A86B900488 /* SWWebViewLoadingHandlerTests.swift in Sources */, EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */, + 3D5684BAF13C86ABED458EB9 /* SerialTaskCoordinatorTests.swift in Sources */, 919A08D7F25BD2DF27A22697 /* StorageMock.swift in Sources */, 713A1F9D9861C6A1E5EB9174 /* StorageTests.swift in Sources */, 701B1B586B6C1E3F0B3AF560 /* StoreKitManagerTests.swift in Sources */, @@ -3357,6 +3524,8 @@ B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */, 5E51E14716E29C9B88B8A6F2 /* StripeTrialEligibilityTests.swift in Sources */, 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */, + 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */, + 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, @@ -3457,11 +3626,23 @@ B4C9FECB4894151E42A34F85 /* Date+TimeIntervalMilliseconds.swift in Sources */, F326AADBFE0083F8F18E81CE /* Date+WithinAnHourBefore.swift in Sources */, 432FB2AE172725B4ED208416 /* DebugManager.swift in Sources */, + 8D0B281D5CB739D6AD5EBC0D /* DebugPaywallPickerViewController.swift in Sources */, + 6CAB392FECDE6E5F0CA2B476 /* DebugPickerLogic.swift in Sources */, 11FF1373BAAE0B019CE6AE21 /* DebugViewController.swift in Sources */, B5AE7BCDCB6D49FC8493D55B /* DecodingError+Extensions.swift in Sources */, CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */, 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */, + 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */, + B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */, + DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */, + 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */, + 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */, + EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */, + 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */, + CC82569EF062EE50C1B06373 /* DevServerSettings.swift in Sources */, + 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, + DB7506D37B2BE86074BFF901 /* DeviceIdentifiers.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */, 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */, @@ -3474,6 +3655,7 @@ B89435087910E6B501471622 /* Email.swift in Sources */, 9EAE577E60052F5E1C7B9657 /* EmptyResponse.swift in Sources */, CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */, + 1EA8A326074CB46980BA7C9C /* Encoder+ReportsUnknownFieldsAsNull.swift in Sources */, 11477D1EB60D1FDA32F5099A /* Endpoint.swift in Sources */, AD26500C2B27829305F76859 /* EndpointKind.swift in Sources */, 14B9EC6E8BAE199C9349838C /* Enrichment.swift in Sources */, @@ -3648,6 +3830,7 @@ F5CCDC90D8CBA5ED0C5BAD2E /* PublicGetPresentationResult.swift in Sources */, 0AB9CCC164DD87C81318AAB0 /* PublicIdentity.swift in Sources */, 8F24AAC773F119481E2C654F /* PublicPresentation.swift in Sources */, + E20B1214CA9785C0D0C7AD7A /* PublishedSubscriptionStatus.swift in Sources */, D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */, 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */, 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */, @@ -3666,7 +3849,9 @@ 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */, B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */, 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */, + 43555DB889239C0F1FB73AE1 /* ReceiptManager+Restore.swift in Sources */, 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */, + 2F8329A05DF5BFF975FFA8B7 /* ReceiptRefreshDelegateWrapper.swift in Sources */, 4D37588A69A4C770C86FC585 /* RedeemRequest.swift in Sources */, 02C8AEBA38988C0140734957 /* RedeemResponse.swift in Sources */, 654A73B0F1E27315DB1AE2D4 /* Redeemable.swift in Sources */, @@ -3696,6 +3881,7 @@ CE411644469AB99AC9DBB492 /* SWWebViewLoadingHandler.swift in Sources */, 074EBBBF7E3B2B207B00A275 /* SWWebViewLogic.swift in Sources */, 1753820CBFBDD8FF70455D71 /* ScaleAnimation.swift in Sources */, + 223E12D4EDBF41D8F5515E53 /* SerialTaskCoordinator.swift in Sources */, AD5EBB6DBA919E3CBC5B85B7 /* SessionEventsRequest.swift in Sources */, 5A6D06700C4E4E2C6C9BC1B2 /* ShimmerView.swift in Sources */, 18B147198F2E19C69013BA4B /* Sk1StoreProduct.swift in Sources */, @@ -3722,6 +3908,8 @@ 91BA5E01D0FB528954ABB937 /* StripeStoreProductDiscount.swift in Sources */, B60C3A3AD25CE2E2C513A2D2 /* Stubbable.swift in Sources */, C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */, + 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */, + C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, 17C0F1960CD89B6BFA8B2FBB /* SuperwallDelegate.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd6..8c5e0a183 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "NO"> String = { "vendor-1" }, + attStatus: @escaping () -> Int? = { 3 }, + idfa: @escaping () -> String? = { "advertiser-1" }, + sync: @escaping ([String: Any?]) -> Void = { _ in } + ) -> AttributionFetcher { + // Storage is backed by the same files across containers, so a dictionary + // left by an earlier test would be loaded here as a starting state. + container.storage.delete(IntegrationAttributes.self) + return AttributionFetcher( + storage: container.storage, + deviceHelper: container.deviceHelper, + webEntitlementRedeemer: container.webEntitlementRedeemer, + vendorIdProvider: vendorId, + attStatusProvider: attStatus, + idfaProvider: idfa, + syncUserAttributes: sync + ) + } + + @Test func unchangedProviderRefreshesDeviceIdentifiersAndConsent() { + let container = DependencyContainer() + var status: Int? = 3 + var idfa: String? = "advertiser-1" + var vendorId = "vendor-1" + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + vendorId: { vendorId }, + attStatus: { status }, + idfa: { idfa }, + sync: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "3") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + + vendorId = "vendor-2" + status = 2 + idfa = nil + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(fetcher.integrationAttributes["idfv"] == "vendor-2") + #expect(fetcher.integrationAttributes["attStatus"] == "2") + #expect(fetcher.integrationAttributes["idfa"] == nil) + #expect(syncedAttributes["idfa"] as? NSNull != nil) + #expect(syncedAttributes["idfv"] as? String == "vendor-2") + #expect(syncedAttributes["attStatus"] as? String == "2") + } + + @Test func activationRefreshesWithoutSettingProviderAgain() { + let container = DependencyContainer() + var status: Int? = 0 + var idfa: String? + let fetcher = makeFetcher( + container: container, + attStatus: { status }, + idfa: { idfa } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "0") + + status = 3 + idfa = "advertiser-1" + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["attStatus"] == "3") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + } + + @Test func omitsVendorIdWhenItIsUnavailable() { + let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + vendorId: { "" }, + sync: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["idfv"] == nil) + #expect(syncedAttributes["idfv"] as? NSNull != nil) + } + + @Test func keepsIdfaWhileConsentIsUndecided() { + let container = DependencyContainer() + let fetcher = makeFetcher( + container: container, + attStatus: { 0 }, + idfa: { "advertiser-1" } + ) + defer { fetcher.cancelPendingOperations() } + + // Before iOS 14.5 the IDFA is readable while ATT still reads + // `notDetermined`, and a build that can't resolve `ATTrackingManager` + // reports the same status, so the status mustn't gate the IDFA. + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "0") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + } + + @Test func omitsAttStatusWhereTheOsHasNoConsentPrompt() { + let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + attStatus: { nil }, + sync: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == nil) + #expect(syncedAttributes["attStatus"] as? NSNull != nil) + } + + @Test func onlySyncsUserAttributesWhenTheDeviceSnapshotChanges() { + let container = DependencyContainer() + var status: Int? = 3 + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + attStatus: { status }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + fetcher.refreshDeviceIdentifiers() + fetcher.refreshDeviceIdentifiers() + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + #expect(syncCount == 1) + + status = 2 + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["attStatus"] == "2") + #expect(syncCount == 2) + } + + @Test func resetKeepsTheInstallScopedAttributesForTheNewUser() { + let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + sync: { + syncedAttributes = $0 + syncCount += 1 + } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes( + attributes: [ + "appsflyerId": "af-1", + "amplitudeUserId": "person-1" + ] + ) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + #expect(syncedAttributes["appsflyerId"] == nil) + + // A reset deletes the user-specific copy and empties the user's attributes. + container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() + + // The AppsFlyer id describes the device, so the new user keeps it. The + // Amplitude user id describes the person who just signed out. + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(fetcher.integrationAttributes["amplitudeUserId"] == nil) + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + #expect(syncCount == 2) + + // The kept ids go back to the new user, alongside the device ones. + #expect(syncedAttributes["appsflyerId"] as? String == "af-1") + #expect(syncedAttributes["amplitudeUserId"] == nil) + #expect(syncedAttributes["idfv"] as? String == "vendor-1") + + // And they're on disk again, so the next launch still refreshes and the + // redeem that follows the reset doesn't ship empty metadata. + let stored = container.storage.get(IntegrationAttributes.self) + #expect(stored?["appsflyerId"] == "af-1") + #expect(stored?["amplitudeUserId"] == nil) + } + + @Test func resetKeepsTheDeviceKeysWhenNoProviderIdIsInstallScoped() { + let container = DependencyContainer() + let fetcher = makeFetcher(container: container) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["amplitudeUserId": "person-1"]) + #expect(fetcher.integrationAttributes["amplitudeUserId"] == "person-1") + + container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() + + // Nothing of the old user's is left, but the device keys are the SDK's own, + // so they stay and the activation refresh keeps working for the new user. + #expect(fetcher.integrationAttributes["amplitudeUserId"] == nil) + #expect(fetcher.integrationAttributes["idfv"] == "vendor-1") + #expect(container.storage.get(IntegrationAttributes.self)?["amplitudeUserId"] == nil) + #expect(container.storage.get(IntegrationAttributes.self)?["idfv"] == "vendor-1") + } + + @Test func resetClearsEverythingWhenTheDeviceOffersNoIdentifiers() { + let container = DependencyContainer() + let fetcher = makeFetcher( + container: container, + vendorId: { "" }, + attStatus: { nil }, + idfa: { nil } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["amplitudeUserId": "person-1"]) + #expect(fetcher.integrationAttributes["amplitudeUserId"] == "person-1") + + container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() + + #expect(fetcher.integrationAttributes.isEmpty) + #expect(container.storage.get(IntegrationAttributes.self) == nil) + } + + @Test func resendsTheIdentifiersWhenSomethingElseDropsThem() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // Nothing about the device changed, so there's nothing to say. + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // But an app that writes over one of the SDK's keys has to be answered, + // otherwise the router loses the identifier until one of them changes. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["email": "a@b.com", "idfv": nil]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + } + + @Test func keepsQuietWhenTheAppWritesItsOwnAttributes() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // Keys the SDK doesn't own say nothing about its own. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["email": "a@b.com"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // Nor does the same answer in a different box: whatever echoes the user's + // attributes back may hold `attStatus` as a JSON number rather than the + // quoted one the SDK sent. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": NSNumber(value: 3)]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // A different status is still a different status. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": NSNumber(value: 2)]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + + // Neither does a write echoing back exactly what the SDK sent β€” which is + // what the SDK's own sync and the enrichment response both look like. + fetcher.forgetSyncedDeviceIdentifiers( + ifChangedBy: [ + "idfv": "vendor-1", + "idfa": "advertiser-1", + "attStatus": "3" + ] + ) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + } + + @Test func resendsWhenAnIdentifierTheSdkLeftOutIsGivenAValue() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + idfa: { nil }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // The SDK sent `idfa` as an explicit null, so a null back is a match. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["idfa": NSNull()]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // An app putting its own value there is not. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["idfa": "made-up"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + } + + @Test func treatsABooleanStatusAsAnOverwrite() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + attStatus: { 1 }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "1") + #expect(syncCount == 1) + + // `true` renders as "1" through NSNumber, which is what the SDK sent for + // `restricted` β€” but it isn't a status, so the real one has to go back. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": true]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["attStatus"] == "1") + #expect(syncCount == 2) + } + + @Test func everyIntegrationAttributeIsScoped() { + // Every case lands on one side of the split β€” the exhaustive switch in + // `isInstallScoped` forces that. Bump both counts when adding a case, so + // nobody adds an integration without picking a side. + #expect(IntegrationAttribute.allCases.count == 23) + #expect(IntegrationAttribute.installScopedKeys.count == 11) + + #expect(IntegrationAttribute.installScopedKeys.contains("appsflyerId")) + #expect(IntegrationAttribute.installScopedKeys.contains("adjustId")) + #expect(!IntegrationAttribute.installScopedKeys.contains("amplitudeUserId")) + #expect(!IntegrationAttribute.installScopedKeys.contains("customerioId")) + } + + @Test func resetDropsThePersonScopedAttributesWaitingOnTheTransactionId() { + let container = DependencyContainer() + let superwall = Superwall(dependencyContainer: container) + superwall.enqueuedIntegrationAttributes = [ + .appsflyerId: "af-1", + .amplitudeUserId: "person-1" + ] + + // Called directly rather than through `reset()`, whose storage wipe and + // config reset would reach well beyond this suite. + superwall.resetEnqueuedIntegrationAttributes() + + let enqueued = superwall.enqueuedIntegrationAttributes + #expect(enqueued?[.appsflyerId] == "af-1") + #expect(enqueued?[.amplitudeUserId] == nil) + } +} diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift new file mode 100644 index 000000000..633166248 --- /dev/null +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -0,0 +1,854 @@ +// +// ConfigManagerEarlyPublishTests.swift +// SuperwallKitTests +// +// A subscriber with a cached config still waited on this launch's StoreKit read +// before `configState` was published, so every `register` call at cold launch +// stalled for as long as that read took. When the customer info saved by the +// previous launch proves the user is entitled through a date that hasn't +// arrived yet, config is now published first and the in-memory purchase state +// is rebuilt from the saved copy until the read lands. +// +// swiftlint:disable all + +import Foundation +@testable import SuperwallKit +import Testing + +@Suite(.serialized) +struct ConfigManagerEarlyPublishTests { + /// Held for the lifetime of each test: the managers keep `unowned` + /// references to the container. + let dependencyContainer = DependencyContainer() + + private struct Harness { + let storage: StorageMock + let configManager: ConfigManager + let receiptManager: ReceiptManager + let receipt: SlowReceiptManagerType + /// Kept alive: other container members hold `unowned` references to the + /// original receipt manager and to the products manager. + let originalReceiptManager: ReceiptManager + let productsManager: ProductsManager + /// Kept alive: `ConfigManager` holds these `unowned`. + let network: NetworkMock + let deviceHelper: DeviceHelperMock + } + + private static let silverProductId = "com.app.silver" + private static let goldProductId = "com.app.gold" + /// A second product in its own group, unlocking a separate entitlement. + private static let legacyProductId = "com.app.legacy" + private static let webProductId = "com.app.web" + private static let coinsProductId = "com.app.coins" + + /// Customer info the way the previous launch would have saved it: one + /// subscription in `group_A` unlocking `pro`, expiring `expiresIn` from now. + /// The saved `isActive` flag is always true: when `expiresIn` is negative + /// that's the stale shape, the flag frozen at the last launch and the date + /// since passed. + private func savedCustomerInfo(expiresIn: TimeInterval, willRenew: Bool = false) -> CustomerInfo { + let expiresAt = Date().addingTimeInterval(expiresIn) + let silver = SubscriptionTransaction( + transactionId: "1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-3600), + willRenew: willRenew, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: expiresAt, + subscriptionGroupId: "group_A" + ) + let pro = Entitlement( + id: "pro", + isActive: true, + productIds: [Self.silverProductId, Self.goldProductId], + latestProductId: Self.silverProductId, + store: .appStore, + expiresAt: expiresAt, + willRenew: willRenew + ) + return CustomerInfo(subscriptions: [silver], nonSubscriptions: [], entitlements: [pro]) + } + + /// The valid `silver` subscription plus a `legacy` one in `group_B` that was + /// active when saved but whose expiry has since passed. + private func savedCustomerInfoWithLapsedSecondSubscription() -> CustomerInfo { + let valid = savedCustomerInfo(expiresIn: 3600) + let lapsedAt = Date().addingTimeInterval(-60) + let legacy = SubscriptionTransaction( + transactionId: "2", + productId: Self.legacyProductId, + purchaseDate: Date().addingTimeInterval(-7200), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: lapsedAt, + subscriptionGroupId: "group_B" + ) + let legacyEntitlement = Entitlement( + id: "legacy", + isActive: true, + productIds: [Self.legacyProductId], + latestProductId: Self.legacyProductId, + store: .appStore, + expiresAt: lapsedAt, + willRenew: true + ) + // The saved copy is the merged one, so it also carries a web subscription. + let web = SubscriptionTransaction( + transactionId: "3", + productId: Self.webProductId, + purchaseDate: Date().addingTimeInterval(-600), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: Date().addingTimeInterval(3600), + store: .stripe + ) + return CustomerInfo( + subscriptions: valid.subscriptions + [legacy, web], + nonSubscriptions: [], + entitlements: valid.entitlements + [legacyEntitlement] + ) + } + + /// A web-only subscriber: one Stripe subscription unlocking `pro` on the + /// silver product, no App Store purchases at all. + private func savedWebOnlyCustomerInfo() -> CustomerInfo { + let expiresAt = Date().addingTimeInterval(3600) + let web = SubscriptionTransaction( + transactionId: "web-1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-600), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: expiresAt, + store: .stripe + ) + let pro = Entitlement( + id: "pro", + isActive: true, + productIds: [Self.silverProductId], + latestProductId: Self.silverProductId, + store: .stripe, + expiresAt: expiresAt, + willRenew: true + ) + return CustomerInfo(subscriptions: [web], nonSubscriptions: [], entitlements: [pro]) + } + + /// A valid subscription plus one saved active with no expiry at all, which + /// nothing can show is still current. + private func savedCustomerInfoWithUndatedSubscription() -> CustomerInfo { + let valid = savedCustomerInfo(expiresIn: 3600) + let undated = SubscriptionTransaction( + transactionId: "4", + productId: Self.legacyProductId, + purchaseDate: Date().addingTimeInterval(-7200), + willRenew: false, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: nil, + subscriptionGroupId: "group_B" + ) + return CustomerInfo( + subscriptions: valid.subscriptions + [undated], + nonSubscriptions: [], + entitlements: valid.entitlements + ) + } + + /// A lifetime purchase: the non-consumable row that unlocks `pro` with no + /// expiry and `isLifetime` set, plus a consumable the user also bought. + private func savedLifetimeCustomerInfo() -> CustomerInfo { + let lifetime = NonSubscriptionTransaction( + transactionId: "life-1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-86_400), + isConsumable: false, + isRevoked: false + ) + let coins = NonSubscriptionTransaction( + transactionId: "coins-1", + productId: Self.coinsProductId, + purchaseDate: Date().addingTimeInterval(-600), + isConsumable: true, + isRevoked: false + ) + let pro = Entitlement( + id: "pro", + isActive: true, + productIds: [Self.silverProductId], + latestProductId: Self.silverProductId, + store: .appStore, + expiresAt: nil, + isLifetime: true + ) + return CustomerInfo(subscriptions: [], nonSubscriptions: [lifetime, coins], entitlements: [pro]) + } + + /// Builds a config manager whose receipt loading takes `loadDelay` seconds + /// on the first call only, so the background refresh that follows does not + /// keep the test alive. `savedCustomerInfo` stands in for what the previous + /// launch wrote to disk. + private func makeHarness( + container: DependencyContainer? = nil, + storeKitVersion: SuperwallOptions.StoreKitVersion = .storeKit2, + isSubscribed: Bool, + savedCustomerInfo: CustomerInfo?, + loadDelay: TimeInterval, + receiptDelegate: ReceiptDelegate? = nil + ) -> Harness { + let dependencyContainer = container ?? self.dependencyContainer + let storage = StorageMock() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + let deviceHelper = DeviceHelperMock( + api: dependencyContainer.api, + storage: storage, + network: network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer + ) + + let receipt = SlowReceiptManagerType(loadDelay: loadDelay) + let productsFetcher = ProductsFetcherSK1Mock( + productCompletionResult: .success([]), + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: productsFetcher + ) + let originalReceiptManager: ReceiptManager = dependencyContainer.receiptManager + let receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: receipt, + receiptDelegate: receiptDelegate, + factory: dependencyContainer, + storage: storage + ) + dependencyContainer.receiptManager = receiptManager + + // The config knows both products and the entitlement they unlock; the + // saved customer info says which is active. + let products = [Self.silverProductId, Self.goldProductId].map { + // Decoded config entitlements default to inactive; only the saved copy + // or a grant can make one active. + Product( + name: $0, + type: .appStore(.init(id: $0)), + id: $0, + entitlements: [Entitlement(id: "pro", isActive: false)] + ) + } + [ + Product( + name: Self.legacyProductId, + type: .appStore(.init(id: Self.legacyProductId)), + id: Self.legacyProductId, + entitlements: [Entitlement(id: "legacy", isActive: false)] + ) + ] + let cachedConfig: Config = .stub() + .setting(\.buildId, to: "cached_123") + .setting(\.featureFlags, to: .stub()) + .setting(\.products, to: products) + storage.save(cachedConfig, forType: LatestConfig.self) + + if isSubscribed { + storage.save(SubscriptionStatus.active([.stub()]), forType: SubscriptionStatusKey.self) + } else { + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.self) + } + storage.save(savedCustomerInfo ?? .blank(), forType: LatestCustomerInfo.self) + + let enrichment = Enrichment( + user: JSON(["test_user_key": "test_user_value"]), + device: JSON(["test_device_key": "test_device_value"]) + ) + storage.save(enrichment, forType: LatestEnrichment.self) + + let newConfig: Config = .stub() + .setting(\.buildId, to: "fresh_456") + network.configReturnValue = .success(newConfig) + + let options = SuperwallOptions() + options.storeKitVersion = storeKitVersion + let configManager = ConfigManager( + options: options, + storeKitManager: dependencyContainer.storeKitManager, + storage: storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + deviceHelper: deviceHelper, + entitlementsInfo: dependencyContainer.entitlementsInfo, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + factory: dependencyContainer + ) + dependencyContainer.configManager = configManager + + return Harness( + storage: storage, + configManager: configManager, + receiptManager: receiptManager, + receipt: receipt, + originalReceiptManager: originalReceiptManager, + productsManager: productsManager, + network: network, + deviceHelper: deviceHelper + ) + } + + /// Polls until `configState` holds a config or `timeout` passes. Returns the + /// seconds it waited. + private func waitForConfig( + _ configManager: ConfigManager, + timeout: TimeInterval + ) async -> TimeInterval { + let start = Date() + while configManager.config == nil, Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 10_000_000) + } + return Date().timeIntervalSince(start) + } + + /// Polls until the purchases load has started or `timeout` passes. Making + /// the load's `Task` doesn't run its first line, so it can start a moment + /// after config is published rather than before. + private func waitForLoadToStart( + _ receipt: SlowReceiptManagerType, + timeout: TimeInterval + ) async { + let start = Date() + while !receipt.didStartLoad, Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func settle() async { + // Let the background refresh finish before the container goes away. + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Saved entitlement still valid: config is published before StoreKit finishes") + func publishesBeforeStoreKitWhenSavedEntitlementIsValid() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config?.buildId == "cached_123") + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + await waitForLoadToStart(harness.receipt, timeout: 1) + #expect(harness.receipt.didStartLoad, "purchases load must still be kicked off") + #expect(!harness.receipt.didFinishLoad, "config was published only after StoreKit finished") + + await fetch.value + #expect(harness.receipt.didFinishLoad, "fetchConfiguration still waits for the purchases load") + await settle() + } + + @Test("While StoreKit loads, purchase state comes from the saved customer info") + func restoresPurchaseStateFromSavedCustomerInfo() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600, willRenew: false), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Active products for audience filters. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.silverProductId)) + + // The product-to-entitlement map paywall products are built from, carrying + // the saved willRenew that audience filters read. + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.map(\.id) == ["pro"]) + #expect(silverEntitlements.first?.willRenew == false) + #expect(silverEntitlements.first?.isActive == true) + + await fetch.value + await settle() + } + + @Test("A second subscription that lapsed since the last launch is restored inactive") + func restoresLapsedSecondSubscriptionAsInactive() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The still-valid silver keeps the fast path, but legacy is past its expiry + // and the web row isn't a device purchase. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.legacyProductId) == false) + #expect(await harness.receiptManager.isSubscribed(to: Self.webProductId) == false) + let legacyEntitlements = Superwall.shared.entitlements.byProductId(Self.legacyProductId) + #expect(legacyEntitlements.first?.isActive == false) + #expect(legacyEntitlements.first?.willRenew == true, "the rest of the saved copy carries over") + + await fetch.value + await settle() + } + + @Test("The restore publishes the lapse-corrected status and customer info") + func restorePublishesCorrectedStatusAndCustomerInfo() async { + // The controller reads the entitlement map the restore writes, which + // belongs to the shared instance, so it has to look at that one. + let purchaseController = AutomaticPurchaseController( + factory: dependencyContainer, + entitlementsInfo: Superwall.shared.entitlements + ) + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2, + receiptDelegate: purchaseController + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The status is rebuilt from the seeded purchases, so silver's `pro` + // is active and the lapsed `legacy` isn't part of it. The shared + // instance can carry web entitlements from other suites, so only these + // two are checked. + guard case .active(let entitlements) = Superwall.shared.subscriptionStatus else { + Issue.record("expected an active status, got \(Superwall.shared.subscriptionStatus)") + await fetch.value + await settle() + return + } + #expect(entitlements.contains { $0.id == "pro" && $0.isActive }) + #expect(!entitlements.contains { $0.id == "legacy" }) + + // The customer info carries every saved row, with the lapsed one corrected. + let customerInfo = Superwall.shared.customerInfo + let legacy = customerInfo.entitlements.first { $0.id == "legacy" } + #expect(legacy?.isActive == false) + #expect(legacy?.willRenew == true, "the rest of the saved copy carries over") + #expect(customerInfo.entitlements.first { $0.id == "pro" }?.isActive == true) + #expect(customerInfo.subscriptions.first { $0.productId == Self.legacyProductId }?.isActive == false) + #expect(customerInfo.subscriptions.first { $0.productId == Self.silverProductId }?.isActive == true) + #expect(customerInfo.subscriptions.first { $0.productId == Self.webProductId }?.isActive == true) + + await fetch.value + await settle() + } + + @Test("The read replaces everything the restore seeded") + func readReplacesRestoredState() async { + // The stand-in read returns an empty snapshot, so anything still showing + // the saved copy after it lands is state the read failed to replace. + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + let gold = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: Self.goldProductId, + subscriptionGroupIdentifier: "group_A" + ) + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Mid-load: purchases, the entitlement map and the active groups all come + // from the saved copy. The group is seeded, so no trial on an upgrade. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) + #expect(await harness.receiptManager.isFreeTrialAvailable(for: gold) == false) + + await fetch.value + #expect(harness.receipt.didFinishLoad) + + // After the read: the snapshot it returned, not the seed. + #expect(await harness.receiptManager.getActiveProductIds().isEmpty) + #expect(await harness.receiptManager.isSubscribed(to: Self.silverProductId) == false) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty) + // The seeded group is gone too, so the upgrade is trial-eligible again. + #expect(await harness.receiptManager.isFreeTrialAvailable(for: gold) == true) + + await settle() + } + + @Test("Trial eligibility waits for the purchases load that config no longer waits for") + func trialEligibilityWaitsForInitialPurchasesLoad() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + let gold = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: Self.goldProductId, + subscriptionGroupIdentifier: "group_A" + ) + ) + _ = await dependencyContainer.isFreeTrialAvailable(for: gold) + #expect(harness.receipt.didFinishLoad, "eligibility was answered before the load finished") + + await fetch.value + await settle() + } + + @Test("A web-only subscriber takes the fast path with no device purchases seeded") + func publishesEarlyForWebOnlySubscriber() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedWebOnlyCustomerInfo(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Device purchases stay empty, as they are after the read for a web + // subscriber, while the entitlement map carries the web entitlement. + #expect(await harness.receiptManager.getActiveProductIds().isEmpty) + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.isActive == true) + #expect(silverEntitlements.first?.store == .stripe) + + await fetch.value + await settle() + } + + @Test("A developer-granted entitlement takes the fast path with nothing else saved") + func publishesEarlyForGrantedEntitlement() async { + // Granted entitlements carry whatever the developer set; usually no expiry. + dependencyContainer.entitlementsInfo.setGranted([Entitlement(id: "pro")]) + defer { dependencyContainer.entitlementsInfo.setGranted([]) } + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: nil, + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Every load merges grants back in, so the restore has to carry them too. + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.id == "pro") + #expect(silverEntitlements.first?.isActive == true) + + await fetch.value + await settle() + } + + @Test("A grant survives a saved row with the same id that has lapsed") + func grantSurvivesLapsedSavedRowWithSameId() async { + // The grant and the saved `pro` row share an id, and the saved row lapsed + // since the last launch. The developer's verdict has to win. + dependencyContainer.entitlementsInfo.setGranted([Entitlement(id: "pro")]) + defer { dependencyContainer.entitlementsInfo.setGranted([]) } + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: -60), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.id == "pro") + #expect( + silverEntitlements.first?.isActive == true, + "the lapsed saved row deactivated the grant" + ) + + await fetch.value + await settle() + } + + @Test("A subscription saved with no expiry is not restored active") + func doesNotRestoreUndatedSubscriptionAsActive() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithUndatedSubscription(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Only the dated, unexpired silver is active. The undated row can't be + // shown to be current, and the read wouldn't count it either. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.legacyProductId) == false) + + await fetch.value + await settle() + } + + @Test("A lifetime purchase has no expiry and takes the fast path") + func publishesEarlyForLifetimePurchase() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedLifetimeCustomerInfo(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The non-consumable is active, the consumable isn't, same as after the read. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.coinsProductId) == false) + + await fetch.value + await settle() + } + + @Test("StoreKit 1 has no saved device rows to restore from, so config waits") + func waitsOnStoreKit1() async { + let harness = makeHarness( + storeKitVersion: .storeKit1, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("Saved entitlement still flagged active but past its expiry: config waits") + func waitsWhenSavedEntitlementHasExpired() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: -60), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil, "config was published after \(waited)s, before purchases loaded") + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("An active status on disk with a blank saved customer info is not enough") + func waitsWithBlankSavedCustomerInfo() async { + // `Superwall.init` persists `.blank()` before the first read, so this is + // the real first-launch shape; the harness stores it for a nil input. + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: nil, + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("Unknown subscriber: purchases still load before config is published") + func syncPathStillLoadsPurchasesBeforePublishing() async { + let harness = makeHarness( + isSubscribed: false, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("A purchase controller takes the fast path too") + func publishesEarlyWithExternalPurchaseController() async { + let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) + let harness = makeHarness( + container: controllerContainer, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad) + + await fetch.value + await settle() + } + + @Test("With a purchase controller the restore seeds purchases but leaves the status and customer info alone") + func restoreLeavesControllerStateAloneWithExternalPurchaseController() async { + // Put back afterwards: the SDK never overwrites controller-set state on + // this path, so it would otherwise leak into suites running alongside. + let originalCustomerInfo = Superwall.shared.customerInfo + let originalStatus = Superwall.shared.subscriptionStatus + defer { + Superwall.shared.customerInfo = originalCustomerInfo + Superwall.shared.subscriptionStatus = originalStatus + } + + let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) + // Would publish the status if the restore reached it; the guard must stop + // it getting that far. + let purchaseController = AutomaticPurchaseController( + factory: controllerContainer, + entitlementsInfo: Superwall.shared.entitlements + ) + let harness = makeHarness( + container: controllerContainer, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2, + receiptDelegate: purchaseController + ) + + // State the developer's controller set since launch, which the saved copy + // predates and must not replace. + let controllerEntitlement = Entitlement(id: "controller_only", isActive: true, store: .appStore) + let controllerCustomerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [controllerEntitlement] + ) + await MainActor.run { + Superwall.shared.customerInfo = controllerCustomerInfo + } + Superwall.shared.subscriptionStatus = .active([controllerEntitlement]) + let statusBefore = Superwall.shared.subscriptionStatus + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The purchase state is still seeded from the saved copy. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) + + // The controller's state is untouched. + #expect(Superwall.shared.customerInfo == controllerCustomerInfo) + #expect(Superwall.shared.subscriptionStatus == statusBefore) + + await fetch.value + await settle() + } +} + +/// A `ReceiptManagerType` whose first `loadPurchases` sleeps, standing in for a +/// StoreKit read on a weak network. +private final class SlowReceiptManagerType: ReceiptManagerType, @unchecked Sendable { + let loadsSubscriptionGroupsFromProducts = false + private let loadDelay: TimeInterval + private(set) var didStartLoad = false + private(set) var didFinishLoad = false + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(loadDelay: TimeInterval) { + self.loadDelay = loadDelay + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + let isFirstLoad = !didStartLoad + didStartLoad = true + if isFirstLoad { + try? await Task.sleep(nanoseconds: UInt64(loadDelay * 1_000_000_000)) + } + didFinishLoad = true + purchases = [] + return PurchaseSnapshot( + purchases: [], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +} diff --git a/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift new file mode 100644 index 000000000..f64f3f99d --- /dev/null +++ b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift @@ -0,0 +1,81 @@ +// +// DebugPickerLogicTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DebugPickerLogicTests: XCTestCase { + func test_splitsLocalSurfacesAndPublishedPaywallsIntoSections() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["chatgpt-plus", "pro"], + publishedNames: ["Winback", "Onboarding"], + selectedLocalId: "pro", + selectedPublishedIndex: nil + ) + XCTAssertEqual(sections.map { $0.title }, [ + DebugPickerLogic.localTitle, + DebugPickerLogic.publishedTitle + ]) + XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus", "pro"]) + XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 0), .local(index: 1)]) + XCTAssertEqual(sections[1].rows.map { $0.kind }, [.published(index: 0), .published(index: 1)]) + } + + func test_marksTheShowingPaywall() { + let local = DebugPickerLogic.sections( + localSurfaceIds: ["pro"], + publishedNames: ["Winback"], + selectedLocalId: "pro", + selectedPublishedIndex: 0 + ) + XCTAssertTrue(local[0].rows[0].isSelected) + // a local surface is on screen, so no published row is marked + XCTAssertFalse(local[1].rows[0].isSelected) + + let published = DebugPickerLogic.sections( + localSurfaceIds: [], + publishedNames: ["Winback", "Onboarding"], + selectedLocalId: nil, + selectedPublishedIndex: 1 + ) + XCTAssertEqual(published[0].rows.filter { $0.isSelected }.map { $0.title }, ["Onboarding"]) + } + + func test_searchFiltersBothSectionsAndDropsEmptyOnes() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["chatgpt-plus", "pro"], + publishedNames: ["Winback"], + selectedLocalId: nil, + selectedPublishedIndex: nil, + query: " CHAT " + ) + XCTAssertEqual(sections.count, 1) + XCTAssertEqual(sections[0].title, DebugPickerLogic.localTitle) + XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus"]) + // the row still points at its original index, not the filtered one + XCTAssertEqual(sections[0].rows[0].kind, .local(index: 0)) + } + + func test_keepsIndicesStableWhenSearchHidesEarlierRows() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["alpha", "beta", "gamma"], + publishedNames: [], + selectedLocalId: nil, + selectedPublishedIndex: nil, + query: "gamma" + ) + XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 2)]) + } + + func test_omitsASectionWithNothingInIt() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: [], + publishedNames: ["Winback"], + selectedLocalId: nil, + selectedPublishedIndex: 0 + ) + XCTAssertEqual(sections.map { $0.title }, [DebugPickerLogic.publishedTitle]) + } +} diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 3d2be9159..1c4401500 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -95,6 +95,15 @@ struct DeepLinkRouterTests { #expect(result == false) } + // MARK: - Dev Server Preview URLs + + @Test("Returns false for a superwall_dev link when no dev server is set") + func storeDeepLink_devServerLink_devServerOff() { + let url = URL(string: "myapp://?superwall_dev=http://localhost:6100")! + let result = DeepLinkRouter.storeDeepLink(url) + #expect(result == false) + } + // MARK: - Non-Superwall URLs @Test("Returns false for generic app URL") diff --git a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift new file mode 100644 index 000000000..c3d25b508 --- /dev/null +++ b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift @@ -0,0 +1,96 @@ +// +// DependencyContainerInitTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 25/08/2026. +// + +import Testing +@testable import SuperwallKit +import Foundation + +struct DependencyContainerInitTests { + /// https://github.com/superwall/Superwall-iOS/issues/504 + /// + /// `DependencyContainer.init` used to pass `self` to `WebEntitlementRedeemer`, + /// whose init spawned a task reading `configManager` on a background thread + /// while init was still assigning stored properties. This loop only signals + /// under Thread Sanitizer (`-enableThreadSanitizer YES`), where it reproduced + /// the race on the first iteration; without TSan it's a smoke test. + @Test("Constructing the container doesn't race against its own init") + func containerInitHasNoDataRace() { + for _ in 0..<10 { + _ = DependencyContainer(apiKey: "pk_test_504") + } + } +} + +/// The purchases read only ever adds App Store history, so callers ask this +/// before deciding whether an entitlement's answer can change when it lands. +@Suite(.serialized) +struct AppStoreEntitlementLookupTests { + private func makeProduct( + id: String, + entitlementId: String, + isAppStore: Bool + ) -> SuperwallKit.Product { + return SuperwallKit.Product( + name: id, + type: isAppStore ? .appStore(.init(id: id)) : .stripe(.init(id: id, trialDays: nil)), + id: id, + entitlements: [Entitlement(id: entitlementId)] + ) + } + + @Test("Only App Store products put an entitlement within the read's reach") + func onlyAppStoreProductsCount() { + let container = DependencyContainer() + let config: Config = .stub() + .setting( + \.products, + to: [ + makeProduct(id: "com.app.pro", entitlementId: "pro", isAppStore: true), + makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false) + ] + ) + container.configManager.configState.send(.retrieved(config)) + + #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) + // A web product sharing an entitlement with an App Store one still waits. + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only", "pro"])) + #expect(container.purchasesLoadCouldChange(entitlementIds: []) == false) + } + + @Test("Without config the load can't be ruled out, so callers wait") + func waitsWhenConfigIsMissing() { + let container = DependencyContainer() + #expect(container.configManager.config == nil) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) + } + + @Test("A granted web-only entitlement still skips the wait, with or without a controller") + func grantedWebOnlyEntitlementIsNotInReach() { + let webOnly: Config = .stub() + .setting( + \.products, + to: [makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false)] + ) + + // Setting a grant assigns `customerInfo` before the setter returns on both + // paths β€” `refreshAutomaticCustomerInfoAfterGrantChange` without a purchase + // controller, `refreshExternalControllerCustomerInfo` with one β€” so the + // load has nothing to add either way. + let automatic = DependencyContainer() + automatic.configManager.configState.send(.retrieved(webOnly)) + automatic.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) + defer { automatic.entitlementsInfo.setGranted([]) } + #expect(automatic.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) + + let controlled = DependencyContainer(purchaseController: MockPurchaseController()) + controlled.configManager.configState.send(.retrieved(webOnly)) + controlled.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) + defer { controlled.entitlementsInfo.setGranted([]) } + #expect(controlled.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift new file mode 100644 index 000000000..0fdaa1acb --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift @@ -0,0 +1,47 @@ +// +// DevModeTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevModeTests: XCTestCase { + override func tearDown() { + DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } + super.tearDown() + } + + private func options(devServer: SuperwallOptions.DevServer? = nil) -> SuperwallOptions { + let options = SuperwallOptions() + options.devServer = devServer + return options + } + + func test_isInactiveWhenNobodyAskedForIt() { + DevMode.isSandboxEnvironment = { true } + XCTAssertFalse(DevMode.isActive(options())) + } + + func test_isActiveInSandboxWhenTheToggleIsOn() { + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devServer: .default))) + } + + /// The one that matters: an App Store build must behave as if dev mode was + /// never set, so purchases stay real and paywalls stay published. + func test_isInertInProductionEvenWhenTheToggleIsOn() { + DevMode.isSandboxEnvironment = { false } + XCTAssertFalse(DevMode.isActive(options(devServer: .default))) + } + + func test_anExplicitDevServerUrlIsAlsoGated() throws { + let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devServer: .url(url)))) + + DevMode.isSandboxEnvironment = { false } + XCTAssertFalse(DevMode.isActive(options(devServer: .url(url)))) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift new file mode 100644 index 000000000..4270ee839 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift @@ -0,0 +1,253 @@ +// +// DevServerLocatorTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct DevServerLocatorTests { + private static let manifest = Data(""" + {"surfaces":[{"kind":"paywall","id":"pro","url":"/preview/paywall/pro"}]} + """.utf8) + + /// Answers the manifest probe for the origins it is told are serving, and + /// records everything it was asked for. + private actor Probe { + private(set) var requestedURLs: [URL] = [] + private let serving: Set + + init(serving: Set) { + self.serving = serving + } + + func load(_ request: URLRequest) -> (Data, URLResponse?) { + guard let url = request.url else { + return (Data(), nil) + } + requestedURLs.append(url) + let origin = "\(url.scheme ?? "")://\(url.host ?? ""):\(url.port ?? 0)" + let isServing = serving.contains(origin) + let response = HTTPURLResponse( + url: url, + statusCode: isServing ? 200 : 404, + httpVersion: nil, + headerFields: nil + ) + return (isServing ? DevServerLocatorTests.manifest : Data(), response) + } + } + + /// Holds a request for one origin until the test releases it, so two + /// lookups can be interleaved. + private actor GatedProbe { + private let serving: Set + private let gatedOrigin: String + private var gate: [CheckedContinuation] = [] + private var arrival: CheckedContinuation? + private var hasArrived = false + private var isOpen = false + + init(serving: Set, gating gatedOrigin: String) { + self.serving = serving + self.gatedOrigin = gatedOrigin + } + + /// Returns once a request for the gated origin is waiting. + func waitUntilGated() async { + if hasArrived { + return + } + await withCheckedContinuation { arrival = $0 } + } + + func release() { + isOpen = true + for continuation in gate { + continuation.resume() + } + gate = [] + } + + func load(_ request: URLRequest) async -> (Data, URLResponse?) { + guard let url = request.url else { + return (Data(), nil) + } + let origin = "\(url.scheme ?? "")://\(url.host ?? ""):\(url.port ?? 0)" + if origin == gatedOrigin, + !isOpen { + hasArrived = true + arrival?.resume() + arrival = nil + await withCheckedContinuation { gate.append($0) } + } + let isServing = serving.contains(origin) + let response = HTTPURLResponse( + url: url, + statusCode: isServing ? 200 : 404, + httpVersion: nil, + headerFields: nil + ) + return (isServing ? DevServerLocatorTests.manifest : Data(), response) + } + } + + private func locator(_ probe: Probe) -> DevServerLocator { + return DevServerLocator { request in + await probe.load(request) + } + } + + private func locator(_ probe: GatedProbe) -> DevServerLocator { + return DevServerLocator { request in + await probe.load(request) + } + } + + private func url(_ string: String) throws -> URL { + return try #require(URL(string: string)) + } + + @Test("Reuses the server it just found for the same address") + func locate_cachesHitForSameAddress() async throws { + let base = try url("http://192.168.1.10:6100") + let probe = Probe(serving: [base.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: base) + #expect(first?.base == base) + + let second = await locator.locate(devServerURL: base) + #expect(second?.base == base) + + let requestedURLs = await probe.requestedURLs + #expect(requestedURLs.count == 1) + } + + @Test("Pointing devServer somewhere else drops the cached server") + func locate_discardsHitWhenAddressChanges() async throws { + let old = try url("http://192.168.1.10:6100") + let new = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [old.absoluteString, new.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: old) + #expect(first?.base == old) + + let second = await locator.locate(devServerURL: new) + #expect(second?.base == new) + } + + @Test("A miss at one address doesn't suppress the lookup at another") + func locate_discardsMissWhenAddressChanges() async throws { + let missing = try url("http://192.168.1.10:6100") + let running = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [running.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: missing) + #expect(first == nil) + + let second = await locator.locate(devServerURL: running) + #expect(second?.base == running) + } + + @Test("A deep link's pin wins over the address devServer names") + func locate_pinnedBaseWinsOverConfiguredAddress() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = Probe(serving: [pinned.absoluteString]) + let locator = locator(probe) + + await locator.pin(base: pinned) + + let located = await locator.locate(devServerURL: configured) + #expect(located?.base == pinned) + } + + @Test("Pointing devServer somewhere else drops a deep link's pin") + func locate_discardsPinWhenAddressChanges() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let new = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [pinned.absoluteString, new.absoluteString]) + let locator = locator(probe) + + _ = await locator.locate(devServerURL: configured) + await locator.pin(base: pinned) + let beforeChange = await locator.locate(devServerURL: configured) + #expect(beforeChange?.base == pinned) + + let located = await locator.locate(devServerURL: new) + #expect(located?.base == new) + } + + @Test("A lookup still in flight for the old address can't refill the cache") + func locate_staleLookupDoesNotOverwriteTheNewAddress() async throws { + let old = try url("http://192.168.1.10:6100") + let new = try url("http://192.168.1.20:6100") + let probe = GatedProbe( + serving: [old.absoluteString, new.absoluteString], + gating: old.absoluteString + ) + let locator = locator(probe) + + let stale = Task { await locator.locate(devServerURL: old) } + await probe.waitUntilGated() + + // devServer is pointed at the new address while the old probe is waiting. + let current = await locator.locate(devServerURL: new) + #expect(current?.base == new) + + await probe.release() + let staleResult = await stale.value + #expect(staleResult == nil) + + let afterStaleFinished = await locator.locate(devServerURL: new) + #expect(afterStaleFinished?.base == new) + } + + @Test("A pin landing mid-lookup wins over the walk already in flight") + func locate_pinDuringLookupWinsOverTheWalkInFlight() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = GatedProbe( + serving: [configured.absoluteString, pinned.absoluteString], + gating: configured.absoluteString + ) + let locator = locator(probe) + + let inFlight = Task { await locator.locate(devServerURL: configured) } + await probe.waitUntilGated() + + // A superwall_dev link lands while that walk is still waiting. + await locator.pin(base: pinned) + let afterPin = await locator.locate(devServerURL: configured) + #expect(afterPin?.base == pinned) + + await probe.release() + let inFlightResult = await inFlight.value + #expect(inFlightResult == nil) + + let afterWalkFinished = await locator.locate(devServerURL: configured) + #expect(afterWalkFinished?.base == pinned) + } + + @Test("Forgetting drops the cached server and the pin") + func forget_clearsCacheAndPin() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = Probe(serving: [pinned.absoluteString]) + let locator = locator(probe) + + await locator.pin(base: pinned) + let beforeForgetting = await locator.locate(devServerURL: configured) + #expect(beforeForgetting?.base == pinned) + + await locator.forget() + let located = await locator.locate(devServerURL: configured) + #expect(located == nil) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift new file mode 100644 index 000000000..344022964 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -0,0 +1,301 @@ +// +// DevServerManifestTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerManifestTests: XCTestCase { + private func manifest(_ json: String) throws -> DevServerManifest { + return try JSONDecoder().decode(DevServerManifest.self, from: Data(json.utf8)) + } + + func test_decodesManifestJson() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" }, + { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" } + ] + } + """) + XCTAssertEqual(decoded.surfaces.count, 2) + XCTAssertEqual(decoded.surfaces[0].paywallId, "12345") + XCTAssertNil(decoded.surfaces[1].paywallId) + } + + func test_boundPaywallWinsOverSingleFallback() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max", "paywallId": "678" } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "678")?.id, "max") + } + + func test_singlePaywallServesEveryDatabaseId() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "anything")?.id, "pro") + } + + func test_severalUnboundPaywallsMatchNothing() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" } + ] + } + """) + XCTAssertNil(decoded.surface(forPaywallDatabaseId: "anything")) + } + + func test_candidatesDefaultToLocalhostAcrossTheDevPortRange() { + let bases = DevServerCandidates.bases(devServerURL: nil) + XCTAssertEqual( + bases.map { $0.absoluteString }, + (6100...6104).map { "http://localhost:\($0)" } + ) + } + + func test_anExplicitDevServerUrlIsTheOnlyCandidate() throws { + let url = try XCTUnwrap(URL(string: "http://192.168.1.10:7000")) + XCTAssertEqual(DevServerCandidates.bases(devServerURL: url), [url]) + } + + func test_decodesTheIdentifierWhenTheManifestCarriesIt() throws { + let decoded = try manifest(""" + { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "1", "identifier": "pro-slug" }] } + """) + XCTAssertEqual(decoded.surfaces.first?.identifier, "pro-slug") + } + + func test_devLinkOutcomeParsesBaseAndOptionalSurface() throws { + let base = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=http://192.168.1.10:6100")) + let outcome = try XCTUnwrap(DevServerPreview.outcomeForDeepLink(url: base)) + XCTAssertEqual(outcome.base.absoluteString, "http://192.168.1.10:6100") + XCTAssertNil(outcome.surfaceId) + + let direct = try XCTUnwrap(URL( + string: "exampleapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=chatgpt-plus" + )) + XCTAssertEqual( + DevServerPreview.outcomeForDeepLink(url: direct)?.surfaceId, + "chatgpt-plus" + ) + } + + func test_devLinkOutcomeRejectsNonHttpBasesAndOtherLinks() throws { + let js = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=javascript:alert(1)")) + XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: js)) + let debug = try XCTUnwrap(URL(string: "exampleapp://?superwall_debug=true&token=abc")) + XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: debug)) + } + + func test_mountUrlResolvesAgainstTheDevServerOrigin() throws { + let decoded = try manifest(""" + { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }] } + """) + let surface = try XCTUnwrap(decoded.surfaces.first) + let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + XCTAssertEqual( + decoded.mountURL(for: surface, base: base)?.absoluteString, + "http://192.168.1.10:6100/preview/paywall/pro" + ) + } + + func test_mountUrlRejectsSurfacesPointingOffTheDevServerOrigin() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "absolute", "url": "https://evil.example.com/x" }, + { "kind": "paywall", "id": "protocol-relative", "url": "//evil.example.com/x" }, + { "kind": "paywall", "id": "other-port", "url": "http://192.168.1.10:9999/x" } + ] + } + """) + let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + for surface in decoded.surfaces { + XCTAssertNil(decoded.mountURL(for: surface, base: base), surface.id) + } + } + + // MARK: - Tolerating what the SDK can't read + + func test_oneUnreadableSurfaceDoesNotDropTheRest() throws { + // `id` is required, so the middle entry can't decode at all. + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "paywall", "url": "/preview/paywall/nameless" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro", "max"]) + } + + + func test_aBodyWithoutSurfacesIsNotAManifest() { + // `surfaces` is what tells this JSON apart from anything else that might + // answer on a candidate port, so these must not decode β€” otherwise the + // port walk stops on the wrong process. + for body in ["{}", #"{"detail": "Not Found"}"#, #"{"error": {"code": 404}}"#] { + XCTAssertThrowsError(try manifest(body), body) + } + } + + func test_anEmptySurfaceListIsStillAManifest() throws { + XCTAssertTrue(try manifest(#"{"surfaces": []}"#).surfaces.isEmpty) + } + + func test_matchesASurfaceBoundToSeveralPaywalls() { + // superwall.lock can bind one surface to several paywalls: the CLI sends + // the first as `paywallId` and the whole set as `paywallIds`. + // swiftlint:disable:next force_try + let decoded = try! manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { + "kind": "paywall", + "id": "shared", + "url": "/preview/paywall/shared", + "paywallId": "111", + "paywallIds": ["111", "222", "333"] + } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "111")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "222")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "333")?.id, "shared") + XCTAssertNil(decoded.surface(forPaywallDatabaseId: "444")) + } + + /// The manifest a running `superwall dev` actually serves, copied from + /// `/device/manifest.json`, so a change on either side of the wire fails + /// here rather than on someone's device. The CLI writes every key from one + /// serializer (`packages/runtime/src/config.ts`, `paywallSettingsOf`): + /// `presentation_style.type` and `web_checkout_destination` in the push + /// API's uppercase, `feature_gating` and `introductory_offer_eligibility` + /// lowercase, and the eligibility key on every surface, defaulting to + /// `automatic`. + func test_readsTheManifestTheCliServes() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "drawer", + "url": "/preview/paywall/drawer", + "paywallId": "208552", + "settings": { + "presentation_style": { "type": "DRAWER", "height": 60, "corner_radius": 15 }, + "feature_gating": "gated", + "on_device_cache": false, + "scroll_enabled": true, + "game_controller_enabled": true, + "introductory_offer_eligibility": "always_ineligible" + } + }, + { + "kind": "paywall", + "id": "demo", + "url": "/preview/paywall/demo", + "products": { "primary": "demo_monthly" }, + "settings": { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic", + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + }, + { + "kind": "paywall", + "id": "hosted", + "url": "/preview/paywall/hosted", + "settings": { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic", + "web_checkout_destination": "EXTERNAL" + } + } + ] + } + """) + + XCTAssertEqual(decoded.surfaces.count, 3) + let bound = try XCTUnwrap(decoded.surface(forPaywallDatabaseId: "208552")) + let url = try XCTUnwrap(URL(string: "http://localhost:6100/preview/paywall/drawer")) + + // A published paywall that disagrees with config.ts on every setting the + // manifest carries, so nothing here can pass by accident. + let stub = Paywall.stub() + let published = Paywall( + databaseId: "208552", + identifier: "pro_published", + name: "Published Pro", + cacheKey: stub.cacheKey, + buildId: stub.buildId, + url: stub.url, + urlConfig: stub.urlConfig, + htmlSubstitutions: "", + presentation: PaywallPresentationInfo(style: .fullscreen, delay: 300), + backgroundColorHex: "#123456", + backgroundColor: .blue, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: .nonGated, + onDeviceCache: .enabled, + isScrollEnabled: false, + introOfferEligibility: .automatic + ) + + let paywall = Paywall.devServer(surface: bound, url: url, inheriting: published) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 60, cornerRadius: 15)) + XCTAssertEqual(paywall.presentation.delay, 300) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertTrue(paywall.isScrollEnabled) + XCTAssertEqual(paywall.onDeviceCache, .disabled) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + + // Web-only and app-level settings have no paywall field to land on, so the + // surfaces carrying them still read cleanly. + let hosted = try XCTUnwrap(decoded.surfaces.first { $0.id == "hosted" }) + XCTAssertEqual(hosted.settings?.presentationStyle, .fullscreen) + + let demo = try XCTUnwrap(decoded.surfaces.first { $0.id == "demo" }) + XCTAssertEqual(demo.settings?.backgroundColorHex, "#ffffff") + XCTAssertEqual(demo.settings?.darkBackgroundColorHex, "#0d0f12") + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift new file mode 100644 index 000000000..c06d7beec --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -0,0 +1,430 @@ +// +// DevServerPaywallTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerPaywallTests: XCTestCase { + private func surface( + id: String = "pro", + paywallId: String? = nil, + identifier: String? = nil, + products: [String: String]? = nil, + settings: String? = nil + ) -> DevServerSurface { + let json = """ + { + "kind": "paywall", + "id": "\(id)", + "url": "/preview/paywall/\(id)", + \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") + \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") + \(settings.map { "\"settings\": \($0)," } ?? "") + "products": \(products.map { dict in + "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" + } ?? "null") + } + """ + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + private let url = URL(string: "http://localhost:6100/preview/paywall/pro")! + + func test_pointsEveryUrlAtTheDevServerAndDisablesTheArchive() { + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertEqual(paywall.url, url) + XCTAssertEqual(paywall.urlConfig.endpoints.map { $0.url }, [url]) + XCTAssertEqual(paywall.urlConfig.maxAttempts, 1) + // a local build is never the archived, published bytes + XCTAssertNil(paywall.manifest) + XCTAssertFalse(paywall.isUsingManifest) + } + + func test_carriesTheProductsTheSurfaceDeclares() { + let paywall = Paywall.devServer( + surface: surface(products: ["plus": "chatgpt_plus_1999_month", "go": "chatgpt_go_999_month"]), + url: url + ) + + // sorted by reference name, so the order is stable across runs + XCTAssertEqual(paywall.products.map { $0.name }, ["go", "plus"]) + XCTAssertEqual(paywall.productIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"]) + XCTAssertEqual(paywall.appStoreProductIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"]) + } + + func test_worksForASurfaceThatHasNeverBeenPushed() { + let paywall = Paywall.devServer(surface: surface(id: "draft"), url: url) + + XCTAssertEqual(paywall.name, "draft") + XCTAssertTrue(paywall.databaseId.contains("draft")) + XCTAssertTrue(paywall.identifier.contains("draft")) + XCTAssertTrue(paywall.products.isEmpty) + } + + func test_keepsTheDashboardIdentityOfAPushedSurface() { + let paywall = Paywall.devServer( + surface: surface(paywallId: "253583", identifier: "chatgpt-plus"), + url: url + ) + + XCTAssertEqual(paywall.databaseId, "253583") + XCTAssertEqual(paywall.identifier, "chatgpt-plus") + } + + /// The paywall is named by the dashboard, but presenting under that name + /// fetches its published version, so the debugger opens a surface under the + /// id that resolves back to the local bytes. + func test_theDebuggerPreviewsUnderTheSyntheticDevId() { + let pushed = surface(paywallId: "253583", identifier: "chatgpt-plus") + + XCTAssertEqual(Paywall.devServer(surface: pushed, url: url).identifier, "chatgpt-plus") + XCTAssertEqual(pushed.previewIdentifier, "dev:pro") + } + + func test_isMarkedLocalSoEventsCanSaySo() { + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertTrue(paywall.isLocal) + let params = paywall.getInfo(fromPlacement: nil).audienceFilterParams() + XCTAssertEqual(params["is_local"] as? Bool, true) + } + + /// A published paywall whose inheritable fields all differ from the values + /// `Paywall.devServer` would otherwise fall back to, so an assertion on any + /// of them fails if the inheritance is dropped. + private func published( + databaseId: String = "db-1", + identifier: String = "pro_v3" + ) -> Paywall { + let stub = Paywall.stub() + return Paywall( + databaseId: databaseId, + identifier: identifier, + name: "Published Pro", + cacheKey: stub.cacheKey, + buildId: stub.buildId, + url: stub.url, + urlConfig: stub.urlConfig, + htmlSubstitutions: "", + presentation: PaywallPresentationInfo( + style: .drawer(height: 42, cornerRadius: 7), + delay: 250 + ), + backgroundColorHex: "#123456", + backgroundColor: .blue, + darkBackgroundColorHex: "#654321", + darkBackgroundColor: .black, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: .gated, + onDeviceCache: .enabled, + isScrollEnabled: false, + introOfferEligibility: .ineligible + ) + } + + func test_inheritsEverythingTheManifestCannotCarry() { + // The shipped manifest carries none of these, so a bound paywall keeps + // what the dashboard configured rather than the stub's defaults. + let dashboard = published() + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: dashboard) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 42, cornerRadius: 7)) + XCTAssertEqual(paywall.presentation.delay, 250) + XCTAssertEqual(paywall.backgroundColorHex, "#123456") + XCTAssertEqual(paywall.darkBackgroundColorHex, "#654321") + XCTAssertFalse(paywall.isScrollEnabled) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + } + + func test_takesItsIdentityFromThePaywallItStandsInFor() { + // One surface can serve several dashboard paywalls, and identity reaches + // analytics as paywall_id/paywall_identifier and keys the view controller + // cache β€” so it has to be the served paywall's, not the surface's. + let first = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "222", identifier: "pro_annual") + ) + XCTAssertEqual(first.databaseId, "222") + XCTAssertEqual(first.identifier, "pro_annual") + + let second = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "333", identifier: "pro_monthly") + ) + XCTAssertEqual(second.databaseId, "333") + XCTAssertEqual(second.identifier, "pro_monthly") + + // Distinct identities, so they can't collapse onto one cached controller. + XCTAssertNotEqual(first.identifier, second.identifier) + } + + /// A cached PaywallViewController is built from the settings once, so an + /// edited config.ts has to miss the cache rather than live-reload the page + /// inside the frame the old settings built. + func test_givesAChangedConfigItsOwnCacheKey() { + let modal = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"MODAL\" } }"), + url: url + ) + let fullscreen = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"FULLSCREEN\" } }"), + url: url + ) + let unchanged = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"MODAL\" } }"), + url: url + ) + + XCTAssertNotEqual(modal.cacheKey, fullscreen.cacheKey) + XCTAssertNotEqual(modal.cacheKey, Paywall.devServer(surface: surface(), url: url).cacheKey) + // Same config.ts, same key: an unrelated presentation still reuses the view. + XCTAssertEqual(modal.cacheKey, unchanged.cacheKey) + } + + func test_presentsFullscreenWhenNothingDeclaresAStyle() { + let paywall = Paywall.devServer(surface: surface(), url: url) + XCTAssertEqual(paywall.presentation.style, .fullscreen) + } + + // MARK: - What config.ts owns + + /// The settings block is the paywall's own config.ts, so it wins over the + /// published paywall β€” that is the whole point of serving it: a presentation + /// or background change shows on device with no push. + func test_takesEverySettingTheManifestDeclaresOverTheDashboards() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { + "presentation_style": { "type": "POPUP", "width": 80, "height": 60, "corner_radius": 15 }, + "feature_gating": "non_gated", + "introductory_offer_eligibility": "always_eligible", + "scroll_enabled": true, + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) + XCTAssertEqual(paywall.featureGating, .nonGated) + // The dashboard says ineligible; the local config.ts is what's being tried. + XCTAssertEqual(paywall.introOfferEligibility, .eligible) + XCTAssertTrue(paywall.isScrollEnabled) + XCTAssertEqual(paywall.backgroundColorHex, "#ffffff") + XCTAssertEqual(paywall.darkBackgroundColorHex, "#0d0f12") + // The colours are derived from the local hexes, not carried over with them. + XCTAssertEqual(paywall.backgroundColor, UIColor(hexString: "#ffffff")) + XCTAssertEqual(paywall.darkBackgroundColor, UIColor(hexString: "#0d0f12")) + } + + /// config.ts has no word for the loading delay, so overriding the style must + /// not silently reset the dashboard's timing. + func test_keepsThePublishedLoadingDelayWhenTheManifestChangesTheStyle() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertEqual(paywall.presentation.delay, 250) + } + + /// Each key stands alone: the block declaring a style says nothing about + /// gating, which still comes from the dashboard. + func test_inheritsTheSettingsTheBlockLeavesOut() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + XCTAssertFalse(paywall.isScrollEnabled) + XCTAssertEqual(paywall.backgroundColorHex, "#123456") + } + + /// A surface that has never been pushed has nothing to inherit, so its + /// config.ts is all there is β€” and it presents from it. + func test_presentsAnUnpushedSurfaceFromItsOwnConfig() { + let paywall = Paywall.devServer( + surface: surface(id: "draft", settings: """ + { + "presentation_style": { "type": "DRAWER", "height": 60, "corner_radius": 15 }, + "feature_gating": "gated", + "scroll_enabled": false + } + """), + url: url + ) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 60, cornerRadius: 15)) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertFalse(paywall.isScrollEnabled) + } + + /// The block reports the cache because push stamps it, and dev mode ignores + /// it either way β€” a live-reloading page must never come from the cache. + func test_stillDisablesTheCacheWhenTheManifestReportsItEnabled() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "on_device_cache": true, "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.onDeviceCache, .disabled) + } + + // MARK: - What the dashboard keeps owning + + /// The manifest can't express any of these, so a bound surface has to take + /// them from the paywall it stands in for or they vanish silently. + func test_inheritsDashboardOwnedBehaviourFromThePublishedPaywall() { + var published = Paywall.stub() + published.featureGating = .gated + published.surveys = [Survey.stub()] + + let paywall = Paywall.devServer( + surface: surface(), + url: url, + inheriting: published + ) + + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.surveys.count, 1) + } + + func test_neverServesALocalPageFromTheWebViewCache() { + // The dashboard configures onDeviceCache, but a dev server reloads on + // every edit, so an inherited .enabled could serve a stale local page. + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published()) + XCTAssertEqual(paywall.onDeviceCache, .disabled) + } + + func test_doesNotInheritNotificationsTheLocalPaywallDeclaresItself() { + // config.ts can declare notifications, and they arrive as + // `schedule_notification` messages. Inheriting the dashboard's would let + // a stale copy win NotificationScheduler's paywallId+type dedupe. + var published = Paywall.stub() + published.localNotifications = [LocalNotification.stub()] + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertTrue(paywall.localNotifications.isEmpty) + } + + func test_inheritsComputedPropertiesAndIntroOfferEligibility() throws { + let json = """ + { + "computedPropertyRequests": [ + { "type": "HOURS_SINCE", "eventName": "trigger1" } + ], + "introductoryOfferEligibility": "ALWAYS_INELIGIBLE" + } + """ + // Decoded rather than hand-built so the test pins the real dashboard shape. + struct Fields: Decodable { + let computedPropertyRequests: [ComputedPropertyRequest] + let introductoryOfferEligibility: IntroOfferEligibility + } + let fields = try JSONDecoder().decode(Fields.self, from: Data(json.utf8)) + + var published = Paywall.stub() + published = Paywall( + databaseId: published.databaseId, + identifier: published.identifier, + name: published.name, + cacheKey: published.cacheKey, + buildId: published.buildId, + url: published.url, + urlConfig: published.urlConfig, + htmlSubstitutions: published.htmlSubstitutions, + presentation: published.presentation, + backgroundColorHex: published.backgroundColorHex, + backgroundColor: published.backgroundColor, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + computedPropertyRequests: fields.computedPropertyRequests, + isScrollEnabled: true, + introOfferEligibility: fields.introductoryOfferEligibility + ) + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.computedPropertyRequests.count, 1) + XCTAssertEqual(paywall.introOfferEligibility, fields.introductoryOfferEligibility) + } + + func test_anUnboundSurfaceKeepsTheSafeDefaults() { + // The debugger previews surfaces with no dashboard counterpart, so there + // is nothing to inherit and gating must stay off rather than guess. + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertEqual(paywall.featureGating, .nonGated) + XCTAssertTrue(paywall.surveys.isEmpty) + XCTAssertTrue(paywall.localNotifications.isEmpty) + XCTAssertTrue(paywall.computedPropertyRequests.isEmpty) + XCTAssertEqual(paywall.introOfferEligibility, .automatic) + } + + func test_theLocalSurfaceStillOwnsWhatItRenders() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer( + surface: surface(products: ["plus": "local_product"]), + url: url, + inheriting: published + ) + + // The surface owns what it serves: its URL and its own products. + XCTAssertEqual(paywall.url, url) + XCTAssertEqual(paywall.productIds, ["local_product"]) + XCTAssertTrue(paywall.isLocal) + XCTAssertNil(paywall.manifest) + // Inherited behaviour rides along without dragging the published bytes in. + XCTAssertEqual(paywall.featureGating, .gated) + } + + func test_settingsTheSurfaceOmitsStillComeFromTheDashboard() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.featureGating, .gated) + } + +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift new file mode 100644 index 000000000..ae3cc604c --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -0,0 +1,171 @@ +// +// DevServerPreviewTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct DevServerPreviewTests { + private func options( + devServer: SuperwallOptions.DevServer? = .default + ) -> SuperwallOptions { + let options = SuperwallOptions() + options.devServer = devServer + return options + } + + private func surface(id: String, products: [String: String]?) throws -> DevServerSurface { + let productsJSON = products.map { dict in + "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" + } ?? "null" + let json = """ + {"kind": "paywall", "id": "\(id)", "url": "/preview/paywall/\(id)", "products": \(productsJSON)} + """ + return try JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + // MARK: - Resolving a dev: surface + + @Test("A dev: surface is resolved from the manifest the server serves now") + func resolveSurface_prefersTheFreshManifest() throws { + // The debugger opened while config.ts declared one product. It has since + // been edited to declare another, and the server's manifest says so. + let base = try #require(URL(string: "http://localhost:6100")) + let stale = try surface(id: "pro", products: ["primary": "old_product"]) + let edited = try surface(id: "pro", products: ["primary": "new_product"]) + + let resolved = try #require( + DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: DevServerLocation(base: base, manifest: DevServerManifest(surfaces: [edited])), + snapshot: (base: base, surfaces: [stale]) + ) + ) + #expect(resolved.surface == edited) + #expect(resolved.url.absoluteString == "http://localhost:6100/preview/paywall/pro") + } + + @Test("A dev: surface falls back to the debugger's snapshot when the server is unreachable") + func resolveSurface_fallsBackToTheSnapshot() throws { + let base = try #require(URL(string: "http://localhost:6100")) + let snapshotSurface = try surface(id: "pro", products: ["primary": "old_product"]) + + let resolved = try #require( + DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: nil, + snapshot: (base: base, surfaces: [snapshotSurface]) + ) + ) + #expect(resolved.surface == snapshotSurface) + #expect(resolved.url.absoluteString == "http://localhost:6100/preview/paywall/pro") + } + + @Test("A dev: surface the server no longer lists is not resolved from the snapshot") + func resolveSurface_freshManifestWithoutTheSurface_returnsNil() throws { + let base = try #require(URL(string: "http://localhost:6100")) + let removed = try surface(id: "pro", products: nil) + let other = try surface(id: "winback", products: nil) + + let resolved = DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: DevServerLocation(base: base, manifest: DevServerManifest(surfaces: [other])), + snapshot: (base: base, surfaces: [removed]) + ) + #expect(resolved == nil) + } + + @Test("Without a snapshot or a server there is nothing to resolve") + func resolveSurface_nothingToResolve() { + let resolved = DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: nil, + snapshot: nil + ) + #expect(resolved == nil) + } + + // MARK: - Deep link parsing + + @Test("Parses the base and surface from a dev link") + func outcome_parsesBaseAndSurface() throws { + let url = try #require( + URL(string: "myapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=pro") + ) + let outcome = try #require(DevServerPreview.outcomeForDeepLink(url: url)) + #expect(outcome.base.absoluteString == "http://localhost:6100") + #expect(outcome.surfaceId == "pro") + } + + // MARK: - Trusted bases + + @Test( + "Hosts superwall dev can print are trusted", + arguments: [ + "http://localhost:6100", + "http://127.0.0.1:6100", + "http://[::1]:6100", + "http://yusufs-macbook.local:6100", + "http://10.0.1.5:6100", + "http://172.20.10.2:6100", + "http://192.168.1.10:6100", + "http://169.254.5.5:6100" + ] + ) + func trustedBase_privateHosts(base: String) throws { + let url = try #require(URL(string: base)) + #expect(DevServerPreview.isTrustedBase(url, devServerURL: nil)) + } + + @Test( + "Arbitrary internet hosts are not trusted", + arguments: [ + "https://evil.example.com", + "http://8.8.8.8:6100", + "http://172.32.0.1:6100", + "http://10.0.0.1.evil.example.com:6100" + ] + ) + func trustedBase_publicHosts(base: String) throws { + let url = try #require(URL(string: base)) + #expect(!DevServerPreview.isTrustedBase(url, devServerURL: nil)) + } + + @Test("The developer-supplied devServerURL is trusted wherever it points") + func trustedBase_matchingDevServerURL() throws { + let devServerURL = try #require(URL(string: "https://tunnel.example.com:8443")) + let matching = try #require(URL(string: "https://tunnel.example.com:8443")) + let otherPort = try #require(URL(string: "https://tunnel.example.com:9999")) + #expect(DevServerPreview.isTrustedBase(matching, devServerURL: devServerURL)) + #expect(!DevServerPreview.isTrustedBase(otherPort, devServerURL: devServerURL)) + } + + // MARK: - canHandle + + @Test("A dev link is not Superwall's when no dev server is set") + func canHandle_devServerOff() throws { + let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) + #expect(!DevServerPreview.canHandle(url: url, options: options(devServer: nil))) + } + + @Test("A dev link pointing at a local host is Superwall's when a dev server is set") + func canHandle_devServerOnLocalHost() throws { + DevMode.isSandboxEnvironment = { true } + defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } + + let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) + #expect(DevServerPreview.canHandle(url: url, options: options())) + } + + @Test("A dev link pointing at an internet host is refused even with a dev server set") + func canHandle_devServerOnPublicHost() throws { + DevMode.isSandboxEnvironment = { true } + defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } + + let url = try #require(URL(string: "myapp://?superwall_dev=https://evil.example.com")) + #expect(!DevServerPreview.canHandle(url: url, options: options())) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift new file mode 100644 index 000000000..55a1b26ce --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -0,0 +1,213 @@ +// +// DevServerSettingsTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerSettingsTests: XCTestCase { + private func settings(_ json: String) throws -> DevServerSettings { + return try JSONDecoder().decode(DevServerSettings.self, from: Data(json.utf8)) + } + + private func surface(_ json: String) throws -> DevServerSurface { + return try JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + /// The block a current CLI serves for a paywall whose config.ts says nothing: + /// every key the push stamps, at its default. + func test_readsTheBlockAPaywallWithNoConfigGets() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic" + } + """) + + XCTAssertEqual(decoded.presentationStyle, .fullscreen) + XCTAssertEqual(decoded.featureGating, .nonGated) + XCTAssertEqual(decoded.introOfferEligibility, .automatic) + XCTAssertEqual(decoded.isScrollEnabled, true) + XCTAssertNil(decoded.backgroundColorHex) + XCTAssertNil(decoded.darkBackgroundColorHex) + } + + func test_readsEveryStyleTheCliCanSend() throws { + let cases: [(String, PaywallPresentationStyle)] = [ + ("{ \"type\": \"FULLSCREEN\" }", .fullscreen), + ("{ \"type\": \"MODAL\" }", .modal), + ("{ \"type\": \"PUSH\" }", .push), + ("{ \"type\": \"NO_ANIMATION\" }", .fullscreenNoAnimation), + ( + "{ \"type\": \"DRAWER\", \"height\": 60, \"corner_radius\": 15 }", + .drawer(height: 60, cornerRadius: 15) + ), + ( + "{ \"type\": \"POPUP\", \"width\": 80, \"height\": 60, \"corner_radius\": 15 }", + .popup(height: 60, width: 80, cornerRadius: 15) + ) + ] + + for (json, expected) in cases { + let decoded = try settings("{ \"presentation_style\": \(json) }") + XCTAssertEqual(decoded.presentationStyle, expected, json) + } + } + + func test_readsGatedAndTheBackgroundHexes() throws { + let decoded = try settings(""" + { + "feature_gating": "gated", + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + """) + + XCTAssertEqual(decoded.featureGating, .gated) + XCTAssertEqual(decoded.backgroundColorHex, "#ffffff") + XCTAssertEqual(decoded.darkBackgroundColorHex, "#0d0f12") + } + + /// Anything the block leaves out has to stay nil rather than default, since + /// nil is what tells `Paywall.devServer` to inherit. + func test_leavesEveryUnsaidSettingNil() throws { + let decoded = try settings("{}") + + XCTAssertNil(decoded.presentationStyle) + XCTAssertNil(decoded.featureGating) + XCTAssertNil(decoded.introOfferEligibility) + XCTAssertNil(decoded.isScrollEnabled) + XCTAssertNil(decoded.backgroundColorHex) + XCTAssertNil(decoded.darkBackgroundColorHex) + } + + /// A newer CLI naming a style this SDK has never heard of costs the paywall + /// its style, not the settings around it. + func test_dropsAStyleItCannotNameAndKeepsTheRest() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "HOLOGRAM" }, + "feature_gating": "gated", + "scroll_enabled": false + } + """) + + XCTAssertNil(decoded.presentationStyle) + XCTAssertEqual(decoded.featureGating, .gated) + XCTAssertEqual(decoded.isScrollEnabled, false) + } + + /// `NONE` is the push API's "use the dashboard's". The SDK reads it fine, it + /// just leaves the style to the published paywall, which is what a missing + /// style means here β€” so it isn't a value this SDK can't read. + func test_readsNoneAsInheritingTheDashboardsStyle() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "NONE" }, + "feature_gating": "gated" + } + """) + + XCTAssertNil(decoded.presentationStyle) + XCTAssertEqual(decoded.featureGating, .gated) + } + + /// Geometry the SDK can't trust is treated the same way: the CLI resolves + /// height and radius before serving them, so a partial one is unreadable + /// rather than something to guess a default for. + func test_dropsGeometryItCannotTrust() throws { + XCTAssertNil( + try settings("{ \"presentation_style\": { \"type\": \"DRAWER\" } }").presentationStyle + ) + XCTAssertNil( + try settings("{ \"presentation_style\": { \"type\": \"POPUP\", \"height\": 60 } }") + .presentationStyle + ) + } + + func test_readsEveryEligibilityTheCliCanSend() throws { + let cases: [(String, IntroOfferEligibility)] = [ + ("automatic", .automatic), + ("always_eligible", .eligible), + ("always_ineligible", .ineligible) + ] + + for (wire, expected) in cases { + let decoded = try settings("{ \"introductory_offer_eligibility\": \"\(wire)\" }") + XCTAssertEqual(decoded.introOfferEligibility, expected, wire) + } + } + + /// A value named by a newer CLI costs that setting alone, the same way an + /// unreadable style does β€” the paywall falls back to its published gating + /// and eligibility, and keeps everything else its config.ts declares. + func test_dropsAGatingOrEligibilityItCannotNameAndKeepsTheRest() throws { + let decoded = try settings(""" + { + "feature_gating": "sometimes", + "introductory_offer_eligibility": "maybe", + "scroll_enabled": false, + "background_color_hex": "#ffffff" + } + """) + + XCTAssertNil(decoded.featureGating) + XCTAssertNil(decoded.introOfferEligibility) + XCTAssertEqual(decoded.isScrollEnabled, false) + XCTAssertEqual(decoded.backgroundColorHex, "#ffffff") + } + + func test_aSurfaceFromAnOlderDevServerCarriesNoSettings() throws { + let decoded = try surface(""" + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" } + """) + + XCTAssertNil(decoded.settings) + } + + /// A setting the SDK can't name costs that setting alone: the surface keeps + /// its local code and the rest of the block. + func test_keepsASurfaceWhoseSettingIsUnnameable() throws { + let decoded = try surface(""" + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "settings": { "feature_gating": "sometimes", "scroll_enabled": false } + } + """) + + XCTAssertEqual(decoded.id, "pro") + XCTAssertNil(decoded.settings?.featureGating) + XCTAssertEqual(decoded.settings?.isScrollEnabled, false) + } + + /// A block the SDK can't decode at all β€” a key of the wrong type rather than + /// a value it can't name β€” is still dropped whole by the `try?` in + /// `DevServerSurface`, and still costs the surface only its settings: it + /// serves its local code and presents as its published version does. + func test_keepsASurfaceWhoseSettingsBlockIsMalformed() throws { + let decoded = try surface(""" + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "settings": { "scroll_enabled": "yes" } + } + """) + + XCTAssertEqual(decoded.id, "pro") + XCTAssertNil(decoded.settings) + } + + func test_stillRefusesASurfaceMissingItsIdentity() throws { + XCTAssertThrowsError(try surface(""" + { "kind": "paywall", "url": "/preview/paywall/pro" } + """)) + } +} diff --git a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift new file mode 100644 index 000000000..e420bc43f --- /dev/null +++ b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift @@ -0,0 +1,54 @@ +// +// IdentifyUserSwitchTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-17. +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +/// Identifying a different user resets the previous one from inside the +/// identity manager's queue. Anything that reset does must not wait on that +/// queue, or every later identity call hangs for the rest of the process. +/// +/// The identify-triggered reset is wired to the shared instance, so this runs +/// against it. The only assertion is that its queue still answers, which no +/// other suite's use of the instance can make false. +@Suite(.serialized) +struct IdentifyUserSwitchTests { + @Test("Switching user with cached MMP attribution doesn't hang the identity queue") + func switchingUserWithCachedAcquisitionAttributesKeepsTheQueueAlive() { + let superwall = Superwall.shared + let storage: Storage = superwall.dependencyContainer.storage + let identityManager: IdentityManager = superwall.dependencyContainer.identityManager + + // An install match that resolved on an earlier launch. It's install-scoped, + // so the reset re-applies it to the new user. + storage.save(["acquisition_source": JSON("test_network")], forType: MMPAcquisitionDataStorage.self) + defer { + storage.delete(MMPAcquisitionDataStorage.self) + } + + superwall.identify(userId: "switch-user-a") + superwall.identify(userId: "switch-user-b") + + // A read on the identity queue only returns if the reset above finished. + let queueAnswered = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = identityManager.userAttributes + queueAnswered.signal() + } + guard queueAnswered.wait(timeout: .now() + 3) == .success else { + // Anything else that touches the queue would hang too, so stop here. + Issue.record("the identity queue is hung") + return + } + // The reset re-applies the cached attribution to the new user. Its merge + // was queued from inside the identify block, so it lands ahead of this read. + #expect(identityManager.userAttributes["acquisition_source"] as? String == "test_network") + superwall.reset() + } +} diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift new file mode 100644 index 000000000..b6e68d92b --- /dev/null +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -0,0 +1,151 @@ +// +// SerialTaskCoordinatorTests.swift +// SuperwallKitTests +// +// Created by Yusuf TΓΆr on 11/09/2026. +// + +import Foundation +import Testing + +@testable import SuperwallKit + +extension SerialTaskCoordinator { + /// Waits for everything enqueued so far to finish. + fileprivate func drain() async { + await withCheckedContinuation { continuation in + enqueue { + continuation.resume() + } + } + } +} + +@Suite("SerialTaskCoordinator Tests") +struct SerialTaskCoordinatorTests { + /// Records the priority an operation ran at. + private actor PriorityRecorder { + private(set) var recorded: TaskPriority? + + func record(_ priority: TaskPriority) { + recorded = priority + } + } + + /// Records the order operations ran in and how many ran at the same time. + private actor Recorder { + private(set) var order: [Int] = [] + private(set) var maxRunningAtOnce = 0 + private var runningAtOnce = 0 + + func didStart(_ id: Int) { + order.append(id) + runningAtOnce += 1 + maxRunningAtOnce = max(maxRunningAtOnce, runningAtOnce) + } + + func didFinish() { + runningAtOnce -= 1 + } + } + + @Test("Operations run in the order they were enqueued") + func runsOperationsInOrder() async { + let coordinator = SerialTaskCoordinator() + let recorder = Recorder() + + for id in 0..<20 { + coordinator.enqueue { + await recorder.didStart(id) + await Task.yield() + await recorder.didFinish() + } + } + await coordinator.drain() + + let order = await recorder.order + let maxRunningAtOnce = await recorder.maxRunningAtOnce + #expect(order == Array(0..<20)) + #expect(maxRunningAtOnce == 1) + } + + @Test("Only one operation runs at a time when enqueued from many threads") + func runsOneOperationAtATimeAcrossThreads() async { + let coordinator = SerialTaskCoordinator() + let recorder = Recorder() + let operationCount = 200 + + await withCheckedContinuation { continuation in + let group = DispatchGroup() + + for id in 0.. [String: Any] { + override func getTemplateDevice(reportingUnknownFieldsAsNull: Bool = false) async -> [String: Any] { // Return mock device attributes without async calls return [ "publicApiKey": "test_key", diff --git a/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift index 8e9250e80..87f8f56bf 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift @@ -15,49 +15,35 @@ struct PaywallManagerLogicTests { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub(), oldPaywall: .stub(), - isPreloading: false, isForPresentation: false ) #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_samePaywallURLs_isPreloading() { + @Test func handleCachedPaywall_samePaywall_isForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub(), oldPaywall: .stub(), - isPreloading: true, - isForPresentation: false - ) - #expect(outcomes.isEmpty) - } - - @Test func handleCachedPaywall_samePaywallURLs_isNotPreloading() { - let outcomes = PaywallManagerLogic.handleCachedPaywall( - newPaywall: .stub(), - oldPaywall: .stub(), - isPreloading: false, isForPresentation: true ) - #expect(outcomes == [.setDelegate, .updatePaywall]) + #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_diffPaywallURLs_isNotPreloading() { + @Test func handleCachedPaywall_diffPaywall_isNotForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub().setting(\.url, to: URL(string: "https://twitter.com")!), oldPaywall: .stub() .setting(\.cacheKey, to: "123"), - isPreloading: false, - isForPresentation: true + isForPresentation: false ) - #expect(outcomes == [.replacePaywall, .loadWebView, .setDelegate]) + #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_diffPaywallURLs_isPreloading() { + @Test func handleCachedPaywall_diffPaywall_isForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub().setting(\.url, to: URL(string: "https://twitter.com")!), oldPaywall: .stub() .setting(\.cacheKey, to: "123"), - isPreloading: true, isForPresentation: true ) #expect(outcomes == [.replacePaywall, .loadWebView]) diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift index aefe3fa08..82e3f37a4 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift @@ -17,7 +17,6 @@ final class PaywallManagerMock: PaywallManager { for paywall: Paywall, isDebuggerLaunched: Bool, isForPresentation: Bool, - isPreloading: Bool, delegate: PaywallViewControllerDelegateAdapter? ) async throws -> PaywallViewController { if let getPaywallError = getPaywallError { diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift new file mode 100644 index 000000000..07978e1e4 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -0,0 +1,571 @@ +// +// PaywallSharedControllerAttributionTests.swift +// SuperwallKitTests +// + +// swiftlint:disable all + +import Foundation +import Testing +import Combine +import UIKit +@testable import SuperwallKit + +/// Lets a test say whether the view controller is on screen, and records +/// whether the web view was asked to load. +private final class ActivePaywallViewController: PaywallViewController { + var isOnScreen = false + var didLoadWebView: Bool { loadWebViewCount > 0 } + var loadWebViewCount = 0 + /// The delegate installed at the moment a load was asked for. + var delegateAtLoad: PaywallViewControllerDelegateAdapter? + + override var isActive: Bool { isOnScreen } + + override func loadWebView() { + loadWebViewCount += 1 + delegateAtLoad = delegate + } +} + +/// Stands in for the view controller UIKit would present from: the SDK's +/// `present` only needs the presenter to say the presentation finished. +private final class CompletingPresenter: UIViewController { + var presented: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated: Bool, + completion: (() -> Void)? = nil + ) { + presented = viewControllerToPresent + completion?() + } +} + +/// Counts occurrence saves instead of writing to Core Data. +private final class OccurrenceCountingCoreDataManager: CoreDataManager { + var savedOccurrences = 0 + + init() { + super.init(coreDataStack: CoreDataStackMock()) + } + + override func save( + triggerAudienceOccurrence audienceOccurence: TriggerAudienceOccurrence, + completion: ((ManagedTriggerRuleOccurrence) -> Void)? = nil + ) { + savedOccurrences += 1 + } +} + +/// Two campaigns share one paywall, so both requests get the one cached view +/// controller. Whatever a request reports for its presentation must all come +/// from that request, and a presentation that's on screen can't be changed by +/// another request. +@MainActor +struct PaywallSharedControllerAttributionTests { + private let dependencyContainer = DependencyContainer() + + /// The view controller only holds its storage and dependencies unowned, and + /// showing it starts tracking tasks that outlive the test, so anything it + /// depends on must live for the rest of the process. + private static var retained: [AnyObject] = [] + + private func paywall( + experimentId: String, + variantId: String, + source: String + ) -> Paywall { + var paywall = Paywall.stub() + paywall.experiment = Experiment( + id: experimentId, + groupId: "group", + variant: .init(id: variantId, type: .treatment, paywallId: paywall.identifier) + ) + paywall.presentationSourceType = source + return paywall + } + + private var sessionStartPaywall: Paywall { + paywall(experimentId: "180872", variantId: "632038", source: "implicit") + } + + private var campaignPaywall: Paywall { + paywall(experimentId: "181395", variantId: "633524", source: "register") + } + + private var embeddedPaywall: Paywall { + paywall(experimentId: "166736", variantId: "634019", source: "getPaywall") + } + + private func request( + placement: String, + type: PresentationRequestType = .presentation + ) -> PresentationRequest { + dependencyContainer.makePresentationRequest( + .implicitTrigger(PlacementData(name: placement, parameters: [:], createdAt: Date())), + isDebuggerLaunched: false, + isPaywallPresented: false, + type: type + ) + } + + private var getPaywallType: PresentationRequestType { + .getPaywall(PaywallViewControllerDelegateAdapter(swiftDelegate: nil, objcDelegate: nil)) + } + + private func cacheKey(for paywall: Paywall) -> String { + PaywallCacheLogic.key( + identifier: paywall.identifier, + locale: dependencyContainer.deviceHelper.localeIdentifier + ) + } + + private func claim( + _ viewController: PaywallViewController, + placement: String, + paywall: Paywall, + type: PresentationRequestType = .presentation, + unsavedOccurrence: TriggerAudienceOccurrence? = nil, + paywallStatePublisher: PassthroughSubject = .init() + ) { + viewController.set( + request: request(placement: placement, type: type), + paywall: paywall, + paywallStatePublisher: paywallStatePublisher, + unsavedOccurrence: unsavedOccurrence + ) + } + + /// Puts a view controller for `paywall` in the cache, claimed by `placement`. + private func cachedViewController( + for paywall: Paywall, + placement: String, + storage: Storage? = nil + ) -> ActivePaywallViewController { + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = SWWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let cache = dependencyContainer.paywallManager!.cache + let viewController = ActivePaywallViewController( + paywall: paywall, + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: storage ?? dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: cache, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + cache.save(viewController, forKey: cacheKey(for: paywall)) + claim(viewController, placement: placement, paywall: paywall) + return viewController + } + + /// Runs the appearance callbacks the way UIKit does when the app shows the view controller. + private func show(_ viewController: PaywallViewController) { + Self.retained.append(dependencyContainer) + viewController.viewWillAppear(false) + viewController.viewDidAppear(false) + } + + private func hide(_ viewController: PaywallViewController) { + viewController.viewWillDisappear(false) + viewController.viewDidDisappear(false) + } + + private func expectSessionStartAttribution(_ info: PaywallInfo) { + #expect(info.presentedByPlacementWithName == "session_start") + #expect(info.experiment?.id == "180872") + #expect(info.experiment?.variant.id == "632038") + #expect(info.presentationSourceType == "implicit") + } + + private func expectEmbeddedAttribution(_ info: PaywallInfo) { + #expect(info.presentedByPlacementWithName == "embedded") + #expect(info.experiment?.id == "166736") + #expect(info.experiment?.variant.id == "634019") + #expect(info.presentationSourceType == "getPaywall") + } + + // MARK: - Claiming + + @Test + func claimAppliesTheRequestsPaywall() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall) + + let info = viewController.info + #expect(info.presentedByPlacementWithName == "campaign_trigger") + #expect(info.experiment?.id == "181395") + #expect(info.experiment?.variant.id == "633524") + #expect(info.presentationSourceType == "register") + } + + @Test + func claimWithANewPaywallVersionLoadsThatVersion() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + #expect(!viewController.didLoadWebView) + + var newVersion = campaignPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "campaign_trigger", paywall: newVersion) + + #expect(viewController.paywall.cacheKey == "newVersion") + #expect(viewController.didLoadWebView) + #expect(viewController.info.experiment?.id == "181395") + } + + @Test + func reloadForANewVersionIsAnnouncedToTheClaimsDelegate() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + + #expect(viewController.delegateAtLoad != nil) + #expect(viewController.delegateAtLoad === viewController.delegate) + } + + @Test + func restoreWithANewVersionStartsOneLoadEvenAfterAFailedLoad() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + // The view is loaded before a paywall is ever shown, which does the first load. + viewController.loadViewIfNeeded() + viewController.loadWebViewCount = 0 + viewController.isOnScreen = true + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + viewController.isOnScreen = false + + // The web content process died while off screen. + viewController.webView.loadingHandler.didFailToLoad = true + viewController.viewWillAppear(false) + + #expect(viewController.loadWebViewCount == 1) + #expect(viewController.paywall.cacheKey == "newVersion") + } + + @Test + func claimWithTheSamePaywallVersionDoesNotReload() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall) + + #expect(!viewController.didLoadWebView) + #expect(viewController.info.experiment?.id == "181395") + } + + @Test + func restoredClaimWithANewPaywallVersionLoadsThatVersion() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + viewController.isOnScreen = true + + // The paywall is republished while on screen, and the app fetches it. + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + #expect(viewController.paywall.cacheKey == sessionStartPaywall.cacheKey) + #expect(!viewController.didLoadWebView) + + // The presentation ends and the app shows its handle. + viewController.isOnScreen = false + viewController.viewWillAppear(false) + + #expect(viewController.paywall.cacheKey == "newVersion") + #expect(viewController.didLoadWebView) + expectEmbeddedAttribution(viewController.info) + } + + @Test + func interleavedRequestsReportThePresentingRequest() async throws { + let paywallManager = try #require(dependencyContainer.paywallManager) + let paywallA = sessionStartPaywall + + // Both requests fetch the view controller before either presents. + let viewControllerA = try await paywallManager.getViewController( + for: paywallA, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + let viewControllerB = try await paywallManager.getViewController( + for: campaignPaywall, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + #expect(viewControllerB === viewControllerA) + + // Request A presents. Everything it reports must be A's. + claim(viewControllerA, placement: "session_start", paywall: paywallA) + + expectSessionStartAttribution(viewControllerA.info) + } + + // MARK: - While on screen + + @Test + func requestWhilePresentedLeavesViewControllerAlone() async throws { + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") + presented.isOnScreen = true + + // A main-campaign placement whose variant uses the same paywall fires + // while the session-start paywall is on screen. + let paywallManager = try #require(dependencyContainer.paywallManager) + let viewControllerB = try await paywallManager.getViewController( + for: campaignPaywall, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + + #expect(viewControllerB === presented) + expectSessionStartAttribution(presented.info) + } + + @Test + func presentedViewControllerIsNotReplacedByANewPaywallVersion() async throws { + let paywallA = sessionStartPaywall + let presented = cachedViewController(for: paywallA, placement: "session_start") + presented.isOnScreen = true + + // The paywall was republished, so the same request now resolves to a new + // version. The cache would normally swap it in and reload the web view. + var newVersion = campaignPaywall + newVersion.cacheKey = "newVersion" + let paywallManager = try #require(dependencyContainer.paywallManager) + let viewController = try await paywallManager.getViewController( + for: newVersion, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + + #expect(viewController === presented) + #expect(presented.paywall.cacheKey == paywallA.cacheKey) + #expect(!presented.didLoadWebView) + expectSessionStartAttribution(presented.info) + } + + @Test + func presentedViewControllerCannotBeClaimedByAnotherRequest() { + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") + presented.isOnScreen = true + + // `getPaywall` claims after fetching. If the view controller went on + // screen for another placement in between, the claim must not take it. + claim(presented, placement: "campaign_trigger", paywall: campaignPaywall) + + expectSessionStartAttribution(presented.info) + } + + // MARK: - Handed out via getPaywall + + @Test + func getPaywallClaimWhilePresentedIsRestoredWhenTheAppShowsIt() { + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") + presented.isOnScreen = true + + // The app calls `getPaywall` for the paywall that's on screen. It gets the + // view controller back, but the presentation must not change. + claim(presented, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + expectSessionStartAttribution(presented.info) + #expect(presented.delegate == nil) + + // The presentation ends and the app shows the view controller it holds. + presented.isOnScreen = false + presented.viewWillAppear(false) + + expectEmbeddedAttribution(presented.info) + #expect(presented.delegate != nil) + } + + @Test + func secondGetPaywallWhileTheAppShowsItDoesNotChangeThePresentation() { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + show(viewController) + viewController.isOnScreen = true + let delegate = viewController.delegate + + // The app calls `getPaywall` again for the same paywall while it's showing + // it, then something like Safari closing makes it appear again. + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall, type: getPaywallType) + viewController.viewWillAppear(false) + + expectEmbeddedAttribution(viewController.info) + #expect(viewController.delegate === delegate) + + // Once that presentation ends, the newer claim is what the app gets. + hide(viewController) + viewController.isOnScreen = false + viewController.viewWillAppear(false) + + #expect(viewController.info.presentedByPlacementWithName == "campaign_trigger") + #expect(viewController.info.experiment?.id == "181395") + } + + @Test + func handedOutViewControllerReportsItsOwnPlacementWhenTheAppShowsIt() { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + // The app fetched it with `getPaywall` and is holding on to it. + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + + // `register` presents the same paywall for another placement, then it's dismissed. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + viewController.isOnScreen = true + #expect(viewController.info.presentedByPlacementWithName == "session_start") + viewController.isOnScreen = false + + // The app now shows the view controller it was holding. + viewController.viewWillAppear(false) + + expectEmbeddedAttribution(viewController.info) + } + + /// The mirror of the test above: the same armed handle, but the SDK is the + /// one presenting, so the appearance belongs to the SDK's request. + @Test + func sdkPresentationReportsItsOwnPlacementOverAHandedOutClaim() { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + // The app fetched it with `getPaywall` and is holding on to it. + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + + // `register` presents the same paywall for another placement. + let presenter = CompletingPresenter() + viewController.present( + on: presenter, + request: request(placement: "session_start"), + paywall: sessionStartPaywall, + unsavedOccurrence: nil, + presentationStyleOverride: nil, + paywallStatePublisher: .init(), + completion: { _ in } + ) + #expect(presenter.presented === viewController) + + viewController.viewWillAppear(false) + + expectSessionStartAttribution(viewController.info) + } + + @Test + func handedOutOccurrenceIsSavedWhenTheAppFirstShowsIt() { + let coreDataManager = OccurrenceCountingCoreDataManager() + let storage = Storage(factory: dependencyContainer, cache: Cache(), coreDataManager: coreDataManager) + Self.retained.append(storage) + let viewController = cachedViewController( + for: embeddedPaywall, + placement: "embedded", + storage: storage + ) + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + unsavedOccurrence: .stub() + ) + + // `register` claims the paywall before the app has shown its handle. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + + show(viewController) + + #expect(coreDataManager.savedOccurrences == 1) + expectEmbeddedAttribution(viewController.info) + } + + @Test + func restoredClaimDoesNotSaveItsOccurrenceAgain() { + let coreDataManager = OccurrenceCountingCoreDataManager() + let storage = Storage(factory: dependencyContainer, cache: Cache(), coreDataManager: coreDataManager) + Self.retained.append(storage) + let viewController = cachedViewController( + for: embeddedPaywall, + placement: "embedded", + storage: storage + ) + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + unsavedOccurrence: .stub() + ) + + // The app shows and hides its handle, which saves the occurrence. + show(viewController) + hide(viewController) + #expect(coreDataManager.savedOccurrences == 1) + + // `register` claims the paywall, then the app shows its handle again. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + show(viewController) + + #expect(coreDataManager.savedOccurrences == 1) + expectEmbeddedAttribution(viewController.info) + } + + @Test + func restoredClaimGetsAFreshStatePublisherAfterTheOldOneCompleted() async throws { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + let publisher = PassthroughSubject() + final class Completion { var done = false } + let completion = Completion() + let subscription = publisher.sink( + receiveCompletion: { _ in completion.done = true }, + receiveValue: { _ in } + ) + defer { subscription.cancel() } + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + paywallStatePublisher: publisher + ) + + // The app shows and hides its handle. Dismissal completes the publisher. + show(viewController) + hide(viewController) + for _ in 0..<200 where !completion.done { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(completion.done) + + // `register` claims the paywall, then the app shows its handle again. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + show(viewController) + + let stored = try #require(Superwall.shared.presentationItems.last) + #expect(stored.request.presentationInfo.placementName == "embedded") + #expect(stored.statePublisher !== publisher) + expectEmbeddedAttribution(viewController.info) + } + + @Test + func appearingWithoutAHandedOutClaimKeepsTheCurrentRequest() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + viewController.viewWillAppear(false) + + expectSessionStartAttribution(viewController.info) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift index 5061dee3f..87084cbb4 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift @@ -67,6 +67,7 @@ final class PresentPaywallOperatorTests { do { _ = try await Superwall.shared.presentPaywallViewController( paywallVc, + paywall: .stub(), on: UIViewController(), unsavedOccurrence: nil, debugInfo: [:], @@ -135,6 +136,7 @@ final class PresentPaywallOperatorTests { do { _ = try await Superwall.shared.presentPaywallViewController( paywallVc, + paywall: .stub(), on: UIViewController(), unsavedOccurrence: nil, debugInfo: [:], diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift index 0ce75bde5..94643afa3 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift @@ -15,6 +15,7 @@ final class PaywallViewControllerMock: PaywallViewController { override func present( on presenter: UIViewController, request: PresentationRequest, + paywall: Paywall, unsavedOccurrence: TriggerAudienceOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: PassthroughSubject, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 22de441dd..23a247015 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -24,14 +24,17 @@ final class FakePermissionHandler: PermissionHandling { final class FakeWebView: SWWebView { var willHandleJs = false + var evaluatedScripts: [String] = [] #if compiler(>=6.0) override func evaluateJavaScript(_ javaScriptString: String, completionHandler: (@MainActor (Any?, (any Error)?) -> Void)? = nil) { willHandleJs = true + evaluatedScripts.append(javaScriptString) } #else override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, (any Error)?) -> Void)? = nil) { willHandleJs = true + evaluatedScripts.append(javaScriptString) } #endif } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index 7abccfa77..eb1a94281 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -27,6 +27,107 @@ struct PaywallMessageHandlerTests { return webView.willHandleJs } + /// Decodes the `event_name`s of every `accept64` message passed to the webview. + private func passedEvents(in webView: FakeWebView) -> [[String: Any]] { + return webView.evaluatedScripts.flatMap { script -> [[String: Any]] in + guard + let start = script.range(of: "accept64('"), + let end = script.range(of: "')", range: start.upperBound.. [String: Any]? { + for _ in 0..<250 { + if let event = passedEvents(in: webView).first(where: { $0["event_name"] as? String == name }) { + return event + } + try? await Task.sleep(nanoseconds: 20_000_000) + } + return nil + } + + private func makeHandler() -> (PaywallMessageHandler, FakeWebView, PaywallMessageHandlerDelegateMock) { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + return (messageHandler, webView, delegate) + } + + // Regression: the paywall schedules its trial reminder off `freeTrial_start`, so a purchase + // that didn't start a trial (e.g. the user already used it) must not send that message. + @Test + func transactionComplete_withoutTrial_doesNotSendFreeTrialStart() async { + let (messageHandler, webView, delegate) = makeHandler() + + messageHandler.handle( + .transactionComplete( + trialEndDate: nil, + productIdentifier: "product1", + didStartFreeTrial: false + ) + ) + + let complete = await waitForEvent(named: "transaction_complete", in: webView) + #expect(complete?["product_identifier"] as? String == "product1") + + // transaction_complete is sent first in the same task, so give freeTrial_start a + // chance to arrive before asserting it never did. + try? await Task.sleep(nanoseconds: 300_000_000) + let names = passedEvents(in: webView).compactMap { $0["event_name"] as? String } + #expect(!names.contains("freeTrial_start")) + + // The handler's delegate is weak and the webview is reached through it, so the + // mock has to outlive the waits above. + withExtendedLifetime(delegate) {} + } + + @Test + func transactionComplete_withTrial_sendsFreeTrialStartWithEndDate() async { + let (messageHandler, webView, delegate) = makeHandler() + let trialEndDate = Date(timeIntervalSince1970: 1_800_000_000) + + messageHandler.handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: "product1", + didStartFreeTrial: true + ) + ) + + let complete = await waitForEvent(named: "transaction_complete", in: webView) + #expect(complete != nil) + + let freeTrialStart = await waitForEvent(named: "freeTrial_start", in: webView) + #expect(freeTrialStart?["product_identifier"] as? String == "product1") + #expect(freeTrialStart?["trial_end_date"] as? Int == 1_800_000_000_000) + + withExtendedLifetime(delegate) {} + } + @Test func handleTemplateParams() async { let dependencyContainer = DependencyContainer() diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift new file mode 100644 index 000000000..6672def34 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -0,0 +1,567 @@ +// +// GrantedEntitlementsTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-01. +// +// swiftlint:disable all + +@testable import SuperwallKit +import Combine +import Testing +import Foundation + +@Suite(.serialized) +final class GrantedEntitlementsTests { + private let dependencyContainer: DependencyContainer + private let superwall: Superwall + + init() { + // In-memory storage, so nothing this suite writes reaches the on-disk + // store shared with other suites. GrantedEntitlements is app-specific + // and read on every status publish, so a file left behind mid-test would + // leak into suites running in parallel. + dependencyContainer = DependencyContainer(cache: CacheMock()) + superwall = Superwall(dependencyContainer: dependencyContainer) + } + + // MARK: - Helpers + + /// A bare developer-granted entitlement: no transaction history, no store. + private func grantedEntitlement( + id: String = "granted", + isActive: Bool = true, + expiresAt: Date? = nil + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + expiresAt: expiresAt + ) + } + + /// A device entitlement enriched with transaction metadata. + private func deviceEntitlement( + id: String = "premium", + isActive: Bool = true + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + productIds: ["com.example.monthly"], + latestProductId: "com.example.monthly", + store: .appStore, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + expiresAt: Date(timeIntervalSince1970: 1_800_000_000), + isLifetime: false, + willRenew: true, + state: .subscribed + ) + } + + private func activeIds(_ status: SubscriptionStatus) -> Set? { + if case .active(let entitlements) = status { + return Set(entitlements.map(\.id)) + } + return nil + } + + // MARK: - Merging + + @Test + func settingGranted_activatesInactiveStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement()] + + #expect(superwall.subscriptionStatus.isActive) + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) + } + + @Test + func settingGranted_mergesWithActiveStatus() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] + + #expect(activeIds(superwall.subscriptionStatus) == ["premium", "granted"]) + } + + @Test + func unknownStatus_staysUnknownWithGranted() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement()] + + // Grants don't decide a status that hasn't been determined β€” reporting + // active here would let a paywall present before a purchase controller + // has answered. + #expect(superwall.subscriptionStatus == .unknown) + } + + @Test + func grantsSetWhileUnknown_mergeOnceTheStatusIsKnown() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.subscriptionStatus == .unknown) + + superwall.subscriptionStatus = .inactive + + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) + } + + @Test + func inactiveGrantedOnly_doesNotActivateStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] + + // The status stays .inactive, not an .active-cased status that's + // effectively inactive. + #expect(superwall.subscriptionStatus == .inactive) + } + + @Test + func inactiveGrant_doesNotEnterActiveSet() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "extra", isActive: false)] + + #expect(activeIds(superwall.subscriptionStatus) == ["premium"]) + } + + @Test + func inactiveGrantedOnly_leavesUnknownStatusUnknown() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] + + #expect(superwall.subscriptionStatus == .unknown) + } + + @Test + func assigningInactive_keepsGrantedActive() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([deviceEntitlement()]) + + superwall.subscriptionStatus = .inactive + + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) + } + + @Test + func emptyActiveAssignment_stillMergesGranted() { + // The empty-.active β†’ .inactive collapse must run after the granted + // merge, not before it. + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([]) + + #expect(superwall.subscriptionStatus.isActive) + } + + @Test + func grantsSharingAnId_publishOneRecord() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [ + grantedEntitlement(id: "pro", expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + grantedEntitlement(id: "pro", expiresAt: Date(timeIntervalSince1970: 1_900_000_000)) + ] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + // The later expiry wins the merge. + #expect(entitlements.first?.expiresAt == Date(timeIntervalSince1970: 1_900_000_000)) + } else { + Issue.record("Expected .active status") + } + } + + // MARK: - Clearing + + @Test + func clearingGranted_restoresAssignedStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.subscriptionStatus.isActive) + + superwall.grantedEntitlements = [] + + #expect(superwall.subscriptionStatus == .inactive) + } + + @Test + func replacingGranted_replacesOutright() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement(id: "a")] + superwall.grantedEntitlements = [grantedEntitlement(id: "b")] + + #expect(activeIds(superwall.subscriptionStatus) == ["b"]) + } + + @Test + func writingBackThePublishedStatus_keepsGrantsRevocable() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement()] + + // A read-modify-write of the published, grant-merged status must not + // hand the grant back as the developer's own. + superwall.subscriptionStatus = superwall.subscriptionStatus + #expect( + dependencyContainer.storage.get(SubscriptionStatusKey.self) + == .active([deviceEntitlement(id: "premium")]) + ) + + superwall.grantedEntitlements = [] + + #expect(activeIds(superwall.subscriptionStatus) == ["premium"]) + } + + @Test + func writingBackACollidingGrant_keepsItRevocable() { + // A lapsed subscription and a grant for the same ID: the grant wins the + // merge but comes back carrying the lapsed record's product IDs, so it + // no longer equals the grant it came from. + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium", isActive: false)]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + superwall.subscriptionStatus = superwall.subscriptionStatus + superwall.grantedEntitlements = [] + + #expect(!superwall.subscriptionStatus.isActive) + } + + // MARK: - Merge precedence + + @Test + func granted_losesToActiveDeviceEntitlementWithHistory() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + // The device entitlement's transaction metadata must be kept. + #expect(entitlements.first?.latestProductId == "com.example.monthly") + #expect(entitlements.first?.store == .appStore) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func activeGranted_beatsExpiredDeviceEntitlement() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium", isActive: false)]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + #expect(superwall.subscriptionStatus.isActive) + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + #expect(entitlements.first?.isActive == true) + // The granted record wins wholesale β€” it mustn't inherit the expired + // transaction's metadata and masquerade as a store subscription. + #expect(entitlements.first?.latestProductId == nil) + } else { + Issue.record("Expected .active status") + } + } + + // MARK: - Idempotence + + @Test + func reassigningPublishedStatus_isStable() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] + + let published = superwall.subscriptionStatus + superwall.subscriptionStatus = published + + // merge(merge(x)) == merge(x): re-assigning an already-merged value + // must settle, not loop or grow. + #expect(superwall.subscriptionStatus == published) + } + + // MARK: - Persistence + + @Test + func persistedStatus_isAssignedValue() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .inactive + + // The published status is merged, but the persisted one must be the + // assigned value β€” otherwise granted entitlements are baked into the + // restored value and can never be cleared after a relaunch. + #expect(superwall.subscriptionStatus.isActive) + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == .inactive) + } + + @Test + func clearingGranted_afterRestore_restoresAssignedStatus() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .inactive + #expect(superwall.subscriptionStatus.isActive) + + // Simulate a relaunch: a fresh instance restores the persisted status, + // exactly as Superwall's configure init does. + let relaunched = Superwall(dependencyContainer: dependencyContainer) + relaunched.setSubscriptionStatus( + assigned: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + ) + #expect(relaunched.subscriptionStatus.isActive) + + relaunched.grantedEntitlements = [] + + #expect(relaunched.subscriptionStatus == .inactive) + } + + @Test + func concurrentAssignments_neverPersistGrantedIntoAssignedStatus() async { + superwall.grantedEntitlements = [grantedEntitlement()] + + // Race external assignments from many threads. Without the lock, a store + // landing mid-publish could capture another thread's merged write-back + // as its assigned status, persisting granted entitlements into + // SubscriptionStatusKey. + await withTaskGroup(of: Void.self) { group in + for index in 0..<50 { + group.addTask { [superwall, deviceEnt = deviceEntitlement(id: "premium")] in + superwall.subscriptionStatus = index.isMultiple(of: 2) + ? .inactive + : .active([deviceEnt]) + } + } + } + + // The persisted status must be exactly one of the assigned values β€” + // any merged value means a write-back was captured as the assigned status. + let persistedStatus = dependencyContainer.storage.get(SubscriptionStatusKey.self) + #expect( + persistedStatus == .inactive + || persistedStatus == .active([deviceEntitlement(id: "premium")]) + ) + // The in-memory assigned status must be clean too: clearing the grant + // must leave a status without it. + superwall.grantedEntitlements = [] + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["premium"]) + } + } + + @Test + func granted_survivesStorageReset() { + superwall.grantedEntitlements = [grantedEntitlement()] + + dependencyContainer.storage.reset() + + #expect(superwall.grantedEntitlements.map(\.id) == ["granted"]) + } + + // MARK: - CustomerInfo + + @Test + func granted_reachesCustomerInfoForExternalPurchaseController() { + superwall.grantedEntitlements = [grantedEntitlement()] + + let customerInfo = CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: .inactive, + granted: superwall.grantedEntitlements + ) + + #expect(customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) + } + + @Test + func preservingExternalControllerEntitlements_keepsGrantCollidingWithExpiredDevice() { + let expiredDevice = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium", isActive: false)] + ) + // The controller's current customer info also carries "premium", so + // the externalOnly filter drops it β€” the grant must survive as its own + // source rather than ride in through that filter. + let current = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium", isActive: false)] + ) + + let merged = CustomerInfo.preservingExternalControllerEntitlements( + device: expiredDevice, + web: nil, + current: current, + granted: [grantedEntitlement(id: "premium")] + ) + + #expect(merged.entitlements.count == 1) + #expect(merged.entitlements.first?.isActive == true) + #expect(merged.entitlements.first?.latestProductId == nil) + } + + @Test + func externalController_customerInfoFollowsStatusAndGrants() { + // The suite's own container uses the internal controller, so this is the + // only test that exercises the external-controller recompute wired into + // publishSubscriptionStatus. + let container = DependencyContainer( + purchaseController: MockPurchaseController(), + cache: CacheMock() + ) + let superwall = Superwall(dependencyContainer: container) + + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(Set(superwall.customerInfo.entitlements.map(\.id)) == ["premium", "granted"]) + + // Clearing recomputes too, even though the status stays active. + superwall.grantedEntitlements = [] + + #expect(superwall.customerInfo.entitlements.map(\.id) == ["premium"]) + } + + @Test + func grantChange_refreshesCustomerInfoOnAutomaticPath() { + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) + + superwall.grantedEntitlements = [] + #expect(!superwall.customerInfo.entitlements.contains { $0.id == "granted" }) + } + + @Test + func grantsBeforeTheDeviceSnapshot_keepCustomerInfoAsPlaceholder() { + #expect(superwall.customerInfo.isPlaceholder) + + superwall.grantedEntitlements = [grantedEntitlement()] + + // Grants alone don't mean the device has been read, so consumers that + // wait for real data keep waiting. + #expect(superwall.customerInfo.isPlaceholder) + #expect(superwall.customerInfo.entitlements.map(\.id) == ["granted"]) + } + + @Test + func merging_treatsGrantedAsASource() { + let device = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium")] + ) + + let merged = device.merging(with: .blank(), granting: [grantedEntitlement()]) + + #expect(merged.entitlements.map(\.id) == ["granted", "premium"]) + #expect(!merged.isPlaceholder) + } + + // MARK: - Publishing + + @Test + func sdkAssignment_publishesOnlyTheMergedValue() { + superwall.grantedEntitlements = [grantedEntitlement()] + + var published: [SubscriptionStatus] = [] + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { published.append($0) } + defer { cancellable.cancel() } + + superwall.setSubscriptionStatus(assigned: .active([deviceEntitlement(id: "premium")])) + + #expect(published.count == 1) + #expect(published.first.flatMap(activeIds) == ["premium", "granted"]) + } + + @Test + func developerAssignment_publishesOnlyTheMergedValue() { + superwall.grantedEntitlements = [grantedEntitlement()] + + var published: [SubscriptionStatus] = [] + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { published.append($0) } + defer { cancellable.cancel() } + + // The public setter must never publish the raw assigned value first. + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(published.count == 1) + #expect(published.first.flatMap(activeIds) == ["premium", "granted"]) + } + + @Test + func assignmentFromASubscriber_isApplied() { + var didReassign = false + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] status in + // A subscriber that reacts to the first change by assigning again, + // synchronously, from inside the emission. + if !didReassign, case .active = status { + didReassign = true + superwall.subscriptionStatus = .inactive + } + } + defer { cancellable.cancel() } + + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(superwall.subscriptionStatus == .inactive) + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == .inactive) + } + + @Test + func inactiveWritesWithGrant_doNotNotifyDelegate() async { + // The device has answered, so the grant has a determined status to + // merge into. Set before listening so only the grant is observed. + superwall.setSubscriptionStatus(assigned: .inactive) + + let delegate = MockSuperwallDelegate() + dependencyContainer.delegateAdapter.swiftDelegate = delegate + superwall.listenToSubscriptionStatus() + + superwall.grantedEntitlements = [grantedEntitlement()] + await waitUntil { delegate.subscriptionStatusChanges.count == 1 } + #expect(delegate.subscriptionStatusChanges.count == 1) + + // A device poll reporting no subscription, and a direct .inactive + // assignment, both publish the granted status again. The delegate + // must not hear about either. + superwall.setSubscriptionStatus(assigned: .inactive) + superwall.subscriptionStatus = .inactive + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(delegate.subscriptionStatusChanges.count == 1) + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping () -> Bool + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + + // MARK: - Event parameters + + @Test + func statusChangeEvent_includesGrantedEntitlementIds() async { + let granted = grantedEntitlement(id: "granted") + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: .active([deviceEntitlement(id: "premium"), granted]), + grantedEntitlements: [granted] + ) + + let params = await event.getSuperwallParameters() + + #expect(params["granted_entitlement_ids"] as? String == "granted") + } + + @Test + func statusChangeEvent_omitsGrantedParamWhenNoneGranted() async { + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: .active([deviceEntitlement(id: "premium")]) + ) + + let params = await event.getSuperwallParameters() + + #expect(params["granted_entitlement_ids"] == nil) + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift new file mode 100644 index 000000000..813810dad --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift @@ -0,0 +1,126 @@ +// +// ReceiptManagerPurchasedProductFetchTests.swift +// SuperwallKitTests +// +// `loadPurchasedProducts` runs before `configState` is published, so every +// `register` call at cold launch waits on it. StoreKit 2 transactions carry +// their subscription group ID, so the active groups come from the snapshot and +// the purchased-product fetch (a network round trip) is skipped. StoreKit 1 +// receipts don't carry the group ID, so SK1 still fetches. +// + +import Foundation +import Testing +@testable import SuperwallKit + +struct ReceiptManagerPurchasedProductFetchTests { + // Held for the lifetime of each test: `ReceiptManager` keeps an `unowned` + // reference to its factory, so the container must outlive the manager. + let dependencyContainer = DependencyContainer() + + private func makeReceiptManager( + loadsSubscriptionGroupsFromProducts: Bool + ) -> (manager: ReceiptManager, fetcher: CountingProductsFetcher, productsManager: ProductsManager) { + let fetcher = CountingProductsFetcher(entitlementsInfo: dependencyContainer.entitlementsInfo) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: fetcher + ) + let receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: SnapshotReceiptManagerType( + loadsSubscriptionGroupsFromProducts: loadsSubscriptionGroupsFromProducts + ), + receiptDelegate: nil, + factory: dependencyContainer, + storage: dependencyContainer.storage + ) + return (receiptManager, fetcher, productsManager) + } + + @Test("StoreKit 2 skips the purchased-product fetch and reads groups from the transactions") + func storeKit2SkipsProductFetch() async { + let (manager, fetcher, productsManager) = makeReceiptManager(loadsSubscriptionGroupsFromProducts: false) + _ = productsManager + + await manager.loadPurchasedProducts(config: .stub()) + + #expect(fetcher.fetchCount == 0) + // The active group still gates the trial, so it was read off the transaction. + let gold = StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.app.gold", subscriptionGroupIdentifier: "group_A") + ) + #expect(await manager.isFreeTrialAvailable(for: gold) == false) + } + + @Test("StoreKit 1 still fetches the purchased products") + func storeKit1FetchesProducts() async { + let (manager, fetcher, productsManager) = makeReceiptManager(loadsSubscriptionGroupsFromProducts: true) + _ = productsManager + + await manager.loadPurchasedProducts(config: .stub()) + + #expect(fetcher.fetchCount == 1) + } +} + +/// Counts product fetches instead of hitting StoreKit. +private final class CountingProductsFetcher: ProductsFetcherSK1 { + private(set) var fetchCount = 0 + + override func products( + identifiers: Set, + forPaywall paywall: Paywall?, + placement: PlacementData? + ) async throws -> Set { + fetchCount += 1 + return [] + } +} + +/// Returns one active subscription in `group_A`, the way an SK2 snapshot would. +private final class SnapshotReceiptManagerType: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts: Bool + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(loadsSubscriptionGroupsFromProducts: Bool) { + self.loadsSubscriptionGroupsFromProducts = loadsSubscriptionGroupsFromProducts + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + let silver = SubscriptionTransaction( + transactionId: "1", + productId: "com.app.silver", + purchaseDate: Date(), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: Date().addingTimeInterval(3600), + subscriptionGroupId: "group_A" + ) + purchases = [Purchase(id: "com.app.silver", isActive: true, purchaseDate: Date())] + return PurchaseSnapshot( + purchases: purchases, + customerInfo: CustomerInfo(subscriptions: [silver], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index 9c01fcf65..5037e54e0 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -110,6 +110,68 @@ struct ReceiptManagerTrialEligibilityTests { #expect(await manager.isFreeTrialAvailable(for: gold) == true) } + /// Stands in for the purchases load config was published ahead of. + private func slowPurchasesLoad(seconds: TimeInterval) -> Task { + return Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + } + + @Test("A product with no subscription group answers without waiting for the load") + func doesNotWaitForLoadWithoutSubscriptionGroup() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let consumable = makeProduct(id: "com.app.coins", subscriptionGroup: nil) + let load = slowPurchasesLoad(seconds: 2) + + let startedAt = Date() + let isAvailable = await manager.isFreeTrialAvailable(for: consumable, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(isAvailable == true) + #expect(waited < 1, "waited \(waited)s on a load that can't change the answer") + load.cancel() + } + + @Test("An intro-ineligible customer answers without waiting for the load") + func doesNotWaitForLoadWhenIntroIneligible() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: false, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + let load = slowPurchasesLoad(seconds: 2) + + let startedAt = Date() + let isAvailable = await manager.isFreeTrialAvailable(for: gold, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(isAvailable == false) + #expect(waited < 1, "waited \(waited)s on a load that can't change the answer") + load.cancel() + } + + @Test("The upgrade check waits for the load that computes the active groups") + func waitsForLoadBeforeCheckingActiveGroups() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + let load = slowPurchasesLoad(seconds: 0.5) + + let startedAt = Date() + _ = await manager.isFreeTrialAvailable(for: gold, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(waited >= 0.5, "answered from active groups the load hadn't computed yet") + } + @Test("No trial when StoreKit reports the customer is intro-ineligible") func noTrialWhenIneligible() async { // Ineligible short-circuits before the active-subscription check. @@ -208,6 +270,7 @@ struct ReceiptManagerTrialEligibilityTests { /// Minimal `ReceiptManagerType` whose `isEligibleForIntroOffer` is fully controlled, /// so tests can isolate `ReceiptManager`'s upgrade/crossgrade gating logic. private final class MockReceiptManagerType: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts = false let isEligibleForIntroOfferResult: Bool var purchases: Set = [] var transactionReceipts: [TransactionReceipt] = [] @@ -221,6 +284,10 @@ private final class MockReceiptManagerType: ReceiptManagerType { func loadIntroOfferEligibility(forProducts _: Set) async {} + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { return PurchaseSnapshot( purchases: [], diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift new file mode 100644 index 000000000..5ee799249 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift @@ -0,0 +1,274 @@ +// +// EntitlementUnknownFieldsTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 07/09/2026. +// +// swiftlint:disable all + +import Foundation +import Superscript +import Testing +@testable import SuperwallKit + +/// An entitlement that a Purchase Controller supplies carries no renewal +/// metadata. These tests pin down that "we don't know" reaches an audience +/// filter as null rather than as an absent key, because a filter gives an +/// absent key the type default and so reads a dropped `Bool?` as `false`. +@Suite(.serialized) +struct EntitlementUnknownFieldsTests { + /// The same trip an entitlement makes on its way to a filter: + /// `JSONEncoder` -> `JSONSerialization` -> `[String: Any]`. + private func encodedDictionary( + _ entitlement: Entitlement, + reportingUnknownFieldsAsNull: Bool = true + ) throws -> [String: Any] { + let encoder = reportingUnknownFieldsAsNull + ? JSONEncoder.reportingUnknownFieldsAsNull() + : JSONEncoder() + let data = try encoder.encode(entitlement) + let object = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + return try #require(object as? [String: Any]) + } + + /// One container for the suite: building one spins up real storage and a + /// persistent container, which the sibling `CELEvaluatorTests` resets for the + /// same reason. + private static let dependencyContainer: DependencyContainer = { + let container = DependencyContainer() + container.storage.reset() + return container + }() + + private func evaluate(_ expression: String, entitlement: Entitlement) throws -> Bool { + let attributes: [String: Any] = [ + "device": [ + "customerInfo": [ + "entitlements": [try encodedDictionary(entitlement)] + ] + ] + ] + + var variablesMap: [String: PassableValue] = [:] + if case let PassableValue.map(dictionary) = toPassableValue(from: attributes) { + variablesMap = dictionary + } + + // Mirrors what CELEvaluator passes in production. + let computedProperties = Dictionary(uniqueKeysWithValues: + ComputedPropertyRequestType.allCases.map { + ($0.description, [PassableValue.string("event_name")]) + } + ) + let executionContext = ExecutionContext( + variables: PassableMap(map: variablesMap), + computed: computedProperties, + device: computedProperties, + expression: expression + ) + let jsonData = try JSONEncoder().encode(executionContext) + let jsonString = try #require(String(data: jsonData, encoding: .utf8)) + + let output = evaluateWithContext( + definition: jsonString, + context: EvaluationContext(storage: Self.dependencyContainer.storage) + ) + + // Decoded rather than compared byte for byte, so a future Superscript bump + // that reshapes the wrapper doesn't read as a behaviour change. + let outputData = try #require(output.data(using: .utf8)) + let result = try JSONDecoder().decode(EvaluationResult.self, from: outputData) + guard case let .success(value) = result, + case let .bool(matched) = value else { + Issue.record("Expected a boolean result, got \(output)") + return false + } + return matched + } + + /// The audience from the incident: "active, and will not renew". + private let willNotRenewFilter = """ + device.customerInfo.entitlements.exists(e, e.isActive == true && e.willRenew == false) + """ + + @Test func unknownWillRenewIsEncodedAsExplicitNull() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary.keys.contains("willRenew")) + #expect(dictionary["willRenew"] is NSNull) + } + + @Test func knownWillRenewIsEncodedAsItsValue() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: false) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary["willRenew"] as? Bool == false) + } + + /// Everything that isn't an audience filter keeps the shape it always had, so + /// the enrichment request, paywall variables, session attributes and + /// `getDeviceAttributes()` don't start carrying nulls. + @Test func unknownFieldsStayOmittedForEveryOtherConsumer() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement, reportingUnknownFieldsAsNull: false) + + for key in ["willRenew", "isLifetime", "state", "offerType", "latestProductId", "store"] { + #expect(dictionary.keys.contains(key) == false, "\(key) should be omitted") + } + } + + /// Dates are deliberately left out rather than nulled: filters compare them + /// with `<` and `>`, and null on either side of an ordering comparison makes + /// the whole filter evaluate to null. + @Test func unknownDatesAreLeftOut() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary.keys.contains("expiresAt") == false) + #expect(dictionary.keys.contains("startsAt") == false) + #expect(dictionary.keys.contains("renewedAt") == false) + } + + /// The flag is set on the encoder for the whole device template, and the + /// entitlements sit two levels down inside it. This pins that `userInfo` + /// reaches a nested encoder, which is what the wiring depends on. + @Test func theNullFlagReachesNestedEntitlements() throws { + let customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "unlimited_access", isActive: true)], + isPlaceholder: false + ) + + func willRenewEntry(using encoder: JSONEncoder) throws -> (present: Bool, isNull: Bool) { + let data = try encoder.encode(customerInfo) + let object = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + let dictionary = try #require(object as? [String: Any]) + let entitlements = try #require(dictionary["entitlements"] as? [[String: Any]]) + let entitlement = try #require(entitlements.first) + return (entitlement.keys.contains("willRenew"), entitlement["willRenew"] is NSNull) + } + + let forFilters = try willRenewEntry(using: .reportingUnknownFieldsAsNull()) + #expect(forFilters.present) + #expect(forFilters.isNull) + + let forEveryoneElse = try willRenewEntry(using: JSONEncoder()) + #expect(forEveryoneElse.present == false) + } + + /// Runs the real production call rather than a hand-built encoder, so that + /// removing `reportingUnknownFieldsAsNull: true` from + /// `makeAudienceFilterAttributes` fails a test instead of silently restoring + /// the incident. That one argument is the whole opt-in. + @Test func onlyTheAudienceFilterPathReportsUnknownFieldsAsNull() async throws { + let container = DependencyContainer() + let previous = Superwall.shared.customerInfo + defer { Superwall.shared.customerInfo = previous } + + Superwall.shared.customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "unlimited_access", isActive: true)], + isPlaceholder: false + ) + + func willRenewEntry(in device: [String: Any]) throws -> (present: Bool, isNull: Bool) { + let customerInfo = try #require(device["customerInfo"] as? [String: Any]) + let entitlements = try #require(customerInfo["entitlements"] as? [[String: Any]]) + let entitlement = try #require( + entitlements.first { $0["identifier"] as? String == "unlimited_access" } + ) + return (entitlement.keys.contains("willRenew"), entitlement["willRenew"] is NSNull) + } + + let filterAttributes = await container.makeAudienceFilterAttributes( + forPlacement: nil, + withComputedProperties: [] + ) + let filterDevice = try #require(filterAttributes["device"] as? [String: Any]) + let forFilters = try willRenewEntry(in: filterDevice) + #expect(forFilters.present) + #expect(forFilters.isNull) + + // The same data on the way to every other consumer keeps its old shape. + let template = await container.deviceHelper.getTemplateDevice() + #expect(try willRenewEntry(in: template).present == false) + } + + @Test func nsNullBecomesPassableNull() { + if case PassableValue.null = toPassableValue(from: NSNull()) { + return + } + Issue.record("Expected NSNull to become PassableValue.null") + } + + @Test func nullSurvivesTheTripIntoTheFilterContext() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let passableValue = toPassableValue(from: try encodedDictionary(entitlement)) + + guard case let PassableValue.map(dictionary) = passableValue else { + Issue.record("Expected a map") + return + } + if case PassableValue.null = try #require(dictionary["willRenew"]) { + return + } + Issue.record("Expected willRenew to be PassableValue.null") + } + + @Test func bareEntitlementDoesNotMatchWillNotRenew() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == false) + } + + @Test func explicitlyNotRenewingStillMatches() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: false) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == true) + } + + @Test func explicitlyRenewingDoesNotMatch() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: true) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == false) + } + + /// The string-valued fields are nulled too, so equality against them has to + /// stay a plain no-match rather than becoming an error or a null that would + /// take the rest of the filter with it. + @Test(arguments: [ + "device.customerInfo.entitlements.exists(e, e.store == \"APP_STORE\")", + "device.customerInfo.entitlements.exists(e, e.state == \"SUBSCRIBED\")", + "device.customerInfo.entitlements.exists(e, e.latestProductId == \"pro_yearly\")", + "device.customerInfo.entitlements.exists(e, e.isLifetime == true)" + ]) + func unknownStringFieldsDoNotMatch(expression: String) throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + + #expect(try evaluate(expression, entitlement: entitlement) == false) + } + + /// The whole point of the change: the field is now present, so a filter can + /// tell that the SDK holds no opinion rather than reading a default. + @Test func unknownWillRenewIsReportedAsPresent() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + + #expect( + try evaluate( + "device.customerInfo.entitlements.exists(e, has(e.willRenew))", + entitlement: entitlement + ) == true + ) + } + + /// A filter can still ask whether the SDK knows the renewal state. + @Test func unknownWillRenewComparesEqualToNull() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + #expect( + try evaluate( + "device.customerInfo.entitlements.exists(e, e.willRenew == null)", + entitlement: entitlement + ) == true + ) + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift new file mode 100644 index 000000000..f8757c235 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift @@ -0,0 +1,165 @@ +// +// SubscriptionStatusEmissionTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-16. +// +// swiftlint:disable all + +@testable import SuperwallKit +import Combine +import Testing +import Foundation + +/// How `$subscriptionStatus` emits when the status is read and written from +/// more than one thread at once. +@Suite(.serialized) +final class SubscriptionStatusEmissionTests { + private let dependencyContainer: DependencyContainer + private let superwall: Superwall + + init() { + dependencyContainer = DependencyContainer(cache: CacheMock()) + superwall = Superwall(dependencyContainer: dependencyContainer) + } + + private func status(_ id: String) -> SubscriptionStatus { + return .active([Entitlement(id: id, type: .serviceLevel, isActive: true)]) + } + + /// Every value that concurrent writers store reaches subscribers exactly + /// once, and the last emission is the stored value. Ordering is covered by + /// `reentrantAssignment_isEmittedAfterTheTriggeringValue`, where the store + /// order is known. + @Test + func concurrentWrites_emitEveryStoredValueOnce() { + let emissions = Locked<[SubscriptionStatus]>([]) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { emittedStatus in emissions.mutate { $0.append(emittedStatus) } } + + let writeCount = 200 + DispatchQueue.concurrentPerform(iterations: writeCount) { index in + superwall.subscriptionStatus = status("entitlement_\(index)") + } + + let emitted = emissions.value + #expect(emitted.count == writeCount, "each distinct value is emitted exactly once") + let emittedIds = emitted.compactMap { emittedStatus -> String? in + guard case .active(let entitlements) = emittedStatus else { + return nil + } + return entitlements.first?.id + } + #expect(Set(emittedIds).count == writeCount, "no value is emitted twice") + #expect(emitted.last == superwall.subscriptionStatus, "the publisher never ends behind storage") + cancellable.cancel() + } + + /// A subscriber that assigns the status from inside an emission neither + /// deadlocks nor reorders: its value is emitted after the one that + /// triggered it. + @Test + func reentrantAssignment_isEmittedAfterTheTriggeringValue() { + let emissions = Locked<[SubscriptionStatus]>([]) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] emittedStatus in + emissions.mutate { $0.append(emittedStatus) } + if emittedStatus == self.status("first") { + superwall.subscriptionStatus = self.status("second") + } + } + + superwall.subscriptionStatus = status("first") + + #expect(emissions.value == [status("first"), status("second")]) + #expect(superwall.subscriptionStatus == status("second")) + cancellable.cancel() + } + + /// A writer whose value is emitted by another thread's drain doesn't wait + /// for that thread's subscribers, so a subscriber blocked on the writer's + /// thread can't deadlock it. + @Test + func writerNeverWaitsOnASubscriber() { + let subscriberEntered = DispatchSemaphore(value: 0) + let releaseSubscriber = DispatchSemaphore(value: 0) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] emittedStatus in + if emittedStatus == self.status("blocking") { + subscriberEntered.signal() + releaseSubscriber.wait() + _ = superwall.subscriptionStatus + } + } + + // Plain threads rather than tasks: the blocked subscriber must not take + // a cooperative-pool thread with it, and every wait is bounded so a + // regression fails the test instead of hanging the run. + let backgroundFinished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { [superwall] in + superwall.subscriptionStatus = self.status("blocking") + backgroundFinished.signal() + } + #expect(subscriberEntered.wait(timeout: .now() + 2) == .success, "the subscriber never ran") + + // The emitting thread is stuck inside its subscriber. This write must + // return without waiting for it. + let writeReturned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { [superwall] in + superwall.subscriptionStatus = self.status("meanwhile") + writeReturned.signal() + } + #expect(writeReturned.wait(timeout: .now() + 2) == .success, "the writer waited on a blocked subscriber") + + releaseSubscriber.signal() + #expect(backgroundFinished.wait(timeout: .now() + 2) == .success, "the drain never finished") + #expect(superwall.subscriptionStatus == status("meanwhile")) + cancellable.cancel() + } + + /// Reads and writes from many threads at once don't tear or crash. This is + /// a smoke test meant for a Thread Sanitizer run; the locking itself is + /// pinned by the tests above. + @Test + func concurrentReadsAndWrites_staySafe() { + DispatchQueue.concurrentPerform(iterations: 500) { index in + if index.isMultiple(of: 2) { + superwall.subscriptionStatus = status("entitlement_\(index)") + } else { + _ = superwall.subscriptionStatus + _ = superwall.assignedSubscriptionStatus + } + } + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + } else { + Issue.record("expected an active status") + } + #expect(superwall.assignedSubscriptionStatus == superwall.subscriptionStatus) + } +} + +/// A value guarded by a lock, for collecting emissions off arbitrary threads. +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return storage + } + + func mutate(_ body: (inout Value) -> Void) { + lock.lock() + defer { lock.unlock() } + body(&storage) + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift new file mode 100644 index 000000000..9bc66e76e --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift @@ -0,0 +1,172 @@ +// +// SubscriptionStatusLogicalEqualityTests.swift +// SuperwallKit +// +// Created by Yusuf TΓΆr on 2026-09-01. +// +// swiftlint:disable all + +@testable import SuperwallKit +import Testing +import Foundation + +struct SubscriptionStatusLogicalEqualityTests { + // MARK: - Helpers + + private func enrichedEntitlement( + id: String = "premium", + isActive: Bool = true, + expiresAt: Date? = Date(timeIntervalSince1970: 1_800_000_000) + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + productIds: ["com.example.monthly", "com.example.annual"], + latestProductId: "com.example.monthly", + store: .appStore, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + renewedAt: Date(timeIntervalSince1970: 1_750_000_000), + expiresAt: expiresAt, + isLifetime: false, + willRenew: true, + state: .subscribed, + offerType: nil + ) + } + + // MARK: - Case comparisons + + @Test + func sameCases_areLogicallyEqual() { + #expect(SubscriptionStatus.unknown.isLogicallyEqual(to: .unknown)) + #expect(SubscriptionStatus.inactive.isLogicallyEqual(to: .inactive)) + } + + @Test + func differentCases_areNotLogicallyEqual() { + #expect(!SubscriptionStatus.unknown.isLogicallyEqual(to: .inactive)) + #expect(!SubscriptionStatus.inactive.isLogicallyEqual(to: .active([Entitlement(id: "premium")]))) + #expect(!SubscriptionStatus.active([Entitlement(id: "premium")]).isLogicallyEqual(to: .unknown)) + } + + // MARK: - Active status comparisons + + @Test + func active_identicalEntitlements_areLogicallyEqual() { + let status: SubscriptionStatus = .active([enrichedEntitlement()]) + let repeatWrite: SubscriptionStatus = .active([enrichedEntitlement()]) + #expect(status.isLogicallyEqual(to: repeatWrite)) + } + + @Test + func active_bareVsEnrichedEntitlement_areLogicallyEqual() { + // An app using a purchase controller writes a bare entitlement while the + // SDK holds an enriched one with transaction metadata. Deep equality says + // these differ; logically the status is unchanged. + let bare: SubscriptionStatus = .active([Entitlement(id: "premium")]) + let enriched: SubscriptionStatus = .active([enrichedEntitlement()]) + #expect(bare != enriched) + #expect(bare.isLogicallyEqual(to: enriched)) + } + + @Test + func active_driftingMetadata_areLogicallyEqual() { + let first: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000)) + ]) + let second: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000.5)) + ]) + #expect(first != second) + #expect(first.isLogicallyEqual(to: second)) + } + + @Test + func active_differentEntitlementIds_areNotLogicallyEqual() { + let premium: SubscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + let pro: SubscriptionStatus = .active([enrichedEntitlement(id: "pro")]) + #expect(!premium.isLogicallyEqual(to: pro)) + } + + @Test + func active_addedEntitlement_areNotLogicallyEqual() { + let one: SubscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + let two: SubscriptionStatus = .active([ + enrichedEntitlement(id: "premium"), + enrichedEntitlement(id: "pro") + ]) + #expect(!one.isLogicallyEqual(to: two)) + } + + @Test + func active_isActiveFlip_areNotLogicallyEqual() { + let active: SubscriptionStatus = .active([enrichedEntitlement(isActive: true)]) + let lapsed: SubscriptionStatus = .active([enrichedEntitlement(isActive: false)]) + #expect(!active.isLogicallyEqual(to: lapsed)) + } + + // MARK: - Persistence + + @Test + func setSubscriptionStatus_metadataOnlyUpdate_refreshesCache() { + let dependencyContainer = DependencyContainer() + let superwall = Superwall(dependencyContainer: dependencyContainer) + dependencyContainer.storage.delete(SubscriptionStatusKey.self) + + let first: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000)) + ]) + superwall.subscriptionStatus = first + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == first) + + // Same logical state, different metadata. The listener dedupes this, + // but the persisted cache must still refresh. + let renewed: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_900_000_000)) + ]) + superwall.subscriptionStatus = renewed + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == renewed) + } + + // MARK: - Listener dedupe + + @Test + func listener_metadataOnlyUpdate_doesNotCallDelegate() async { + let dependencyContainer = DependencyContainer() + let superwall = Superwall(dependencyContainer: dependencyContainer) + let delegate = MockSuperwallDelegate() + dependencyContainer.delegateAdapter.swiftDelegate = delegate + superwall.listenToSubscriptionStatus() + + superwall.subscriptionStatus = .active([Entitlement(id: "premium")]) + await waitUntil { delegate.subscriptionStatusChanges.count == 1 } + #expect(delegate.subscriptionStatusChanges.count == 1) + + // Metadata-only update: same id/type/isActive, enriched with + // transaction metadata. Must not call the delegate again. + superwall.subscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(delegate.subscriptionStatusChanges.count == 1) + + // A logical change must still call the delegate. + superwall.subscriptionStatus = .inactive + await waitUntil { delegate.subscriptionStatusChanges.count == 2 } + #expect(delegate.subscriptionStatusChanges.count == 2) + + // A different entitlement id is also a logical change. + superwall.subscriptionStatus = .active([enrichedEntitlement(id: "pro")]) + await waitUntil { delegate.subscriptionStatusChanges.count == 3 } + #expect(delegate.subscriptionStatusChanges.count == 3) + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping () -> Bool + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift new file mode 100644 index 000000000..e0e8c3a0c --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -0,0 +1,671 @@ +// +// AutomaticPurchaseControllerTests.swift +// SuperwallKit +// +// Created on 15/08/2026. +// + +import Testing +@testable import SuperwallKit +import Foundation + +/// Tests for the anti-downgrade guard in `AutomaticPurchaseController`. +/// +/// These tests reproduce a production failure. A user who pays through +/// Stripe has no App Store purchases. On every cold launch, the automatic +/// StoreKit sync reads zero purchases. Before the guard, that empty read +/// set the status to `.inactive` and persisted it, even though the cached +/// status said `.active`. Recovery needed a network poll, so features +/// gated on entitlements broke on weak internet. +@Suite(.serialized) +struct AutomaticPurchaseControllerTests { + let dependencyContainer = DependencyContainer() + + init() { + // Clear state that a previous test may have left on disk. + dependencyContainer.storage.delete(LatestRedeemResponse.self) + dependencyContainer.storage.delete(SubscriptionStatusKey.self) + dependencyContainer.storage.delete(LastWebEntitlementsFetchDate.self) + dependencyContainer.storage.delete(EntitlementsByProductId.self) + } + + /// A web entitlement like the one a Stripe subscriber holds. + private func stripeEntitlement(expiresAt: Date?) -> Entitlement { + return entitlement(store: .stripe, expiresAt: expiresAt) + } + + private func entitlement(store: EntitlementStore, expiresAt: Date?) -> Entitlement { + return Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: store, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: expiresAt, + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + } + + private func makeController() -> AutomaticPurchaseController { + return AutomaticPurchaseController( + factory: dependencyContainer, + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + } + + // MARK: - The observed production bug + + @Test("Cold launch with empty device read keeps an unexpired active status") + func testEmptyDeviceRead_keepsUnexpiredActiveStatus() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A months-long Stripe subscriber. The last session persisted `.active`. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.save( + SubscriptionStatus.active([entitlement]), + forType: SubscriptionStatusKey.self + ) + + // The web cache is missing. This happens after a poisoned poll response, + // a storage reset, or a failed cache migration. Without the guard, the + // web-entitlement merge cannot rescue the status. + dependencyContainer.storage.delete(LatestRedeemResponse.self) + + // Cold launch restores the persisted status, as `configure` does. + await MainActor.run { + superwall.subscriptionStatus = + dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + } + + // The automatic StoreKit sync reads zero purchases. This is correct for + // a Stripe subscriber: they have no App Store transactions. + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + // Before the fix, the status flipped to `.inactive` here. The user's + // event stream showed this flip 10 times, 0.8s after cold launch. + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("A paying subscriber must not flip to \(status) on an empty device read") + } + } + + @Test("Full cold launch on weak internet keeps access for a Stripe subscriber") + func testStripeSubscriberColdLaunchOnWeakInternet_keepsAccess() async { + guard #available(iOS 14.0, *) else { + return + } + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // Step 1: a previous session persisted `.active` for the Stripe subscriber. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.save( + SubscriptionStatus.active([entitlement]), + forType: SubscriptionStatusKey.self + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + + // Step 2: cold launch restores the status from disk. + await MainActor.run { + superwall.subscriptionStatus = + dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + } + + // Step 3: the web entitlement poll fails. The network is unreachable. + // NetworkMock throws when no response is set. + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + // Step 4: the StoreKit sync reads zero purchases. + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + // The subscriber keeps access with zero network on the critical path. + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("Status was \(status); a weak-internet cold launch must not remove access") + } + } + + // MARK: - The guard must not block real deactivation + + @Test("Empty device read deactivates an expired status") + func testEmptyDeviceRead_expiredStatus_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The subscription lapsed a day ago. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(-86_400)) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([entitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive, "An expired status must not survive an empty device read") + } + + @Test("Empty device read deactivates a status with no expiry date") + func testEmptyDeviceRead_nilExpiry_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // No expiry date means the guard cannot bound the protection, so it + // does not apply. A revoked lifetime purchase deactivates this way. + let entitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["lifetime_product"], + latestProductId: "lifetime_product", + store: .appStore, + startsAt: Date(), + renewedAt: nil, + expiresAt: nil, + isLifetime: true, + willRenew: nil, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([entitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive, "A status with no expiry date must not hold the guard") + } + + @Test("Inactive purchases deactivate an unexpired App Store status") + func testInactivePurchases_appStoreStatus_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A refunded subscription: the transaction stays in the purchases set + // as inactive (SK2 reads Transaction.all; the SK1 receipt keeps + // cancelled purchases). That is an authoritative answer, so the guard + // must not hold, even though the cached expiry date is in the future. + let appStoreEntitlement = entitlement( + store: .appStore, + expiresAt: Date().addingTimeInterval(300 * 86_400) + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [refundedPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect( + status == .inactive, + "A refunded App Store subscription must deactivate on the next device read" + ) + } + + @Test("Inactive purchases cannot refute an unexpired web entitlement") + func testInactivePurchases_webStatus_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber with old, inactive App Store transactions (for + // example an expired trial from years ago). The device read is + // authoritative about the App Store only, so it must not demote the + // web entitlement β€” even with the web cache missing. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement]) + } + + let controller = makeController() + let oldPurchase = Purchase( + id: "old_trial_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [oldPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + } else { + Issue.record("An App Store read must not refute a web entitlement; got \(status)") + } + } + + @Test("An active purchase with no entitlement mapping cannot demote its entitlement") + func testActiveUnmappedPurchase_matchingCachedEntitlement_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The cached App Store entitlement was unlocked by "annual_product". + // The config has since lost the product (undecodable product dropped, + // or a stale cached config), so the active purchase maps to no + // entitlements. That is a mapping failure, not an authoritative + // "nothing is active" β€” the read just confirmed the purchase. + let appStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let activePurchase = Purchase( + id: "annual_product", + isActive: true, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [activePurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(appStoreEntitlement)) + } else { + Issue.record("An active purchase must not demote the entitlement it unlocks; got \(status)") + } + } + + @Test("An unrelated active purchase cannot hold a refunded subscription") + func testRefundedSubscription_withUnrelatedActivePurchase_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The subscription for "annual_product" was refunded. The user also + // owns an unrelated non-consumable that maps to no entitlement. The + // active-but-unmapped purchase unlocks nothing in the cached status, + // so the refund still deactivates immediately. + let appStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(300 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + let unrelatedPurchase = Purchase( + id: "coin_doubler", + isActive: true, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus( + withPurchases: [refundedPurchase, unrelatedPurchase], + superwall: superwall + ) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect( + status == .inactive, + "An unrelated active purchase must not hold a refunded subscription's status" + ) + } + + @Test("A live web entitlement cannot hold a refunded App Store entitlement in place") + func testRefundedAppStoreEntitlement_besideWebEntitlement_isDropped() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber who also bought "annual_product" on the App Store + // and then got it refunded. The device read has no authority over the + // web entitlement, so the status stays active, but it is authoritative + // about the App Store one: that must not survive alongside the web one. + let webEntitlement = Entitlement( + id: "web_pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + let refundedEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(300 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, refundedEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [refundedPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + !entitlements.contains { $0.id == refundedEntitlement.id }, + "A refunded App Store entitlement must not keep granting access beside a web one" + ) + } else { + Issue.record("A web entitlement must survive an App Store refund; got \(status)") + } + } + + @Test("An empty device read keeps a lifetime App Store entitlement beside a held one") + func testEmptyDeviceRead_lifetimeBesideHeldEntitlement_keepsBoth() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A lifetime unlock has no expiry, so it can't hold the status up on its + // own. But an empty read is a non-answer about it too, so while the web + // subscription holds, the lifetime entitlement must not be dropped and + // persisted as gone. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let lifetimeEntitlement = Entitlement( + id: "lifetime", + type: .serviceLevel, + isActive: true, + productIds: ["lifetime_product"], + latestProductId: "lifetime_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: nil, + isLifetime: true, + willRenew: nil, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, lifetimeEntitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + entitlements.contains(lifetimeEntitlement), + "An empty read has no authority over a lifetime unlock, so it must survive" + ) + } else { + Issue.record("A held web entitlement must keep the status active; got \(status)") + } + } + + @Test("An empty device read drops an App Store entitlement whose own expiry has passed") + func testEmptyDeviceRead_lapsedAppStoreBesideHeldEntitlement_dropsLapsed() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The web subscription holds the status. The App Store subscription's + // cached expiry was yesterday: an empty read says nothing about it, but + // the clock does, so it must not keep granting access beside the web one. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let lapsedEntitlement = Entitlement( + id: "app_store_pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-400 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(-86_400), + isLifetime: false, + willRenew: false, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, lapsedEntitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + !entitlements.contains { $0.id == lapsedEntitlement.id }, + "A subscription past its own expiry must not survive on an empty read" + ) + } else { + Issue.record("A held web entitlement must keep the status active; got \(status)") + } + } + + @Test("Inactive purchases cannot refute a nil-store entitlement") + func testInactivePurchases_nilStoreStatus_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A nil store means no App Store transaction unlocks the entitlement: + // a web or manual grant whose payload omitted `store`. Both receipt + // managers stamp `.appStore` on device-derived entitlements, so a + // device read has no authority here and must not demote it. + let nilStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: nil, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([nilStoreEntitlement]) + } + + let controller = makeController() + let oldPurchase = Purchase( + id: "old_trial_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [oldPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(nilStoreEntitlement)) + } else { + Issue.record("A device read must not refute a nil-store entitlement; got \(status)") + } + } + + @Test("SK1 stamps .appStore only on entitlements a receipt transaction unlocks") + func testSK1Entitlements_storeReflectsReceiptTransactions() async { + // The guard protects nil-store entitlements, so refund enforcement on + // StoreKit 1 depends on the receipt manager stamping `.appStore` on + // purchased entitlements β€” while never-purchased entitlements keep a + // nil store, per the `Entitlement.store` contract and the SK2 path. + let receiptManager = SK1ReceiptManager(receiptData: { MockReceiptData.newReceipt }) + let snapshot = await receiptManager.loadPurchases( + serverEntitlementsByProductId: [ + // `newReceipt` contains a transaction for this product. + "CYCLEMAPS_PREMIUM": [Entitlement(id: "pro")], + // No transaction in the receipt unlocks this one. + "com.example.never_purchased": [Entitlement(id: "plus")] + ] + ) + + let purchased = snapshot.customerInfo.entitlements.first { $0.id == "pro" } + #expect( + purchased?.store == .appStore, + "A receipt transaction unlocks `pro`, so it must carry .appStore" + ) + + let neverPurchased = snapshot.customerInfo.entitlements.first { $0.id == "plus" } + #expect(neverPurchased != nil) + #expect( + neverPurchased?.store == nil, + "No transaction unlocks `plus`, so its store must be nil" + ) + } + + @Test("Empty device read from an inactive status stays inactive") + func testEmptyDeviceRead_inactiveStatus_staysInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .inactive + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive) + } + + // MARK: - Existing behavior is unchanged + + @Test("Empty device read still rescues via cached web entitlements") + func testEmptyDeviceRead_withCachedWebEntitlements_staysActiveViaMerge() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The status starts inactive, so the guard does not apply. The cached + // web entitlements alone must rescue the user, as before the fix. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let redeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [entitlement]) + ) + dependencyContainer.storage.save(redeemResponse, forType: LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .inactive + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("Cached web entitlements must rescue an empty device read") + } + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + + @Test("Active purchases set an active status") + func testActivePurchases_setActiveStatus() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + let entitlement = Entitlement(id: "premium") + dependencyContainer.entitlementsInfo.entitlementsByProductId = [ + "monthly_product": [entitlement] + ] + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .unknown + } + + let controller = makeController() + let purchase = Purchase( + id: "monthly_product", + isActive: true, + purchaseDate: Date() + ) + await controller.syncSubscriptionStatus(withPurchases: [purchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("An active purchase must produce an active status") + } + } +} diff --git a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift index a43e87cd6..a2d3984a7 100644 --- a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift +++ b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift @@ -16,6 +16,14 @@ final class MockSuperwallDelegate: SuperwallDelegate { var willRedeemCallCount = 0 var willRedeemCalledAt: Date? var didRedeemCalledAt: Date? + var subscriptionStatusChanges: [(from: SubscriptionStatus, to: SubscriptionStatus)] = [] + + func subscriptionStatusDidChange( + from oldValue: SubscriptionStatus, + to newValue: SubscriptionStatus + ) { + subscriptionStatusChanges.append((from: oldValue, to: newValue)) + } func didRedeemLink(result: RedemptionResult) { receivedResult = result diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index a2c637429..592c48e86 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -29,7 +29,7 @@ struct WebEntitlementRedeemerTests { init() { // Clear any pending stripe checkout state left on disk by a previous test - // to prevent the WebEntitlementRedeemer init Task from triggering unexpected saves. + // so foreground/cold-launch polls triggered later can't see stale state. dependencyContainer.storage.delete(PendingStripeCheckoutPollStorage.self) } @@ -650,6 +650,158 @@ struct WebEntitlementRedeemerTests { #expect(savedRedeemResponse?.customerInfo.entitlements.isEmpty == true, "Saved redeem response should have no entitlements") } + @Test("Empty poll response does not clobber unexpired web entitlements") + func testPollWebEntitlements_emptyResponse_keepsUnexpiredWebEntitlements() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber with a paid-through web entitlement in the cache. + let stripeEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + let previousRedeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [stripeEntitlement]) + ) + dependencyContainer.storage.save(previousRedeemResponse, forType: LatestRedeemResponse.self) + dependencyContainer.storage.delete(LastWebEntitlementsFetchDate.self) + await MainActor.run { + superwall.subscriptionStatus = .active([stripeEntitlement]) + } + + // The backend answers with zero entitlements. The poll is keyed on + // appUserId/deviceId alone, so an alias mismatch or backend hiccup + // produces exactly this response for a still-paying subscriber. Before + // the fix, this response poisoned the cache. The next cold launch then + // read the user as inactive until a network poll recovered them. + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + mockNetwork.getEntitlementsResponse = EntitlementsResponse( + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + // The cache keeps the unexpired entitlement. + let savedRedeemResponse = dependencyContainer.storage.get(LatestRedeemResponse.self) + #expect( + savedRedeemResponse?.customerInfo.entitlements.contains(stripeEntitlement) == true, + "An empty poll response must not remove an unexpired web entitlement" + ) + + // The status stays active. + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .active([stripeEntitlement])) + + // The fetch date is not saved, so the next poll retries without + // waiting out entitlementsMaxAge. + #expect(dependencyContainer.storage.get(LastWebEntitlementsFetchDate.self) == nil) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + + @Test("Empty poll response removes expired web entitlements") + func testPollWebEntitlements_emptyResponse_removesExpiredWebEntitlements() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The cached entitlement expired a day ago, so nothing protects it. + let expiredEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(-86_400), + isLifetime: false, + willRenew: false, + state: nil, + offerType: nil + ) + let previousRedeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [expiredEntitlement]) + ) + dependencyContainer.storage.save(previousRedeemResponse, forType: LatestRedeemResponse.self) + + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + mockNetwork.getEntitlementsResponse = EntitlementsResponse( + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + let savedRedeemResponse = dependencyContainer.storage.get(LatestRedeemResponse.self) + #expect( + savedRedeemResponse?.customerInfo.entitlements.isEmpty == true, + "An empty poll response must remove an expired web entitlement" + ) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + @Test("External purchase controller with mixed web + appStore entitlements - polling removes web entitlements") func testPollWebEntitlements_externalPurchaseController_mixedEntitlements_webRemoved() async { guard #available(iOS 14.0, *) else { @@ -2790,4 +2942,85 @@ struct WebEntitlementRedeemerTests { // Failed status clears pending state, but we can verify the poll happened #expect(mockNetwork.pollRedemptionResultCallCount == 1) } + + @Test("Init doesn't start the cold-launch Stripe poll") + func testInit_doesNotStartColdLaunchPoll() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + + // Seed a pending checkout so a poll would fire if anything kicked one off. + mockStorage.save( + PendingStripeCheckoutPollState( + checkoutContextId: "ctx_cold", + productId: "prod_cold" + ), + forType: PendingStripeCheckoutPollStorage.self + ) + mockNetwork.pollRedemptionResultResponses = [.failure(NetworkError.unknown)] + + _ = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + // Give a stray init-spawned task time to run before asserting it didn't. + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(mockNetwork.pollRedemptionResultCallCount == 0) + } + + @Test("Cold-launch kick-off polls a pending Stripe checkout") + func testPollPendingStripeCheckoutOnColdLaunch_pollsPendingCheckout() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + + mockStorage.save( + PendingStripeCheckoutPollState( + checkoutContextId: "ctx_cold", + productId: "prod_cold" + ), + forType: PendingStripeCheckoutPollStorage.self + ) + mockNetwork.pollRedemptionResultResponses = [.failure(NetworkError.unknown)] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + redeemer.pollPendingStripeCheckoutOnColdLaunch() + + // The kick-off spawns a task; wait for the poll to land. + for _ in 0..<100 where mockNetwork.pollRedemptionResultCallCount == 0 { + try? await Task.sleep(nanoseconds: 50_000_000) + } + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + } }