diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528a..f9ca3de63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 `register` calls stalling for 10 seconds or more at cold launch for subscribers on a weak network. The SDK now marks its configuration ready before it reads purchases from StoreKit when the config is cached and the user is a known subscriber, so gated features run at once. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - 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. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 49c4485861..a677adad18 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -35,6 +35,11 @@ class ConfigManager { var configRetryCount = 0 + /// The purchases load that `fetchConfiguration` starts before it publishes + /// `configState` on the cached-config path. Trial eligibility awaits it so a + /// paywall opened during the load still sees the active subscription groups. + private(set) var initialPurchasesLoad: Task? + private unowned let storeKitManager: StoreKitManager unowned let storage: Storage private unowned let network: Network @@ -179,15 +184,36 @@ class ConfigManager { ) } - // Step 5: Track device attributes + // Step 5: Process config and set state. + // + // On the cached-config path the user is a known subscriber and the + // config is already on disk, so nothing below needs the network. But + // `processConfig` reads StoreKit, which can take 10 to 25 seconds on a + // weak connection, and every `register` call waits on `configState`. + // Publishing before that read keeps gated features from stalling at + // cold launch. The sync path keeps its order: with no known subscriber, + // purchases are read before a paywall can be shown. + var didPublishConfig = false + if shouldFetchAsync { + didPublishConfig = await processConfig( + config, + isFirstTime: true, + publishBeforeLoadingPurchases: true + ) + } + + // Step 6: Track device attributes let deviceAttributes = await factory.makeSessionDeviceAttributes() await Superwall.shared.track( InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes) ) - // Step 6: Process config and set state - await processConfig(config, isFirstTime: true) - configState.send(.retrieved(config)) + if !shouldFetchAsync { + await processConfig(config, isFirstTime: true) + } + if !didPublishConfig { + configState.send(.retrieved(config)) + } // Step 7: Schedule background tasks scheduleBackgroundTasks( @@ -387,10 +413,20 @@ class ConfigManager { ) } + /// Applies `config` and loads purchases from StoreKit. + /// + /// - Parameter publishBeforeLoadingPurchases: When `true`, sends + /// `configState` as soon as the config is applied and before the StoreKit + /// read starts. Only safe when the subscription status on disk is already + /// known, which is why `fetchConfiguration` passes it on the cached-config + /// path alone. Ignored in test mode, where products come from the API. + /// - Returns: Whether `configState` was published here. + @discardableResult private func processConfig( _ config: Config, - isFirstTime: Bool - ) async { + isFirstTime: Bool, + publishBeforeLoadingPurchases: Bool = false + ) async -> Bool { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) storage.save(config, forType: LatestConfig.self) @@ -404,6 +440,7 @@ class ConfigManager { let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode + var didPublishConfig = false if testModeManager.isTestMode { // In test mode, fetch products from API instead of StoreKit await fetchTestModeProducts(testModeManager: testModeManager) @@ -423,7 +460,20 @@ class ConfigManager { entitlements: [] ).merging(with: .blank(), granting: entitlementsInfo.granted) } - await factory.loadPurchasedProducts(config: config) + if publishBeforeLoadingPurchases { + // The task handle is stored before the publish so anything that + // presents on this config can await the load through + // `initialPurchasesLoad`. + let purchasesLoad = Task { [factory] in + await factory.loadPurchasedProducts(config: config) + } + initialPurchasesLoad = purchasesLoad + configState.send(.retrieved(config)) + didPublishConfig = true + await purchasesLoad.value + } else { + await factory.loadPurchasedProducts(config: config) + } } if !testModeManager.isTestMode { @@ -442,6 +492,8 @@ class ConfigManager { let reason = testModeManager.testModeReason { await presentTestModeModal(reason: reason, config: config) } + + return didPublishConfig } /// Reassigns variants and preloads paywalls again. diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 25e57cfdf3..6edd44302d 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -614,6 +614,10 @@ extension DependencyContainer: ReceiptFactory { return false } } + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`). The active subscription groups + // that gate upgrades come from that load, so wait for it. + await configManager.initialPurchasesLoad?.value return await receiptManager.isFreeTrialAvailable(for: product) } 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 86419a9942..92fddf7da2 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -252,9 +252,11 @@ 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 + // cached-config path config is published first, so paywall callers go through + // `DependencyContainer.isFreeTrialAvailable`, which awaits the load before + // reaching here. return !activeSubscriptionGroupIds.contains(subscriptionGroupId) } diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift index 4888d048c4..c4ed9f70f9 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift @@ -6,6 +6,7 @@ // // swiftlint:disable all +import Foundation @testable import SuperwallKit import Testing @@ -598,3 +599,226 @@ struct ConfigManagerTests { try? await Task.sleep(nanoseconds: UInt64(0.2 * 1_000_000_000)) } } + +// MARK: - Cold-launch StoreKit stall + +// Regression tests for a cold-launch stall. A subscriber with a cached config +// took the async config path, so the config itself was available at once, but +// `configState` was not published until `loadPurchasedProducts` finished. +// That call reads StoreKit, which can take 10 to 25 seconds on a weak network. +// Every `register` call waits on `configState`, so gated features froze for +// that long, and features that ran inside the closure were lost if the app +// was killed first. +// +@Suite(.serialized) +struct ConfigManagerStoreKitStallTests { + /// 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 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 + } + + /// 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. + private func makeHarness( + isSubscribed: Bool, + loadDelay: TimeInterval + ) -> Harness { + 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 + dependencyContainer.receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: receipt, + receiptDelegate: nil, + factory: dependencyContainer, + storage: storage + ) + + let cachedConfig: Config = .stub() + .setting(\.buildId, to: "cached_123") + .setting(\.featureFlags, to: .stub()) + storage.save(cachedConfig, forType: LatestConfig.self) + + if isSubscribed { + let activeEntitlements: Set = [.stub()] + storage.save(SubscriptionStatus.active(activeEntitlements), forType: SubscriptionStatusKey.self) + } else { + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.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 configManager = ConfigManager( + options: SuperwallOptions(), + 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, + 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) + } + + @Test("Subscriber with cached config: config is published before StoreKit finishes") + func subscriberWithCachedConfigDoesNotWaitForStoreKit() async { + let harness = makeHarness(isSubscribed: true, 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") + #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") + + // Let the background refresh finish before the container goes away. + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Unknown subscriber: purchases still load before config is published") + func syncPathStillLoadsPurchasesBeforePublishing() async { + let harness = makeHarness(isSubscribed: false, loadDelay: 1) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil, "sync path published config after \(waited)s, before purchases loaded") + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Trial eligibility waits for the purchases load that config no longer waits for") + func trialEligibilityWaitsForInitialPurchasesLoad() async { + let harness = makeHarness(isSubscribed: true, 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 product = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: "com.app.gold", + subscriptionGroupIdentifier: "group_A" + ) + ) + _ = await dependencyContainer.isFreeTrialAvailable(for: product) + #expect(harness.receipt.didFinishLoad, "eligibility was answered before active subscription groups were known") + + await fetch.value + try? await Task.sleep(nanoseconds: 300_000_000) + } +} + +/// A `ReceiptManagerType` whose first `loadPurchases` sleeps, standing in for a +/// StoreKit read on a weak network. +private final class SlowReceiptManagerType: ReceiptManagerType, @unchecked Sendable { + 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 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 + return PurchaseSnapshot( + purchases: [], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +}