diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2ababfd6..f0281a49be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. +- Adds return links, `yourscheme://superwall/return` and `https://.superwall.link/app-link/superwall/return`. To open your app from a teleported web flow without the Open in App alert, add `applinks:.superwall.link` to your app's associated domains. They bring the user back to your app from a Superwall web flow, such as a paywall's checkout page, and leave the paywall open. Unlike other deep links, they don't track `deepLink_open` and don't dismiss the presented paywall. +- Adds the `teleportOpen` event (`teleport_open`), tracked with the paywall's info when a paywall teleports the user to a web flow, such as its checkout page. +- Adds the `teleportReturn` event (`teleport_return`), tracked instead of `deepLink_open` when a return link brings the user back to your app, with the presented paywall's info if there is one. +- A web purchase from a paywall's checkout page now finishes like an App Store purchase: once the code is redeemed, the paywall runs the purchase button's after-purchase actions, and it's dismissed (as `.purchased`) only when those actions close it. Redemption itself is unchanged, including `willRedeemLink`, `didRedeemLink` and the redemption events. - Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on with the conversions it uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). The values are reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted, unless you set `adConsent` yourself. ### Fixes @@ -18,6 +22,9 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes subscribers staying in test mode on every launch after the dashboard setting that caused it had been corrected. The SDK no longer saves a config that turns test mode on, and no longer starts a launch from one saved by an earlier version. - Stops matching installs for attribution unless the Superwall MMP is turned on for your app. - Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish. +- Fixes a paywall reporting `.declined` instead of `.purchased` when it closed after a purchase through an after-purchase action list, such as one that sets state and then closes. +- Fixes the paywall's loading spinner staying up after a web checkout completed while an older pending checkout was still being polled. +- Fixes a web purchase on a paywall whose products carry different entitlements showing the restore failed alert. - Fixes the test mode sheet not showing in apps with more than one scene or window. - Fixes a paywall staying blank after iOS terminated its web content process. diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 8e0830c3db..93cd5417dd 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -994,6 +994,47 @@ enum InternalSuperwallEvent { } } + struct TeleportOpen: TrackableSuperwallEvent { + let paywallInfo: PaywallInfo + + var superwallEvent: SuperwallEvent { + return .teleportOpen(paywallInfo: paywallInfo) + } + + var audienceFilterParams: [String: Any] { + return paywallInfo.audienceFilterParams() + } + + func getSuperwallParameters() async -> [String: Any] { + // No URL: a teleport link's path carries its sealed teleport. + return await paywallInfo.placementParams() + } + } + + struct TeleportReturn: TrackableSuperwallEvent { + /// The ``PaywallInfo`` of the presented paywall, if one is presented when the return link opens + /// the app. + let paywallInfo: PaywallInfo? + /// Why the web flow sent the user back, as it put it in the link, such as `purchased` or `closed`. + let reason: String? + + var superwallEvent: SuperwallEvent { + return .teleportReturn(paywallInfo: paywallInfo) + } + + var audienceFilterParams: [String: Any] { + return paywallInfo?.audienceFilterParams() ?? [:] + } + + func getSuperwallParameters() async -> [String: Any] { + var params = await paywallInfo?.placementParams() ?? [:] + if let reason { + params["reason"] = reason + } + return params + } + } + enum ConfigCacheStatus: String { case cached = "CACHED" case notCached = "NOT_CACHED" diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 81d025c606..646c4503e9 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -353,6 +353,14 @@ public enum SuperwallEvent { /// When a Stripe checkout session fails. case stripeCheckoutFail(paywallInfo: PaywallInfo) + /// When a paywall teleports the user to a web flow in the browser, such as its checkout page. + case teleportOpen(paywallInfo: PaywallInfo) + + /// When a return link brings the user back to the app from a teleported web flow. + /// + /// `paywallInfo` is the ``PaywallInfo`` of the presented paywall, if one is presented. + case teleportReturn(paywallInfo: PaywallInfo?) + /// When the test mode modal is opened. case testModeModalOpen @@ -582,6 +590,10 @@ extension SuperwallEvent { return .init(objcEvent: .stripeCheckoutComplete) case .stripeCheckoutFail: return .init(objcEvent: .stripeCheckoutFail) + case .teleportOpen: + return .init(objcEvent: .teleportOpen) + case .teleportReturn: + return .init(objcEvent: .teleportReturn) case .testModeModalOpen: return .init(objcEvent: .testModeModalOpen) case .testModeModalClose: diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index f371ba0450..f2d5e0a107 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -274,6 +274,12 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When a refund request started from the Customer Center completes. case customerCenterRefundRequest + /// When a paywall teleports the user to a web flow in the browser, such as its checkout page. + case teleportOpen + + /// When a return link brings the user back to the app from a teleported web flow. + case teleportReturn + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } @@ -450,6 +456,10 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "customerCenter_refundRequest" case .paywallPageView: return "paywall_page_view" + case .teleportOpen: + return "teleport_open" + case .teleportReturn: + return "teleport_return" } } } diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 8564bc1cce..ba15a94031 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -15,6 +15,16 @@ final class DeepLinkRouter { private unowned let configManager: ConfigManager private static var pendingDeepLink: URL? + /// Tracks an event. Replaced in tests to observe what's tracked. + var trackEvent: (Trackable) async -> Void = { event in + _ = await Superwall.shared.track(event) + } + + /// The ``PaywallInfo`` of the presented paywall, if one is presented. Replaced in tests. + var presentedPaywallInfo: () async -> PaywallInfo? = { + await Superwall.shared.paywallViewController?.info + } + init( webEntitlementRedeemer: WebEntitlementRedeemer, debugManager: DebugManager, @@ -29,6 +39,14 @@ final class DeepLinkRouter { @discardableResult func route(url: URL) -> Bool { + // A return link only brings the app back from a Superwall web flow. The + // flow's paywall finishes the job itself, so the link must not be tracked + // as a deep link: `deepLink_open` dismisses the presented paywall. + if url.isSuperwallReturnLink { + handleReturnLink(url) + return true + } + // Check if the URL matches the expected web2app format if let code = url.redeemableCode { Task { @@ -54,8 +72,9 @@ final class DeepLinkRouter { deepLinkUrl = url } + let trackEvent = self.trackEvent Task { - await Superwall.shared.track(InternalSuperwallEvent.DeepLink(url: deepLinkUrl)) + await trackEvent(InternalSuperwallEvent.DeepLink(url: deepLinkUrl)) } // Check if this is a debug URL @@ -80,6 +99,38 @@ final class DeepLinkRouter { return false } + private func handleReturnLink(_ url: URL) { + // The paywall that teleported takes its waiting screen out of sight at once. + let userInfo = url.superwallReturnReason.map { ["reason": $0] } + if Thread.isMainThread { + NotificationCenter.default.post( + name: .superwallReturnLinkOpened, + object: nil, + userInfo: userInfo + ) + } else { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .superwallReturnLinkOpened, + object: nil, + userInfo: userInfo + ) + } + } + // Tracked as `teleport_return` instead, with the `PaywallInfo` of the presented paywall. + let trackEvent = self.trackEvent + let presentedPaywallInfo = self.presentedPaywallInfo + Task { + let paywallInfo = await presentedPaywallInfo() + await trackEvent( + InternalSuperwallEvent.TeleportReturn( + paywallInfo: paywallInfo, + reason: url.superwallReturnReason + ) + ) + } + } + private func listenToConfig() { configManager.configState .subscribe( @@ -134,6 +185,11 @@ final class DeepLinkRouter { return true } + // Return links from Superwall web flows + if url.isSuperwallReturnLink { + return true + } + // Debug/preview URLs if DebugManager.outcomeForDeepLink(url: url) != nil { return true @@ -162,6 +218,36 @@ final class DeepLinkRouter { } extension URL { + /// Whether this link brings the user back to the app from a teleported + /// Superwall web flow, such as a paywall's checkout page. Matches + /// `scheme://superwall/return` and + /// `https://.superwall.link/app-link/superwall/return`. + var isSuperwallReturnLink: Bool { + if scheme != "http", + scheme != "https", + host == "superwall", + path == "/return" { + return true + } + + if let host, + host.hasSuffix(".superwall.link"), + path == "/app-link/superwall/return" { + return true + } + + return false + } + + /// Why the web flow sent the user back, when it says, such as `purchased` after paying on a + /// checkout page or `closed` when they closed it instead. + var superwallReturnReason: String? { + URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "reason" }? + .value + } + /// The web checkout code to redeem given a Superwall deep link format. var redeemableCode: String? { let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index c177f12e67..4731c5d0dc 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -101,16 +101,21 @@ actor PaywallRequestManager { activeTasks[requestHash] = task var paywall = try await task.value - paywall = await updatePaywall(paywall, for: request) + // The request that fetched the paywall keeps the id its response events were tracked with. + paywall = await updatePaywall(paywall, for: request, isNewPresentation: false) return paywall } private func updatePaywall( _ paywall: Paywall, - for request: PaywallRequest + for request: PaywallRequest, + isNewPresentation: Bool = true ) async -> Paywall { var paywall = paywall + if isNewPresentation { + paywall.presentationId = UUID().uuidString + } paywall.experiment = request.responseIdentifiers.experiment paywall.presentationSourceType = request.presentationSourceType if let featureGating = request.overrides.featureGatingBehavior { diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 7b67fa3a97..6a8e42b43d 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -159,6 +159,14 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// Tracks whether explicit stripe_checkout_abandon was already received for this checkout flow. private var didReceiveStripeCheckoutAbandonMessage = false + /// What the user sees of a teleport's waiting screen while leaving and coming back. + private var existingTeleportReturnCover: TeleportReturnCover? + + /// The product bought from this presentation, App Store or web. A close that + /// follows a purchase, such as a `close` among the button's after-purchase + /// actions, finishes the presentation as purchased rather than declined. + private(set) var completedPurchaseProduct: StoreProduct? + /// Ensures Stripe checkout callbacks are forwarded to WebEntitlementRedeemer in order. private let stripeCheckoutCoordinator = SerialTaskCoordinator() @@ -1344,16 +1352,24 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { func handleStripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) { didReceiveStripeCheckoutAbandonMessage = true loadingState = .manualLoading closeSafari() + // A paywall that says whether to dismiss wants the purchase finished + // like an App Store purchase; one that doesn't gets the legacy restore. + let completion: StripeCheckoutCompletion = shouldDismiss.map { + .purchase(productId: productId, shouldDismiss: $0) + } ?? .restore + enqueueStripeCheckoutTask { paywall in await paywall.webEntitlementRedeemer.handleStripeCheckoutComplete( contextId: checkoutContextId, - productId: productId + productId: productId, + completion: completion ) } } @@ -1410,7 +1426,36 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { present(safariVC, animated: true) } - func presentSafariExternal(_ url: URL) { + func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool, isTeleport: Bool) { + // Only a teleport needs the cover. It's there even when no waiting screen is drawn, so the + // page still hears the return link. + guard isTeleport else { + openExternally(url) + return + } + teleportReturnCover.leave(drawingWaitingScreen: drawsWaitingScreen) { [weak self] in + self?.openExternally(url) + } + } + + func startTeleportWatch(_ watch: TeleportReturnCover.Watch) { + teleportReturnCover.startWatching(watch) + } + + func endTeleportWatch() { + existingTeleportReturnCover?.stopWatching() + } + + private var teleportReturnCover: TeleportReturnCover { + if let existingTeleportReturnCover { + return existingTeleportReturnCover + } + let cover = TeleportReturnCover(view: view, webView: webView) + existingTeleportReturnCover = cover + return cover + } + + private func openExternally(_ url: URL) { guard let sharedApplication = UIApplication.sharedApplication else { return } @@ -1582,6 +1627,9 @@ extension PaywallViewController { didCallDelegate = false paywall.closeReason = .none + // A purchase belongs to the presentation it was made in. It's cleared here rather than when + // the view disappears: a sheet swiped away disappears before its close is reported. + completedPurchaseProduct = nil Superwall.shared.dependencyContainer.delegateAdapter.willPresentPaywall(withInfo: info) webView.scrollView.contentOffset = CGPoint.zero @@ -1651,6 +1699,7 @@ extension PaywallViewController { override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + existingTeleportReturnCover?.reset() guard isPresented else { return } @@ -1676,11 +1725,32 @@ extension PaywallViewController { presentationDidFinishPrepare = false } + /// Records a completed purchase before the paywall hears `transaction_complete`. + func markPurchaseCompleted(_ product: StoreProduct) { + completedPurchaseProduct = product + } + + /// The result a dismissal reports: a decline after a completed purchase is a purchase. A close + /// to show the next paywall stays as it is, so that paywall keeps the presentation's state. + func resolvedDismissal( + result: PaywallResult, + closeReason: PaywallCloseReason + ) -> (result: PaywallResult, closeReason: PaywallCloseReason) { + if closeReason == .forNextPaywall { + return (result, closeReason) + } + if result == .declined, let product = completedPurchaseProduct { + return (.purchased(product), .systemLogic) + } + return (result, closeReason) + } + func dismiss( result: PaywallResult, closeReason: PaywallCloseReason, completion: (() -> Void)? = nil ) { + let (result, closeReason) = resolvedDismissal(result: result, closeReason: closeReason) dismissCompletionBlock = completion paywallResult = result paywall.closeReason = closeReason diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift new file mode 100644 index 0000000000..93df113eaa --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -0,0 +1,615 @@ +// +// TeleportReturnCover.swift +// SuperwallKit +// +// swiftlint:disable file_length type_body_length + +import UIKit +import WebKit + +extension Notification.Name { + /// A Superwall return link (`scheme://superwall/return`) opened the app. + static let superwallReturnLinkOpened = Notification.Name("SuperwallReturnLinkOpened") +} + +/// Decides what the user sees of a teleport's waiting screen as they leave the app for the +/// browser and come back. +/// +/// The waiting screen has to be in place when they switch back by hand, so it must already be in +/// the snapshot iOS takes when the app leaves. But iOS also shows that snapshot while the app comes +/// back, however it comes back, and a user who returns through the return link, having finished or +/// left the web flow, must never see the screen. A webview paints nothing in the background, so: +/// - leaving, the page draws the screen under a still of the paywall, which comes off once the app +/// is in the background, so the app switcher shows the screen; +/// - in the background, where the page cannot run, the teleport is checked here. Once it is +/// settled, a picture of the paywall without the screen covers it and iOS retakes the snapshot; +/// - just before iOS suspends the app, nothing settled yet, it covers the screen the same way. A +/// return link can arrive at any time after that, and iOS can no longer be asked to retake the +/// snapshot then. The app switcher shows the paywall from then on; +/// - a return link covers it before the first frame, whatever else happened. +/// Back in the app, through the return link or settled, the page removes the screen and the cover +/// comes off. Back by hand with the teleport still open, the cover fades and shows the screen. +/// +/// The page's `showTeleportWaitingScreen` and `handleTeleportReturn` were first named +/// `showCheckoutWaitingScreen` and `hideCheckoutWaitingScreen`; pages built before the rename only +/// have the old names, so each call falls back to them. +@MainActor +final class TeleportReturnCover: NSObject { + // Picks the function before calling it, so a page with both names only has one called. + static let showWaitingScreenScript = """ + const app = window.app; + const show = app?.showTeleportWaitingScreen ?? app?.showCheckoutWaitingScreen; + return await show?.call(app) ?? false + """ + static let returnScript = """ + const app = window.app; + const handleReturn = app?.handleTeleportReturn ?? app?.hideCheckoutWaitingScreen; + return await handleReturn?.call(app, { reason }) ?? false + """ + + /// What the page hands over to check its teleport while the waiting screen is up. + struct Watch: Equatable { + let teleportId: String + let teleportStatusUrl: URL + /// Where a checkout the teleport led to is read. Nil for a web flow without a checkout. + let checkoutStatusUrl: URL? + let publicApiKey: String + } + + /// Why the waiting screen is covered. + private enum Reason { + /// The teleport settled while the app was in the background. + case settled + /// The return link opened the app. + case returnLink + /// The app was about to be suspended. The teleport may still be open. + case suspending + } + + /// How long each step waits. Tests shorten these. + struct Timing { + /// How long leaving waits for the page to draw the waiting screen. + var drawWait: UInt64 = 500_000_000 + /// How long the still stays up when the app does not leave (the browser did not open). + var stillTimeout: UInt64 = 2_000_000_000 + /// The longest the teleport is checked in the background. + var backgroundCheckLimit: TimeInterval = 25 + /// How much background time is kept to cover the screen and have iOS retake the snapshot. + var suspendLead: TimeInterval = 4 + var backgroundCheckInterval: UInt64 = 1_000_000_000 + /// How long the app keeps running once it asked iOS to retake the snapshot. + var snapshotLinger: UInt64 = 1_000_000_000 + /// How long a return by hand waits for a return link before showing the screen again. Some + /// apps hand the link over only after the app is active. + var revealGrace: UInt64 = 350_000_000 + var revealDuration: TimeInterval = 0.25 + } + + /// What the cover asks of iOS and the network. Tests stand these in. + struct Environment { + var applicationState: @MainActor () -> UIApplication.State = { + UIApplication.sharedApplication?.applicationState ?? .active + } + var isSettled: (Watch) async -> Bool = TeleportStatusCheck.isSettled + var makeStill: @MainActor (UIView) -> UIView? = { $0.snapshotView(afterScreenUpdates: false) } + /// Nil when there is no application to ask for background time. + var beginBackgroundTask: @MainActor ( + @escaping @Sendable () -> Void + ) -> UIBackgroundTaskIdentifier? = { handler in + UIApplication.sharedApplication?.beginBackgroundTask( + withName: "Superwall teleport", + expirationHandler: handler + ) + } + var endBackgroundTask: @MainActor (UIBackgroundTaskIdentifier) -> Void = { + UIApplication.sharedApplication?.endBackgroundTask($0) + } + var backgroundTimeRemaining: @MainActor () -> TimeInterval = { + UIApplication.sharedApplication?.backgroundTimeRemaining ?? 0 + } + /// Where the app lifecycle and return link notifications arrive. + var notificationCenter: NotificationCenter = .default + } + + private let timing: Timing + private let environment: Environment + + private weak var view: UIView? + private weak var webView: WKWebView? + private var leavingStill: UIView? + private var pendingOpen: (id: UUID, open: () -> Void)? + /// The paywall as it was before the waiting screen went up. + private var paywallPicture: UIImage? + private var watch: Watch? + private var returnCover: (view: UIView, reason: Reason)? + /// Why the return link brought the user back, as the page put it in the link. + private var returnReason: String? + private var uncovering: UUID? + private var revealing: UUID? + private var backgroundCheck: Task? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + /// Whether a picture of the paywall is over the waiting screen. + var isCovering: Bool { + returnCover != nil + } + + /// The cover's opacity, for tests of the fade. + var coverAlpha: CGFloat? { + returnCover?.view.alpha + } + + init( + view: UIView, + webView: WKWebView, + timing: Timing = Timing(), + environment: Environment = Environment() + ) { + self.view = view + self.webView = webView + self.timing = timing + self.environment = environment + super.init() + let center = environment.notificationCenter + // Called synchronously: iOS takes the snapshot as soon as these observers return. + center.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(returnLinkOpened), + name: .superwallReturnLinkOpened, + object: nil + ) + } + + /// Has the page draw its waiting screen under a still of the paywall, then calls `open`. + func leave(drawingWaitingScreen: Bool, open: @escaping () -> Void) { + guard + drawingWaitingScreen, + let view, + let webView, + let still = environment.makeStill(view) + else { + open() + return + } + removeLeavingStill() + paywallPicture = UIGraphicsImageRenderer(bounds: view.bounds).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) + } + still.frame = view.bounds + still.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(still) + leavingStill = still + + let id = UUID() + pendingOpen = (id, open) + webView.callAsyncJavaScript( + Self.showWaitingScreenScript, + arguments: [:], + in: nil, + in: .page + ) { [weak self] _ in + Task { @MainActor in + self?.open(id) + } + } + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.drawWait) + self?.open(id) + } + } + + func startWatching(_ watch: Watch) { + self.watch = watch + } + + func stopWatching() { + watch = nil + stopBackgroundCheck() + } + + /// The paywall is gone: nothing of a teleport stays over it. + func reset() { + removeLeavingStill() + removeCover() + pendingOpen = nil + paywallPicture = nil + stopWatching() + } + + private func open(_ id: UUID) { + guard let pending = pendingOpen, pending.id == id else { + return + } + pendingOpen = nil + pending.open() + let still = leavingStill + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.stillTimeout) + // A later leave put up its own still, which its own timeout takes down. + guard + let self, + self.leavingStill === still, + environment.applicationState() == .active + else { + return + } + self.removeLeavingStill() + } + } + + private func removeLeavingStill() { + leavingStill?.removeFromSuperview() + leavingStill = nil + } + + // MARK: - App lifecycle + + @objc private func appDidEnterBackground() { + if leavingStill != nil { + removeLeavingStill() + // Commit now, so the snapshot shows the waiting screen rather than the still. + CATransaction.flush() + } + if watch != nil, paywallPicture != nil, returnCover == nil { + checkInBackground() + } + } + + @objc private func appWillEnterForeground() { + stopBackgroundCheck() + } + + @objc private func appDidBecomeActive() { + guard let returnCover else { + return + } + switch returnCover.reason { + case .returnLink: + uncover() + case .settled: + uncoverAfterGrace() + case .suspending: + revealAfterGrace() + } + } + + @objc private func returnLinkOpened(_ notification: Notification) { + returnReason = notification.userInfo?["reason"] as? String + guard watch != nil || returnCover != nil else { + // No waiting screen to cover, but the page still has to know the user is back: it + // checks the teleport for a while, then treats an unfinished one as abandoned. + tellPageOfReturn() + return + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Teleport: return link opened, covering the waiting screen" + ) + if let returnCover { + self.returnCover = (returnCover.view, .returnLink) + } else { + cover(.returnLink) + } + guard returnCover != nil else { + // No picture of the paywall to cover with (the page reloaded since it left). + tellPageOfReturn() + return + } + if environment.applicationState() == .active { + uncover() + } + } + + // MARK: - Background check + + private func checkInBackground() { + stopBackgroundCheck() + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Teleport: checking the teleport while the app is in the background" + ) + // Out of time before the check finished: cover the screen while iOS still listens. iOS + // calls this on the main thread and expects the task ended before it returns, so no hop. + guard let task = environment.beginBackgroundTask({ [weak self] in + MainActor.assumeIsolated { + guard let self else { + return + } + self.coverBeforeSuspending() + self.stopBackgroundCheck() + } + }) else { + return + } + backgroundTask = task + // Leaves enough of the background time to cover the screen and have the snapshot retaken. + let remaining = environment.backgroundTimeRemaining() + let checkFor = min(timing.backgroundCheckLimit, max(0, remaining - timing.suspendLead)) + backgroundCheck = Task { @MainActor [weak self, timing] in + let deadline = Date().addingTimeInterval(checkFor) + while !Task.isCancelled, Date() < deadline { + guard let watch = self?.watch else { + break + } + if await self?.environment.isSettled(watch) ?? false { + guard !Task.isCancelled else { + break + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Teleport: settled in the background, covering the waiting screen" + ) + self?.cover(.settled) + self?.refreshSnapshot() + try? await Task.sleep(nanoseconds: timing.snapshotLinger) + self?.endBackgroundTask() + return + } + try? await Task.sleep(nanoseconds: timing.backgroundCheckInterval) + } + guard !Task.isCancelled else { + return + } + self?.coverBeforeSuspending() + try? await Task.sleep(nanoseconds: timing.snapshotLinger) + self?.endBackgroundTask() + } + } + + /// iOS is about to suspend the app, and a return link that arrives after that would come back + /// to a snapshot with the waiting screen in it. So the snapshot goes back to the paywall. + private func coverBeforeSuspending() { + guard watch != nil, returnCover == nil else { + return + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Teleport: suspending with the teleport open, covering the waiting screen" + ) + cover(.suspending) + refreshSnapshot() + } + + private func stopBackgroundCheck() { + backgroundCheck?.cancel() + backgroundCheck = nil + endBackgroundTask() + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { + return + } + environment.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + + // MARK: - Cover + + private func cover(_ reason: Reason) { + guard returnCover == nil, let view, let paywallPicture else { + return + } + let cover = UIImageView(image: paywallPicture) + cover.frame = view.bounds + cover.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(cover) + returnCover = (cover, reason) + CATransaction.flush() + } + + private func removeCover() { + returnCover?.view.removeFromSuperview() + returnCover = nil + returnReason = nil + uncovering = nil + revealing = nil + } + + private func refreshSnapshot() { + guard let session = view?.window?.windowScene?.session else { + return + } + UIApplication.sharedApplication?.requestSceneSessionRefresh(session) + } + + /// Has the page remove the waiting screen, then takes the cover off once that is painted. + private func uncover() { + guard let returnCover, uncovering == nil else { + return + } + revealing = nil + // A fade in progress (back by hand, then the return link) stops: the cover stays whole + // until the page has taken the waiting screen down. + returnCover.view.layer.removeAllAnimations() + returnCover.view.alpha = 1 + let id = UUID() + uncovering = id + if let webView { + webView.callAsyncJavaScript( + Self.returnScript, + arguments: ["reason": returnReason ?? ""], + in: nil, + in: .page + ) { [weak self] _ in + Task { @MainActor in + self?.finishUncovering(id) + } + } + } + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.drawWait) + self?.finishUncovering(id) + } + } + + private func finishUncovering(_ id: UUID) { + guard uncovering == id else { + return + } + removeCover() + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Teleport: waiting screen removed, cover off" + ) + } +} + +// MARK: - Coming back + +private extension TeleportReturnCover { + func tellPageOfReturn() { + guard let webView else { + return + } + webView.callAsyncJavaScript( + Self.returnScript, + arguments: ["reason": returnReason ?? ""], + in: nil, + in: .page, + completionHandler: nil + ) + returnReason = nil + } + + /// Settled in the background: the page is told the user is back once a return link has had the + /// chance to arrive, so it hears why they came back when they came back through the link. + func uncoverAfterGrace() { + guard revealing == nil, uncovering == nil else { + return + } + let id = UUID() + revealing = id + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.revealGrace) + guard let self, self.revealing == id else { + return + } + self.revealing = nil + guard environment.applicationState() == .active else { + return + } + self.uncover() + } + } + + /// Back by hand with the teleport still open: the waiting screen is still the right thing to + /// show, so the cover fades off it, unless a return link turns up first. + func revealAfterGrace() { + guard revealing == nil, uncovering == nil else { + return + } + let id = UUID() + revealing = id + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.revealGrace) + guard let self, self.revealing == id else { + return + } + // Whatever happens next, the next activation may try again. + self.revealing = nil + guard + let returnCover = self.returnCover, + returnCover.reason == .suspending, + environment.applicationState() == .active + else { + return + } + // The cover is the paywall, so this reads as the waiting screen coming up over it. + UIView.animate( + withDuration: timing.revealDuration, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction] + ) { + returnCover.view.alpha = 0 + } completion: { [weak self] finished in + // A return link that arrived mid-fade took the cover over; it comes off with the page. + guard + let self, + finished, + self.uncovering == nil, + self.returnCover?.view === returnCover.view + else { + return + } + self.removeCover() + } + } + } +} + +/// Reads a teleport's status from the background, where the paywall page cannot run. +enum TeleportStatusCheck { + /// Completed or expired, or the checkout it led to paid: the waiting screen goes, and the user + /// likely comes back through the return link. + static func isSettled(_ watch: TeleportReturnCover.Watch) async -> Bool { + guard let teleport = await post( + watch.teleportStatusUrl, + body: ["teleportId": watch.teleportId], + publicApiKey: watch.publicApiKey + ) else { + return false + } + switch teleport["status"] as? String { + case "completed", + "expired": + return true + default: + break + } + guard + let checkoutStatusUrl = watch.checkoutStatusUrl, + let checkoutId = teleport["checkoutContextId"] as? String, + let checkout = await post( + checkoutStatusUrl, + body: ["checkoutId": checkoutId], + publicApiKey: watch.publicApiKey + ) + else { + return false + } + return checkout["status"] as? String == "completed" + } + + private static func post( + _ url: URL, + body: [String: String], + publicApiKey: String + ) async -> [String: Any]? { + var request = URLRequest(url: url, timeoutInterval: 5) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(publicApiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + guard + let (data, response) = try? await URLSession.shared.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200 + else { + return nil + } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} 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 fe946a2dde..b00481d8df 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 @@ -49,7 +49,13 @@ enum PaywallMessage: Decodable, Equatable { case close case restore case openUrl(_ url: URL) - case openUrlInSafari(_ url: URL) + case openUrlInSafari( + _ url: URL, + drawsWaitingScreen: Bool = false, + isTeleport: Bool = false + ) + case teleportWatchStart(TeleportReturnCover.Watch) + case teleportWatchEnd case openPaymentSheet(_ url: URL) case openDeepLink(url: URL) case purchase(productId: String, shouldDismiss: Bool) @@ -60,7 +66,8 @@ enum PaywallMessage: Decodable, Equatable { case stripeCheckoutStart(checkoutContextId: String, productId: String) case stripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) case stripeCheckoutSubmit(checkoutContextId: String, productId: String) case stripeCheckoutFail(checkoutContextId: String, productId: String) @@ -106,6 +113,8 @@ enum PaywallMessage: Decodable, Equatable { case restore case openUrl = "open_url" case openUrlInSafari = "open_url_external" + case teleportWatchStart = "teleport_watch_start" + case teleportWatchEnd = "teleport_watch_end" case openDeepLink = "open_deep_link" case purchase case custom @@ -140,6 +149,12 @@ enum PaywallMessage: Decodable, Equatable { case attributes case reviewType case browserType + case waitingScreen + case teleport + case teleportId + case teleportStatusUrl + case checkoutStatusUrl + case publicApiKey case checkoutContextId case type case title @@ -193,9 +208,32 @@ enum PaywallMessage: Decodable, Equatable { case .openUrlInSafari: if let urlString = try? values.decode(String.self, forKey: .url), let url = URL(string: urlString) { - self = .openUrlInSafari(url) + let waitingScreen = (try? values.decodeIfPresent(Bool.self, forKey: .waitingScreen)) ?? false + let teleport = (try? values.decodeIfPresent(Bool.self, forKey: .teleport)) ?? false + self = .openUrlInSafari( + url, + drawsWaitingScreen: waitingScreen, + isTeleport: teleport + ) return } + case .teleportWatchStart: + if let teleportId = try? values.decode(String.self, forKey: .teleportId), + let teleportStatusUrl = try? values.decode(URL.self, forKey: .teleportStatusUrl), + let publicApiKey = try? values.decode(String.self, forKey: .publicApiKey) { + self = .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: teleportId, + teleportStatusUrl: teleportStatusUrl, + checkoutStatusUrl: try? values.decodeIfPresent(URL.self, forKey: .checkoutStatusUrl), + publicApiKey: publicApiKey + ) + ) + return + } + case .teleportWatchEnd: + self = .teleportWatchEnd + return case .openDeepLink: if let urlString = try? values.decode(String.self, forKey: .link), let url = URL(string: urlString) { @@ -235,9 +273,11 @@ enum PaywallMessage: Decodable, Equatable { case .stripeCheckoutComplete: if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), let productId = try? values.decode(String.self, forKey: .productId) { + let shouldDismiss = try? values.decodeIfPresent(Bool.self, forKey: .shouldDismiss) self = .stripeCheckoutComplete( checkoutContextId: checkoutContextId, - productId: productId + productId: productId, + shouldDismiss: shouldDismiss ) return } 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 2d47387c5b..390b109abd 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 @@ -20,13 +20,16 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func eventDidOccur(_ paywallWebEvent: PaywallWebEvent) func openDeepLink(_ url: URL, shouldDismiss: Bool) func presentSafariInApp(_ url: URL) - func presentSafariExternal(_ url: URL) + func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool, isTeleport: Bool) + func startTeleportWatch(_ watch: TeleportReturnCover.Watch) + func endTeleportWatch() func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) func revealWebViewBehindSpinner() @@ -52,6 +55,11 @@ final class PaywallMessageHandler: WebEventDelegate { /// ready. private var documentId = 0 + /// Tracks an event. Replaced in tests to observe what's tracked. + var trackEvent: (Trackable) async -> Void = { event in + _ = await Superwall.shared.track(event) + } + init( receiptManager: ReceiptManager, factory: VariablesFactory, @@ -103,8 +111,9 @@ final class PaywallMessageHandler: WebEventDelegate { // A page that isn't ready yet is told once it is. if isDocumentReady { let paywallOpen = SuperwallEventObjc.paywallOpen.description + let payload = presentationPayload() Task { - await self.pass(placement: paywallOpen, from: paywall) + await self.pass(placement: paywallOpen, from: paywall, payload: payload) } } case .paywallClose: @@ -187,8 +196,16 @@ final class PaywallMessageHandler: WebEventDelegate { } case .openUrl(let url): openUrl(url) - case .openUrlInSafari(let url): - openUrlInSafari(url) + case let .openUrlInSafari(url, drawsWaitingScreen, isTeleport): + openUrlInSafari( + url, + drawsWaitingScreen: drawsWaitingScreen, + isTeleport: isTeleport + ) + case .teleportWatchStart(let watch): + delegate?.startTeleportWatch(watch) + case .teleportWatchEnd: + delegate?.endTeleportWatch() case .openPaymentSheet(let url): openPaymentSheet(url) case .openDeepLink(let url): @@ -212,14 +229,15 @@ final class PaywallMessageHandler: WebEventDelegate { state: .start, productId: productId ) - case let .stripeCheckoutComplete(checkoutContextId, productId): + case let .stripeCheckoutComplete(checkoutContextId, productId, shouldDismiss): trackStripeCheckoutEvent( state: .complete, productId: productId ) delegate?.handleStripeCheckoutComplete( checkoutContextId: checkoutContextId, - productId: productId + productId: productId, + shouldDismiss: shouldDismiss ) case let .stripeCheckoutSubmit(checkoutContextId, productId): trackStripeCheckoutEvent( @@ -284,10 +302,25 @@ final class PaywallMessageHandler: WebEventDelegate { /// Called when a new page replaces the old one, and when the SDK starts /// loading a page over one that is still on screen. The old page isn't /// told about the paywall opening or closing, and the new one is told the - /// paywall is open once it pings. + /// paywall is open once it pings. A teleport the old page was watching ends + /// with it, since the new page will never end it. func documentWillLoad() { isDocumentReady = false documentId += 1 + delegate?.endTeleportWatch() + } + + /// How this presentation came about, so purchases the paywall starts are attributed to it. + private func presentationPayload() -> [String: Any] { + guard let info = delegate?.info else { + return [:] + } + var payload: [String: Any] = ["presented_by": info.presentedBy] + payload["presentation_id"] = info.presentationId + payload["presentation_source_type"] = info.presentationSourceType + payload["presented_by_event_id"] = info.presentedByPlacementWithId + payload["presented_by_event_name"] = info.presentedByPlacementWithName + return payload } nonisolated private func pass( @@ -490,8 +523,9 @@ final class PaywallMessageHandler: WebEventDelegate { isDocumentReady = true if isPaywallOpen { let paywallOpen = SuperwallEventObjc.paywallOpen.description + let payload = presentationPayload() Task { - await self.pass(placement: paywallOpen, from: paywall) + await self.pass(placement: paywallOpen, from: paywall, payload: payload) } } } @@ -499,7 +533,7 @@ final class PaywallMessageHandler: WebEventDelegate { private func openUrl(_ url: URL) { #if os(visionOS) - openUrlInSafari(url) + openUrlInSafari(url, drawsWaitingScreen: false, isTeleport: false) #else detectHiddenPaywallEvent( "openUrl", @@ -511,14 +545,25 @@ final class PaywallMessageHandler: WebEventDelegate { #endif } - private func openUrlInSafari(_ url: URL) { + private func openUrlInSafari( + _ url: URL, + drawsWaitingScreen: Bool, + isTeleport: Bool + ) { detectHiddenPaywallEvent( "openUrlInSafari", userInfo: ["url": url] ) hapticFeedback() delegate?.eventDidOccur(.openedUrlInSafari(url)) - delegate?.presentSafariExternal(url) + if isTeleport { + trackTeleportOpen() + } + delegate?.presentSafariExternal( + url, + drawsWaitingScreen: drawsWaitingScreen, + isTeleport: isTeleport + ) } private func openPaymentSheet(_ url: URL) { @@ -600,6 +645,15 @@ final class PaywallMessageHandler: WebEventDelegate { } } + private func trackTeleportOpen() { + guard let delegate = delegate else { return } + let event = InternalSuperwallEvent.TeleportOpen(paywallInfo: delegate.info) + let trackEvent = self.trackEvent + Task { + await trackEvent(event) + } + } + private func detectHiddenPaywallEvent( _ eventName: String, userInfo: [String: Any]? = nil diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index 8b89bcc97c..10a5727234 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -52,7 +52,7 @@ class SWWebView: WKWebView { isMac: Bool, messageHandler: PaywallMessageHandler, isOnDeviceCacheEnabled: Bool, - factory: FeatureFlagsFactory & DeviceHelperFactory + factory: FeatureFlagsFactory & DeviceHelperFactory & OptionsFactory ) { self.isMac = isMac self.messageHandler = messageHandler @@ -128,6 +128,17 @@ class SWWebView: WKWebView { ) ) } + if let environmentSource = SubscriptionsApiEnvironmentScript.source( + for: factory.makeSuperwallOptions().networkEnvironment + ) { + wkConfig.userContentController.addUserScript( + WKUserScript( + source: environmentSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + ) + } self.navigationDelegate = self translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift new file mode 100644 index 0000000000..688adbc8d9 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift @@ -0,0 +1,29 @@ +// +// SubscriptionsApiEnvironmentScript.swift +// SuperwallKit +// + +import Foundation + +/// Tells paywall.js which subscriptions-api to call for web checkouts and teleports. +/// +/// A native paywall is served from the same CDN host in every environment, so the +/// page cannot infer the environment from its own URL the way a hosted web paywall +/// can. Without this it falls back to production, which silently breaks a +/// `.developer` or `.local` SDK testing a teleport or web checkout against that stack. +enum SubscriptionsApiEnvironmentScript { + /// The script source, or `nil` when the environment is production or unknown and + /// paywall.js should keep its own default. + static func source(for environment: SuperwallOptions.NetworkEnvironment) -> String? { + let value: String + switch environment { + case .developer: + value = "staging" + case .local: + value = "local" + case .release, .releaseCandidate, .custom: + return nil + } + return "window.__SW_SUBSCRIPTIONS_API_ENV__ = \"\(value)\";" + } +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index fb634ba103..5bd2681fb9 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -1141,6 +1141,7 @@ final class TransactionManager { case .internal(_, let paywallViewController, _): paywallInfo = await paywallViewController.info eventSource = .internal + await paywallViewController.markPurchaseCompleted(product) await paywallViewController.webView.messageHandler .handle( .transactionComplete( diff --git a/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift index 2fbc4a1028..18dcc92447 100644 --- a/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift +++ b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift @@ -12,25 +12,44 @@ struct PendingStripeCheckoutPollState: Codable, Equatable { let checkoutContextId: String let productId: String + /// How the checkout finishes once its code is redeemed. A recovery poll (on foreground or + /// paywall open) finishes it the way the paywall asked, not as a restore. + let completion: StripeCheckoutCompletion let remainingForegroundAttempts: Int let updatedAt: Date init( checkoutContextId: String, productId: String, + completion: StripeCheckoutCompletion = .restore, remainingForegroundAttempts: Int = defaultForegroundAttempts, updatedAt: Date = Date() ) { self.checkoutContextId = checkoutContextId self.productId = productId + self.completion = completion self.remainingForegroundAttempts = remainingForegroundAttempts self.updatedAt = updatedAt } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + checkoutContextId = try container.decode(String.self, forKey: .checkoutContextId) + productId = try container.decode(String.self, forKey: .productId) + // State saved by an SDK before the completion was stored finishes as a restore, as it did then. + completion = try container.decodeIfPresent( + StripeCheckoutCompletion.self, + forKey: .completion + ) ?? .restore + remainingForegroundAttempts = try container.decode(Int.self, forKey: .remainingForegroundAttempts) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } + func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { PendingStripeCheckoutPollState( checkoutContextId: checkoutContextId, productId: productId, + completion: completion, remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), updatedAt: Date() ) diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index c101f00df3..d9505a987c 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -9,6 +9,18 @@ import Foundation import UIKit +/// How a web checkout the paywall reported as complete finishes once its code is redeemed. +/// Stored with the pending checkout, so a poll that recovers it later finishes it the same way. +enum StripeCheckoutCompletion: Equatable, Codable { + /// Legacy: the paywall hears `restore_complete`, and the paywall dismisses + /// as `.restored` once its entitlements are active. + case restore + /// Like an App Store purchase: the paywall hears `transaction_complete`, so + /// the button's after-purchase actions run, and it dismisses as + /// `.purchased` only when `shouldDismiss` is true. + case purchase(productId: String, shouldDismiss: Bool) +} + actor WebEntitlementRedeemer { private let network: Network private let storage: Storage @@ -24,6 +36,9 @@ actor WebEntitlementRedeemer { private var hasActiveStripePoll = false private var lastCompletedStripePollResult: (contextId: String, outcome: StripePollOutcome)? private var awaitingCheckoutComplete = false + /// The checkout the paywall last reported complete, while it's being finished. An older + /// checkout's poll stops for it, so it doesn't wait behind a poll that can't redeem it. + private var completingCheckoutContextId: String? private var superwall: Superwall? typealias Factory = WebEntitlementFactory & OptionsFactory @@ -155,6 +170,21 @@ actor WebEntitlementRedeemer { return true } + private func isRedeemed(_ contextId: String) -> Bool { + lastCompletedStripePollResult?.contextId == contextId + && lastCompletedStripePollResult?.outcome == .redeemed + } + + private func waitForActiveStripePoll() async { + let waitStart = DispatchTime.now().uptimeNanoseconds + while hasActiveStripePoll, !Task.isCancelled { + if DispatchTime.now().uptimeNanoseconds - waitStart >= stripePendingPollTimeoutNs { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + /// Either starts a new poll or waits for an existing in-flight poll to /// finish. Returns `true` if the checkout was redeemed. func pollOrWaitForActiveStripePoll() async -> Bool { @@ -166,16 +196,9 @@ actor WebEntitlementRedeemer { // If there's already an active poll (e.g. from cold-launch init task), // wait for it to finish rather than skipping. if hasActiveStripePoll { - let waitStart = DispatchTime.now().uptimeNanoseconds - while hasActiveStripePoll { - if Task.isCancelled { - return false - } - let elapsed = DispatchTime.now().uptimeNanoseconds - waitStart - if elapsed >= stripePendingPollTimeoutNs { - return false - } - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + await waitForActiveStripePoll() + if hasActiveStripePoll || Task.isCancelled { + return false } if let completed = lastCompletedStripePollResult, completed.contextId == waitingContextId { @@ -200,7 +223,8 @@ actor WebEntitlementRedeemer { let outcome = await pollStripeRedemptionResult( contextId: pendingState.checkoutContextId, productId: pendingState.productId, - trigger: .paywallOpen + trigger: .paywallOpen, + completion: pendingState.completion ) return outcome == .redeemed @@ -208,16 +232,40 @@ actor WebEntitlementRedeemer { func handleStripeCheckoutComplete( contextId: String, - productId: String + productId: String, + completion: StripeCheckoutCompletion = .restore ) async { awaitingCheckoutComplete = false + // The page can report one purchase more than once (its status poll and the return check). + // Redeemed once is enough: a second redemption would put the spinner back up behind the + // success alert, with nothing left to take it down. + if isRedeemed(contextId) { + let superwall = self.superwall ?? Superwall.shared + await MainActor.run { + // The success alert's close takes the spinner down once the purchase is finished. + let paywallViewController = superwall.paywallViewController + if !(paywallViewController?.presentedViewController is UIAlertController) { + paywallViewController?.loadingState = .ready + } + } + return + } + + completingCheckoutContextId = contextId + defer { + if completingCheckoutContextId == contextId { + completingCheckoutContextId = nil + } + } + if let existingState = pendingStripeCheckoutState, existingState.checkoutContextId == contextId { savePendingStripeCheckoutState( .init( checkoutContextId: contextId, productId: productId, + completion: completion, remainingForegroundAttempts: existingState.remainingForegroundAttempts ) ) @@ -225,20 +273,37 @@ actor WebEntitlementRedeemer { savePendingStripeCheckoutState( .init( checkoutContextId: contextId, - productId: productId + productId: productId, + completion: completion ) ) } - let outcome = await pollStripeRedemptionResult( + var outcome = await pollStripeRedemptionResult( contextId: contextId, productId: productId, - trigger: .checkoutComplete + trigger: .checkoutComplete, + completion: completion ) - // Don't hide spinner if another poll is in-flight — it will handle the - // loading state when it finishes. - if outcome != .redeemed, outcome != .skippedInFlight { + // A poll already in flight is for another checkout (an older pending one, from the + // foreground or paywall open), so it never redeems this one. Wait it out, then poll this + // checkout: skipping left the spinner up with nothing left to take it down. + while outcome == .skippedInFlight, !Task.isCancelled { + await waitForActiveStripePoll() + if isRedeemed(contextId) { + outcome = .redeemed + break + } + outcome = await pollStripeRedemptionResult( + contextId: contextId, + productId: productId, + trigger: .checkoutComplete, + completion: completion + ) + } + + if outcome != .redeemed { let superwall = self.superwall ?? Superwall.shared await MainActor.run { superwall.paywallViewController?.loadingState = .ready @@ -279,7 +344,8 @@ actor WebEntitlementRedeemer { let outcome = await pollStripeRedemptionResult( contextId: pendingState.checkoutContextId, productId: pendingState.productId, - trigger: .foreground + trigger: .foreground, + completion: pendingState.completion ) // Consume foreground attempts after each trigger completes, except when skipped @@ -428,7 +494,9 @@ actor WebEntitlementRedeemer { response: RedeemResponse, type: RedeemType, superwall: Superwall, - callbackMode: RedemptionCallbackMode + callbackMode: RedemptionCallbackMode, + completion: StripeCheckoutCompletion = .restore, + checkoutContextId: String? = nil ) async { storage.save(Date(), forType: LastWebEntitlementsFetchDate.self) @@ -443,6 +511,7 @@ actor WebEntitlementRedeemer { let (allEntitlements, paywallEntitlementIds) = await processEntitlements( response: response, type: type, + completion: completion, superwall: superwall ) @@ -462,7 +531,9 @@ actor WebEntitlementRedeemer { allEntitlementIds: Set(allEntitlements.map { $0.id }), paywallEntitlementIds: paywallEntitlementIds, superwall: superwall, - callbackMode: callbackMode + callbackMode: callbackMode, + completion: completion, + checkoutContextId: checkoutContextId ) } } @@ -470,6 +541,7 @@ actor WebEntitlementRedeemer { private func processEntitlements( response: RedeemResponse, type: RedeemType, + completion: StripeCheckoutCompletion, superwall: Superwall ) async -> (allEntitlements: Set, paywallEntitlementIds: Set) { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() @@ -480,7 +552,10 @@ actor WebEntitlementRedeemer { var paywallEntitlementIds: Set = [] - if case .code = type, let paywallVc = superwall.paywallViewController { + // A restore is only complete once every entitlement the paywall offers is active, and the + // paywall hears restore_complete or restore_fail. A purchase bought one product, so none of + // that applies: it hears transaction_complete instead. + if case .code = type, completion == .restore, let paywallVc = superwall.paywallViewController { for id in await paywallVc.info.productIds { let entitlements = superwall.entitlements.byProductId(id) paywallEntitlementIds.formUnion(entitlements.map { $0.id }) @@ -554,7 +629,9 @@ actor WebEntitlementRedeemer { allEntitlementIds: Set, paywallEntitlementIds: Set, superwall: Superwall, - callbackMode: RedemptionCallbackMode + callbackMode: RedemptionCallbackMode, + completion: StripeCheckoutCompletion, + checkoutContextId: String? ) async { guard let codeResult = response.results.first(where: { $0.code == code }) else { return } @@ -596,11 +673,25 @@ actor WebEntitlementRedeemer { } } - if let paywallVc = superwall.paywallViewController, - !paywallEntitlementIds.isEmpty, - paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, - superwallOptions.paywalls.automaticallyDismiss { - await superwall.dismiss(paywallVc, result: .restored) + switch completion { + case .restore: + if let paywallVc = superwall.paywallViewController, + !paywallEntitlementIds.isEmpty, + paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, + superwallOptions.paywalls.automaticallyDismiss { + await superwall.dismiss(paywallVc, result: .restored) + } + case let .purchase(productId, shouldDismiss): + if case .success(_, let redemptionInfo) = codeResult, + let paywallVc = superwall.paywallViewController { + await finishWebPurchase( + productId: productId, + trialPeriodDays: redemptionInfo.paywallInfo?.product?.trialPeriodDays ?? 0, + shouldDismiss: shouldDismiss && superwallOptions.paywalls.automaticallyDismiss, + paywallViewController: paywallVc, + superwall: superwall + ) + } } await MainActor.run { @@ -637,7 +728,7 @@ actor WebEntitlementRedeemer { onClose: { [weak self] in Task { await afterRedeem() - await self?.clearPendingStripeCheckoutState() + await self?.clearPendingStripeCheckoutState(for: checkoutContextId) } } ) @@ -649,14 +740,44 @@ actor WebEntitlementRedeemer { onClose: { [weak self] in Task { await afterRedeem() - await self?.clearPendingStripeCheckoutState() + await self?.clearPendingStripeCheckoutState(for: checkoutContextId) } } ) } } else { await afterRedeem() - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: checkoutContextId) + } + } + + /// Finishes a redeemed web purchase the way `TransactionManager` finishes + /// an App Store purchase made from a paywall. + private func finishWebPurchase( + productId: String, + trialPeriodDays: Int, + shouldDismiss: Bool, + paywallViewController: PaywallViewController, + superwall: Superwall + ) async { + let product = StoreProduct.blank(productIdentifier: productId) + let paywallInfo = await paywallViewController.info + let didStartFreeTrial = trialPeriodDays > 0 && paywallInfo.isFreeTrialAvailable + let trialEndDate = didStartFreeTrial + ? Calendar.current.date(byAdding: .day, value: trialPeriodDays, to: Date()) + : nil + + await paywallViewController.markPurchaseCompleted(product) + await paywallViewController.webView.messageHandler.handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: productId, + didStartFreeTrial: didStartFreeTrial + ) + ) + + if shouldDismiss { + await superwall.dismiss(paywallViewController, result: .purchased(product)) } } @@ -757,6 +878,16 @@ actor WebEntitlementRedeemer { storage.delete(PendingStripeCheckoutPollStorage.self) } + /// Clears the pending checkout only if it's this checkout's, so a poll that finishes an older + /// checkout leaves a newer one to be recovered. Nil clears whatever is pending. + private func clearPendingStripeCheckoutState(for checkoutContextId: String?) { + if let checkoutContextId, + pendingStripeCheckoutState?.checkoutContextId != checkoutContextId { + return + } + clearPendingStripeCheckoutState() + } + private func makePollRedemptionRequest( contextId: String ) -> PollRedemptionResultRequest { @@ -770,7 +901,8 @@ actor WebEntitlementRedeemer { private func pollStripeRedemptionResult( contextId: String, productId: String, - trigger: StripePollTrigger + trigger: StripePollTrigger, + completion: StripeCheckoutCompletion = .restore ) async -> StripePollOutcome { if hasActiveStripePoll { return .skippedInFlight @@ -793,6 +925,11 @@ actor WebEntitlementRedeemer { clearPendingStripeCheckoutState() return .noRedemptionFound } + // A newer checkout was reported complete: this one stops so that one is polled. One that + // was only started doesn't stop it, since this one may still be paid for. + if let completingCheckoutContextId, completingCheckoutContextId != contextId { + return .noRedemptionFound + } do { let response = try await network.pollRedemptionResult(request: request) @@ -803,7 +940,9 @@ actor WebEntitlementRedeemer { response: response, type: .code(code), superwall: superwall, - callbackMode: .pollFakeCompatibility + callbackMode: .pollFakeCompatibility, + completion: completion, + checkoutContextId: contextId ) finalOutcome = .redeemed return finalOutcome @@ -811,7 +950,7 @@ actor WebEntitlementRedeemer { switch response.status { case .failed: - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: contextId) finalOutcome = .checkoutFailed return finalOutcome case .pending: @@ -823,7 +962,7 @@ actor WebEntitlementRedeemer { try? await Task.sleep(nanoseconds: stripePendingPollIntervalNs) continue case .complete: - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: contextId) finalOutcome = .noRedemptionFound return finalOutcome case .none: diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 2ab569027c..8f0f53f945 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */; }; 4E5F1AB421E91E102B6D4800 /* OnDeviceCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5283BA49E380740C34D78856 /* OnDeviceCaching.swift */; }; 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A53CC571B7BC3F5AAD71BF /* ReceiptLogic.swift */; }; + 4ED5A9193F5672E254E8181F /* TeleportReturnCoverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */; }; 4EF50815E31F85400513F053 /* PresentationItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23F2FE294EBC63F81786A85 /* PresentationItems.swift */; }; 4EFA142D3D37B0564BDB52CB /* NetworkMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */; }; 4FE19D26711ECB7B2EE8806D /* TriggerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481D47E5121C521DDA268609 /* TriggerRule.swift */; }; @@ -250,6 +251,7 @@ 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC91544C3B7D209A8D51397F /* RawWebMessageHandlerTests.swift */; }; 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */; }; 5FB78EB52A12BA704AD3AE31 /* EventsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFDA9310B29A0C918D7A292 /* EventsResponse.swift */; }; + 616D346F1180226BBF7496CE /* SubscriptionsApiEnvironmentScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */; }; 61AE58F17230AE1578B9FB19 /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = C22CA9431D5F791BE7A9BE27 /* Documentation.docc */; }; 61EC2A032D127B323D4A8124 /* ConfirmPaywallAssignmentOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC7F8602B38644BCCDAD159 /* ConfirmPaywallAssignmentOperatorTests.swift */; }; 62CF18234F0EC4775DA8F828 /* UIViewController+AsyncPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */; }; @@ -335,6 +337,7 @@ 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 */; }; + 8135760014288BA11BF815EB /* TeleportReturnCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */; }; 81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; }; 82060DEBCCB16E69508D249D /* WebProductPricingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */; }; 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; @@ -656,6 +659,7 @@ F2D5ADFFC1DD842744E0160A /* StorePayment.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */; }; F30B4A30D481E4B2ACF58E33 /* SK2TransactionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C273F16E6EDA8803DE9DA47D /* SK2TransactionListener.swift */; }; F326AADBFE0083F8F18E81CE /* Date+WithinAnHourBefore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */; }; + F35F87C3126C03232F83292F /* SubscriptionsApiEnvironmentScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */; }; F365F06BBA58055920FC751B /* SuperwallPlacementInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF73F40EFFEC58FA0D30DC4 /* SuperwallPlacementInfo.swift */; }; F3A7AF6D766960ECFE03E4B8 /* UIViewController+TopVc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759D7C0FCB370EB4FC33F4E4 /* UIViewController+TopVc.swift */; }; F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */; }; @@ -729,6 +733,7 @@ 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManager.swift; sourceTree = ""; }; 054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitManagerTests.swift; sourceTree = ""; }; 0596DAAE31B2242A59060C5F /* StorePresentationObjects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePresentationObjects.swift; sourceTree = ""; }; + 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsApiEnvironmentScript.swift; sourceTree = ""; }; 0695B39826F85AACBA833B77 /* InAppReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = ""; }; 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreTransaction.swift; sourceTree = ""; }; 072886BB8C0E08DF414D9162 /* InAppReceiptPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayload.swift; sourceTree = ""; }; @@ -868,6 +873,7 @@ 35F538F901BAF97DDCA86F84 /* DeviceHelperMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperMock.swift; sourceTree = ""; }; 368EF475049935105AF8154C /* Entitlement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlement.swift; sourceTree = ""; }; 37B17A8801A2A9454E66D892 /* ASN1Decoder+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+Utils.swift"; sourceTree = ""; }; + 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeleportReturnCoverTests.swift; sourceTree = ""; }; 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockReceiptData.swift; sourceTree = ""; }; 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2Migrator.swift; sourceTree = ""; }; 3A7BF742EBC950966662849F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -1198,6 +1204,7 @@ ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationGrouping.swift; sourceTree = ""; }; 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 = ""; }; + AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeleportReturnCover.swift; 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 = ""; }; @@ -1378,6 +1385,7 @@ E4623C4E5EDA10B38746C384 /* LocationPermissionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegateTests.swift; sourceTree = ""; }; E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheTests.swift; sourceTree = ""; }; E51D0B38180377D9CD3E65DA /* Date+TimeIntervalMilliseconds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeIntervalMilliseconds.swift"; sourceTree = ""; }; + E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsApiEnvironmentScriptTests.swift; sourceTree = ""; }; E72593E1D4123B176EC83499 /* WebArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebArchive.swift; sourceTree = ""; }; E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManagerFakeDataMock.swift; sourceTree = ""; }; E7F1150DA75C81CB3815F2F4 /* ConfigurationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStatus.swift; sourceTree = ""; }; @@ -1817,6 +1825,7 @@ children = ( 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */, 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */, + 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */, 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */, 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */, 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */, @@ -1990,6 +1999,7 @@ children = ( AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */, 7569A0893012A88442B7ED36 /* PaywallViewController.swift */, + AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */, 6F9276EC956CE4A6C09949CE /* Delegates */, 1DE8CF6D5ACEFA6B6349B142 /* Loading */, 43AE802CA13E36803E2FF13E /* Popup Transition */, @@ -2044,6 +2054,7 @@ children = ( 01AC1F76564A6EC47EE696F9 /* DevicePreloadScriptTests.swift */, A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */, + E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */, 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */, 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */, FD8F67EFF69ECDBE85EB24F5 /* Message Handling */, @@ -2273,6 +2284,7 @@ A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */, 4BFFA527207A52EB7C70CAD4 /* PopupTransitionTests.swift */, B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */, + 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */, 3BE2C31A01D986D7FED5C4F4 /* Web View */, ); path = "View Controller"; @@ -3928,10 +3940,12 @@ 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */, 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, + 616D346F1180226BBF7496CE /* SubscriptionsApiEnvironmentScriptTests.swift in Sources */, ADFFF22341B34F9F28FE595B /* SupportEmailComposerTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, 18D39CB7BCF324B44197735D /* TaskRetryingTests.swift in Sources */, + 4ED5A9193F5672E254E8181F /* TeleportReturnCoverTests.swift in Sources */, 14D8827BA663A0EFD64DEC2C /* TemplateLogicTests.swift in Sources */, 7D47BABD89CE33CDD78DFCC6 /* TestFileManager.swift in Sources */, 5338AD57C30507242FFC0A39 /* TestModeManagerTests.swift in Sources */, @@ -4363,6 +4377,7 @@ 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */, C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, + F35F87C3126C03232F83292F /* SubscriptionsApiEnvironmentScript.swift in Sources */, 28A1E7AFF222A9C36F4AE019 /* Superwall+CustomerCenter.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, 17C0F1960CD89B6BFA8B2FBB /* SuperwallDelegate.swift in Sources */, @@ -4388,6 +4403,7 @@ 0A0BD14265AA8CE75A84940E /* TaskCoalescer.swift in Sources */, 1FFADB97F7E05549750E14E9 /* TaskExecutor.swift in Sources */, D916475C6CE464EEB094F419 /* TaskRetryLogic.swift in Sources */, + 8135760014288BA11BF815EB /* TeleportReturnCover.swift in Sources */, 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */, E0547EDA2BB0A807656EBFE7 /* TestModeDeviceAttributesViewController.swift in Sources */, FA382AF6BA204F0B158B7175 /* TestModeEntitlementRowView.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 4e9aa19859..744dade5f6 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -2820,4 +2820,56 @@ struct TrackingTests { #expect( result.parameters.audienceFilterParams["$paywall_count"] as! Int == paywallCount) } + + @Test func teleportEvents_namesAndObjcMirrors() { + let paywallInfo: PaywallInfo = .stub() + #expect(SuperwallEvent.teleportOpen(paywallInfo: paywallInfo).description == "teleport_open") + #expect(SuperwallEvent.teleportReturn(paywallInfo: nil).description == "teleport_return") + #expect( + SuperwallEventObjc(event: .teleportOpen(paywallInfo: paywallInfo)) + == .teleportOpen) + #expect(SuperwallEventObjc(event: .teleportReturn(paywallInfo: paywallInfo)) == .teleportReturn) + } + + @Test func teleportOpen() async { + let paywallInfo: PaywallInfo = .stub() + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportOpen(paywallInfo: paywallInfo)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect(result.parameters.audienceFilterParams["$is_standard_event"] as! Bool) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_open") + #expect( + result.parameters.audienceFilterParams["$paywall_id"] as! String == paywallInfo.databaseId) + #expect( + result.parameters.audienceFilterParams["$paywall_identifier"] as! String == + paywallInfo.identifier) + // The page's URL carries its sealed teleport, so it stays out of analytics. + #expect(result.parameters.audienceFilterParams["$url"] == nil) + #expect( + result.parameters.audienceFilterParams["paywall_id"] as! String == paywallInfo.databaseId) + } + + @Test func teleportReturn_withPresentedPaywall() async { + let paywallInfo: PaywallInfo = .stub() + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportReturn(paywallInfo: paywallInfo, reason: nil)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect(result.parameters.audienceFilterParams["$is_standard_event"] as! Bool) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_return") + #expect( + result.parameters.audienceFilterParams["$paywall_id"] as! String == paywallInfo.databaseId) + #expect( + result.parameters.audienceFilterParams["paywall_id"] as! String == paywallInfo.databaseId) + } + + @Test func teleportReturn_withoutPresentedPaywall() async { + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportReturn(paywallInfo: nil, reason: nil)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_return") + #expect(result.parameters.audienceFilterParams["$paywall_id"] == nil) + } } diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 1c4401500a..a25b6fc9cc 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -180,4 +180,145 @@ struct DeepLinkRouterTests { let url = URL(string: "myapp://home")! #expect(url.redeemableCode == nil) } + + // MARK: - Return Links + + @Test( + "isSuperwallReturnLink matches the scheme and universal link forms", + arguments: [ + "myapp://superwall/return", + "myapp://superwall/return?source=checkout", + "https://myapp.superwall.link/app-link/superwall/return", + ] + ) + func isSuperwallReturnLink_matches(link: String) { + #expect(URL(string: link)!.isSuperwallReturnLink == true) + } + + @Test( + "isSuperwallReturnLink ignores other links", + arguments: [ + "myapp://superwall/redeem?code=ABC123", + "myapp://superwall/checkout-return", + "myapp://superwall/return/extra", + "myapp://return", + "myapp://other/return", + "https://superwall/return", + "https://example.com/app-link/superwall/return", + "https://myapp.superwall.link/superwall/return", + "https://myapp.superwall.link/app-link/superwall/other", + // The backend only sends return links on superwall.link. + "https://myapp.superwall.app/app-link/superwall/return", + ] + ) + func isSuperwallReturnLink_ignores(link: String) { + #expect(URL(string: link)!.isSuperwallReturnLink == false) + } + + @Test("Returns true for a return link so a cold start still claims it") + func storeDeepLink_returnLink() { + #expect(DeepLinkRouter.storeDeepLink(URL(string: "myapp://superwall/return")!) == true) + #expect( + DeepLinkRouter.storeDeepLink( + URL(string: "https://myapp.superwall.link/app-link/superwall/return")! + ) == true + ) + } + + // MARK: - Return Link Tracking + + private final class TrackedEvents: @unchecked Sendable { + private let lock = NSLock() + private var events: [Trackable] = [] + + func append(_ event: Trackable) { + lock.lock() + defer { lock.unlock() } + events.append(event) + } + + var all: [Trackable] { + lock.lock() + defer { lock.unlock() } + return events + } + } + + private func routeAndCollect( + _ url: URL, + presentedPaywallInfo: PaywallInfo? + ) async -> (handled: Bool, events: [Trackable]) { + let dependencyContainer = DependencyContainer() + let router = dependencyContainer.deepLinkRouter! + let tracked = TrackedEvents() + router.trackEvent = { tracked.append($0) } + router.presentedPaywallInfo = { presentedPaywallInfo } + + let handled = router.route(url: url) + + // Wait for the first event, then a little longer so a stray second one would show. + for _ in 0..<50 where tracked.all.isEmpty { + try? await Task.sleep(nanoseconds: 20_000_000) + } + try? await Task.sleep(nanoseconds: 100_000_000) + return (handled, tracked.all) + } + + @Test("A return link tracks teleport_return with the presented paywall's info, not deepLink_open") + func route_returnLink_tracksTeleportReturn() async { + let paywallInfo: PaywallInfo = .stub() + let result = await routeAndCollect( + URL(string: "myapp://superwall/return")!, + presentedPaywallInfo: paywallInfo + ) + + #expect(result.handled == true) + #expect(result.events.count == 1) + let event = result.events.first as? InternalSuperwallEvent.TeleportReturn + #expect(event?.superwallEvent.description == "teleport_return") + #expect(event?.paywallInfo?.databaseId == paywallInfo.databaseId) + #expect(!result.events.contains(where: { $0 is InternalSuperwallEvent.DeepLink })) + } + + @Test("A return link carries the checkout page's reason on teleport_return") + func route_returnLink_tracksReason() async { + let purchased = await routeAndCollect( + URL(string: "myapp://superwall/return?reason=purchased")!, + presentedPaywallInfo: nil + ) + let closed = await routeAndCollect( + URL(string: "https://myapp.superwall.link/app-link/superwall/return?reason=closed")!, + presentedPaywallInfo: nil + ) + + #expect((purchased.events.first as? InternalSuperwallEvent.TeleportReturn)?.reason == "purchased") + #expect((closed.events.first as? InternalSuperwallEvent.TeleportReturn)?.reason == "closed") + #expect(purchased.handled == true) + #expect(closed.handled == true) + } + + @Test("A return link with no paywall presented tracks teleport_return without paywall info") + func route_returnLink_noPresentedPaywall() async { + let result = await routeAndCollect( + URL(string: "https://myapp.superwall.link/app-link/superwall/return")!, + presentedPaywallInfo: nil + ) + + #expect(result.handled == true) + #expect(result.events.count == 1) + let event = result.events.first as? InternalSuperwallEvent.TeleportReturn + #expect(event != nil) + #expect(event?.paywallInfo == nil) + } + + @Test("Any other deep link still tracks deepLink_open and not teleport_return") + func route_otherLink_tracksDeepLinkOnly() async { + let result = await routeAndCollect( + URL(string: "myapp://home")!, + presentedPaywallInfo: .stub() + ) + + #expect(result.events.contains(where: { $0 is InternalSuperwallEvent.DeepLink })) + #expect(!result.events.contains(where: { $0 is InternalSuperwallEvent.TeleportReturn })) + } } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift new file mode 100644 index 0000000000..1e8fba8757 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift @@ -0,0 +1,299 @@ +// +// TeleportReturnCoverTests.swift +// SuperwallKitTests +// + +import Testing +import UIKit +import WebKit +@testable import SuperwallKit + +/// Drives the cover through the app lifecycle the way iOS does, with notifications, and with the +/// checkout never settling, so every cover is the one put up just before suspension. +@MainActor +struct TeleportReturnCoverTests { + @MainActor + private final class Fixture { + let window: UIWindow + let view: UIView + let webView: WKWebView + let cover: TeleportReturnCover + /// This fixture's own center, so no other test's cover hears its notifications. + let notificationCenter = NotificationCenter() + + init(timing: TeleportReturnCover.Timing, isSettled: Bool = false) { + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view = UIView(frame: window.bounds) + webView = WKWebView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + var state: UIApplication.State = .active + var environment = TeleportReturnCover.Environment() + environment.applicationState = { state } + environment.isSettled = { _ in isSettled } + environment.makeStill = { _ in UIView() } + environment.beginBackgroundTask = { _ in UIBackgroundTaskIdentifier(rawValue: 1) } + environment.endBackgroundTask = { _ in } + environment.backgroundTimeRemaining = { 30 } + environment.notificationCenter = notificationCenter + cover = TeleportReturnCover( + view: view, + webView: webView, + timing: timing, + environment: environment + ) + setState = { state = $0 } + } + + private var setState: (UIApplication.State) -> Void = { _ in } + + func set(_ newState: UIApplication.State) { + setState(newState) + } + } + + private static let watch = TeleportReturnCover.Watch( + teleportId: "tp_1", + teleportStatusUrl: URL(string: "https://example.com/teleport")!, + checkoutStatusUrl: URL(string: "https://example.com/checkout")!, + publicApiKey: "pk_test" + ) + + private static var fastTiming: TeleportReturnCover.Timing { + var timing = TeleportReturnCover.Timing() + timing.drawWait = 50_000_000 + timing.stillTimeout = 50_000_000 + timing.backgroundCheckLimit = 0 + timing.suspendLead = 0 + timing.snapshotLinger = 10_000_000 + timing.revealGrace = 100_000_000 + timing.revealDuration = 0.4 + return timing + } + + private func post( + _ name: Notification.Name, + to fixture: Fixture, + userInfo: [AnyHashable: Any]? = nil + ) { + fixture.notificationCenter.post(name: name, object: nil, userInfo: userInfo) + } + + private func sleep(_ seconds: Double) async { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + /// Leaves for the browser and comes back to a suspended app: the cover put up before + /// suspension is over the waiting screen. + private func makeSuspendedCover() async -> Fixture { + let fixture = Fixture(timing: Self.fastTiming) + fixture.cover.startWatching(Self.watch) + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.set(.background) + post(UIApplication.didEnterBackgroundNotification, to: fixture) + await sleep(0.1) + #expect(fixture.cover.isCovering) + return fixture + } + + @Test("An activation interrupted during the grace period does not stop the next one revealing") + func testRevealAfterInterruptedGrace() async { + let fixture = await makeSuspendedCover() + + // The app becomes active and then inactive again before the grace period ends. + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification, to: fixture) + fixture.set(.inactive) + await sleep(0.2) + #expect(fixture.cover.isCovering) + + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification, to: fixture) + await sleep(0.2 + Self.fastTiming.revealDuration + 0.2) + + #expect(!fixture.cover.isCovering) + } + + @Test("A return link during the fade keeps the cover whole until the page removes the screen") + func testReturnLinkDuringFade() async { + let fixture = await makeSuspendedCover() + + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification, to: fixture) + await sleep(0.2) + #expect(fixture.cover.isCovering) + + post(.superwallReturnLinkOpened, to: fixture, userInfo: ["reason": "purchased"]) + #expect(fixture.cover.isCovering) + #expect(fixture.cover.coverAlpha == 1) + + await sleep(0.15) + #expect(!fixture.cover.isCovering) + } + + @Test( + "The page functions are called by their teleport names, or their checkout names on older pages", + arguments: [ + ("showTeleportWaitingScreen", "handleTeleportReturn"), + ("showCheckoutWaitingScreen", "hideCheckoutWaitingScreen"), + ] + ) + func pageFunctions(show: String, handleReturn: String) async throws { + let webView = try await loadedWebView() + _ = try await webView.callAsyncJavaScript( + """ + window.calls = []; + window.app = { + [show]: async () => { window.calls.push("show"); return true }, + [handleReturn]: async (options) => { window.calls.push("return:" + options.reason); return true }, + }; + """, + arguments: ["show": show, "handleReturn": handleReturn], + contentWorld: .page + ) + + let shown = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + let returned = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": "purchased"], + contentWorld: .page + ) + let calls = try await webView.callAsyncJavaScript( + "return window.calls", + contentWorld: .page + ) as? [String] + + #expect(shown as? Bool == true) + #expect(returned as? Bool == true) + #expect(calls == ["show", "return:purchased"]) + } + + @Test("A page with both names only has the teleport-named function called") + func pageWithBothNames() async throws { + let webView = try await loadedWebView() + _ = try await webView.callAsyncJavaScript( + """ + window.calls = []; + window.app = { + showTeleportWaitingScreen: () => { window.calls.push("show") }, + showCheckoutWaitingScreen: () => { window.calls.push("showCheckout") }, + handleTeleportReturn: () => { window.calls.push("return") }, + hideCheckoutWaitingScreen: () => { window.calls.push("hideCheckout") }, + }; + """, + contentWorld: .page + ) + + _ = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + _ = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": ""], + contentWorld: .page + ) + let calls = try await webView.callAsyncJavaScript( + "return window.calls", + contentWorld: .page + ) as? [String] + + #expect(calls == ["show", "return"]) + } + + @Test("A still from an earlier leave timing out does not take down a later leave's still") + func testEarlierStillTimeoutKeepsLaterStill() async { + var timing = Self.fastTiming + timing.stillTimeout = 200_000_000 + let fixture = Fixture(timing: timing) + + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.cover.leave(drawingWaitingScreen: true) {} + // Past the first leave's timeout, before the second's. + await sleep(0.18) + #expect(fixture.view.subviews.count == 1) + + await sleep(0.2) + #expect(fixture.view.subviews.isEmpty) + } + + @Test("Back after settling, the page hears the reason of a return link that arrives late") + func testSettledCoverWaitsForLateReturnLink() async throws { + var timing = Self.fastTiming + timing.backgroundCheckLimit = 1 + let fixture = Fixture(timing: timing, isSettled: true) + fixture.webView.loadHTMLString("", baseURL: nil) + while try await fixture.webView.evaluateJavaScript( + "document.getElementById('loaded') !== null" + ) as? Bool != true { + try await Task.sleep(nanoseconds: 10_000_000) + } + _ = try await fixture.webView.callAsyncJavaScript( + """ + window.reasons = []; + window.app = { + handleTeleportReturn: async (options) => { window.reasons.push(options.reason); return true }, + }; + """, + contentWorld: .page + ) + + fixture.cover.startWatching(Self.watch) + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.set(.background) + post(UIApplication.didEnterBackgroundNotification, to: fixture) + await sleep(0.1) + #expect(fixture.cover.isCovering) + + // The app is active before the link is handed over. + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification, to: fixture) + await sleep(0.02) + post(.superwallReturnLinkOpened, to: fixture, userInfo: ["reason": "purchased"]) + await sleep(0.3) + + let reasons = try await fixture.webView.callAsyncJavaScript( + "return window.reasons", + contentWorld: .page + ) as? [String] + #expect(reasons == ["purchased"]) + #expect(!fixture.cover.isCovering) + } + + @Test("A page without the functions is answered with false") + func pageWithoutFunctions() async throws { + let webView = try await loadedWebView() + + let shown = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + let returned = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": ""], + contentWorld: .page + ) + + #expect(shown as? Bool == false) + #expect(returned as? Bool == false) + } + + /// A web view whose page has loaded. The blank page before it already reads as complete, so the + /// wait is for an element only the loaded page has. + private func loadedWebView() async throws -> WKWebView { + let webView = WKWebView() + webView.loadHTMLString("", baseURL: nil) + while try await webView.evaluateJavaScript( + "document.getElementById('loaded') !== null" + ) as? Bool != true { + try await Task.sleep(nanoseconds: 10_000_000) + } + return webView + } +} 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 d05d237f89..502f298fc6 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 @@ -40,11 +40,15 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didPresentSafariInApp = false var didOpenDeepLink = false var deepLinkShouldDismiss: Bool? + var teleportWatch: TeleportReturnCover.Watch? + var didDrawWaitingScreen = false + var didOpenAsTeleport = false var didPresentSafariExternal = false var didRequestReview = false var didOpenPaymentSheet = false var stripeCheckoutSubmit: (checkoutContextId: String, productId: String)? var stripeCheckoutComplete: (checkoutContextId: String, productId: String)? + var stripeCheckoutCompleteShouldDismiss: Bool? var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? var request: PresentationRequest? @@ -80,8 +84,18 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didPresentSafariInApp = true } - func presentSafariExternal(_ url: URL) { + func startTeleportWatch(_ watch: TeleportReturnCover.Watch) { + teleportWatch = watch + } + + func endTeleportWatch() { + teleportWatch = nil + } + + func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool, isTeleport: Bool) { didPresentSafariExternal = true + didDrawWaitingScreen = drawsWaitingScreen + didOpenAsTeleport = isTeleport } func requestReview(type: ReviewType) { @@ -98,9 +112,11 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { func handleStripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) { stripeCheckoutComplete = (checkoutContextId, productId) + stripeCheckoutCompleteShouldDismiss = shouldDismiss } func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { 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 4c4a7c1a60..66717b587a 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 @@ -77,6 +77,29 @@ struct PaywallMessageHandlerTests { return (messageHandler, webView, delegate) } + // The paywall attributes purchases it starts (web checkouts, teleports) to how it was presented. + // Covers both the page being ready already and `paywall_open` waiting for it. + @Test(arguments: [true, false]) + func paywallOpen_sendsThePresentation(pageReadyFirst: Bool) async { + let (messageHandler, webView, delegate) = makeHandler() + + if pageReadyFirst { + messageHandler.handle(.onReady(paywallJsVersion: "2")) + await waitForSetup(of: webView) + messageHandler.handle(.paywallOpen) + } else { + messageHandler.handle(.paywallOpen) + messageHandler.handle(.onReady(paywallJsVersion: "2")) + } + + let open = await waitForEvent(named: "paywall_open", in: webView) + #expect(open?["presented_by"] as? String == "programmatically") + #expect(open?["presentation_source_type"] as? String == "register") + #expect(open?["presentation_id"] == nil) + #expect(open?["presented_by_event_id"] == nil) + withExtendedLifetime(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 @@ -280,6 +303,27 @@ struct PaywallMessageHandlerTests { #expect(await paywallOpenCount(in: webView, reaching: 2, keeping: delegate) == 1) } + // A page reloaded after iOS killed its web process never ends the old page's checkout watch. + @Test + func aNewDocumentEndsTheOldDocumentsTeleportWatch() { + let (messageHandler, _, delegate) = makeHandler() + messageHandler.handle( + .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: "v1:teleport", + teleportStatusUrl: URL(string: "https://subs.example.com/teleport/status")!, + checkoutStatusUrl: URL(string: "https://subs.example.com/checkout/status")!, + publicApiKey: "pk_test" + ) + ) + ) + #expect(delegate.teleportWatch != nil) + + messageHandler.documentWillLoad() + + #expect(delegate.teleportWatch == nil) + } + @Test func paywallOpenIsSentOnceWhenTheDocumentDiesBeforeItIsReady() async { let (messageHandler, webView, delegate) = makeHandler() @@ -420,6 +464,78 @@ struct PaywallMessageHandlerTests { #expect(delegate.didPresentSafariExternal == true) } + @Test + func openUrlInSafari_forcesSafariWhenAsked() { + 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 + let url = URL(string: "https://example.com/sw-teleport/abc")! + messageHandler.handle(.openUrlInSafari(url, drawsWaitingScreen: true)) + + #expect(delegate.didPresentSafariExternal == true) + #expect(delegate.didDrawWaitingScreen == true) + } + + @Test(arguments: [true, false]) + func openUrlInSafari_tracksTeleportOpenOnlyForTeleports(isTeleport: Bool) async { + 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 + var tracked: [Trackable] = [] + messageHandler.trackEvent = { tracked.append($0) } + + let url = URL(string: "https://example.com/sw-teleport/abc?token=secret#ref=primary")! + messageHandler.handle(.openUrlInSafari(url, isTeleport: isTeleport)) + + for _ in 0..<15 where tracked.isEmpty { + try? await Task.sleep(nanoseconds: 20_000_000) + } + + #expect(delegate.didPresentSafariExternal == true) + #expect(delegate.didOpenAsTeleport == isTeleport) + guard isTeleport else { + #expect(tracked.isEmpty) + return + } + #expect(tracked.count == 1) + let event = tracked.first as? InternalSuperwallEvent.TeleportOpen + #expect(event?.superwallEvent.description == "teleport_open") + #expect(event?.paywallInfo.databaseId == delegate.info.databaseId) + let params = await event?.getSuperwallParameters() + #expect(params?["url"] == nil) + #expect(params?["paywall_id"] as? String == delegate.info.databaseId) + } + @Test func openDeepLink_regularDeepLink_shouldDismiss() { let dependencyContainer = DependencyContainer() @@ -755,6 +871,135 @@ struct PaywallMessageHandlerTests { )) } + // MARK: - Open URL External Decoding Tests + + @Test(arguments: [ + (nil, false), + (false, false), + (true, true), + ] as [(Bool?, Bool)]) + func decodeOpenUrlExternal_waitingScreen(waitingScreen: Bool?, draws: Bool) throws { + let field = waitingScreen.map { ", \"waiting_screen\": \($0)" } ?? "" + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "open_url_external", + "url": "https://example.com/sw-teleport/abc"\(field) + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .openUrlInSafari( + URL(string: "https://example.com/sw-teleport/abc")!, + drawsWaitingScreen: draws + )) + } + + @Test(arguments: [ + (nil, false), + (false, false), + (true, true), + ] as [(Bool?, Bool)]) + func decodeOpenUrlExternal_teleport(teleport: Bool?, isTeleport: Bool) throws { + let field = teleport.map { ", \"teleport\": \($0)" } ?? "" + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "open_url_external", + "url": "https://example.com/sw-teleport/abc", + "waiting_screen": true\(field) + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .openUrlInSafari( + URL(string: "https://example.com/sw-teleport/abc")!, + drawsWaitingScreen: true, + isTeleport: isTeleport + )) + } + + @Test + func decodeTeleportWatch() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "teleport_watch_start", + "teleport_id": "v1:teleport", + "teleport_status_url": "https://subs.example.com/teleport/status", + "checkout_status_url": "https://subs.example.com/checkout/status", + "public_api_key": "pk_test" + }, + { "eventName": "teleport_watch_end" } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages == [ + .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: "v1:teleport", + teleportStatusUrl: URL(string: "https://subs.example.com/teleport/status")!, + checkoutStatusUrl: URL(string: "https://subs.example.com/checkout/status")!, + publicApiKey: "pk_test" + ) + ), + .teleportWatchEnd, + ]) + } + + @Test + func decodeTeleportWatch_withoutACheckout() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "teleport_watch_start", + "teleport_id": "v1:teleport", + "teleport_status_url": "https://subs.example.com/teleport/status", + "public_api_key": "pk_test" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages == [ + .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: "v1:teleport", + teleportStatusUrl: URL(string: "https://subs.example.com/teleport/status")!, + checkoutStatusUrl: nil, + publicApiKey: "pk_test" + ) + ), + ]) + } + // MARK: - Haptic Feedback Message Decoding Tests @Test @@ -881,7 +1126,36 @@ struct PaywallMessageHandlerTests { #expect(message == .stripeCheckoutComplete( checkoutContextId: "ctx_123", - productId: "prod_123" + productId: "prod_123", + shouldDismiss: nil + )) + } + + @Test(arguments: [true, false]) + func decodeStripeCheckoutComplete_withShouldDismiss(shouldDismiss: Bool) throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_complete", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123", + "should_dismiss": \(shouldDismiss) + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .stripeCheckoutComplete( + checkoutContextId: "ctx_123", + productId: "prod_123", + shouldDismiss: shouldDismiss )) } @@ -981,12 +1255,14 @@ struct PaywallMessageHandlerTests { messageHandler.handle( .stripeCheckoutComplete( checkoutContextId: "ctx_123", - productId: "prod_123" + productId: "prod_123", + shouldDismiss: false ) ) #expect(delegate.stripeCheckoutComplete?.checkoutContextId == "ctx_123") #expect(delegate.stripeCheckoutComplete?.productId == "prod_123") + #expect(delegate.stripeCheckoutCompleteShouldDismiss == false) } @Test diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift new file mode 100644 index 0000000000..5f547156ea --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift @@ -0,0 +1,32 @@ +// +// SubscriptionsApiEnvironmentScriptTests.swift +// SuperwallKitTests +// + +import Testing +@testable import SuperwallKit + +struct SubscriptionsApiEnvironmentScriptTests { + @Test("Points a developer SDK at the staging subscriptions-api") + func developerIsStaging() { + #expect( + SubscriptionsApiEnvironmentScript.source(for: .developer) + == #"window.__SW_SUBSCRIPTIONS_API_ENV__ = "staging";"# + ) + } + + @Test("Points a local SDK at the local subscriptions-api") + func localIsLocal() { + #expect( + SubscriptionsApiEnvironmentScript.source(for: .local) + == #"window.__SW_SUBSCRIPTIONS_API_ENV__ = "local";"# + ) + } + + @Test("Leaves paywall.js on its production default otherwise") + func productionAndCustomInjectNothing() { + #expect(SubscriptionsApiEnvironmentScript.source(for: .release) == nil) + #expect(SubscriptionsApiEnvironmentScript.source(for: .releaseCandidate) == nil) + #expect(SubscriptionsApiEnvironmentScript.source(for: .custom("https://example.com")) == nil) + } +} diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 75fe3fa274..1c11555e28 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -2166,6 +2166,68 @@ struct WebEntitlementRedeemerTests { } } + @Test("A purchase the page reports twice is redeemed once") + func testStripeCheckoutComplete_reportedTwice_redeemsOnce() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "redemption_123", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockDelegate.willRedeemCallCount == 1) + } + @Test("Legacy redeem keeps callback compatibility: willRedeemLink fires before /redeem request") func testLegacyRedeem_callbacksCompatibility() async { @@ -2267,6 +2329,89 @@ struct WebEntitlementRedeemerTests { #expect(state?.remainingForegroundAttempts == 5) } + @Test("An older checkout keeps polling when a newer one is only started") + func testOlderPoll_keepsPollingWhenNewerCheckoutOnlyStarts() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + stripePendingPollIntervalNs: 20_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, + superwall: superwall + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + mockNetwork.pollRedemptionResultResponses = Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 15 + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_old", productId: "prod_old") + let olderPoll = Task { + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + } + try? await Task.sleep(nanoseconds: 60_000_000) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_new", productId: "prod_new") + let callsWhenNewerStarted = mockNetwork.pollRedemptionResultCallCount + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(mockNetwork.pollRedemptionResultCallCount > callsWhenNewerStarted) + #expect(mockNetwork.pollRedemptionResultRequest?.checkoutContextId == "ctx_old") + await olderPoll.value + } + + @Test("An older checkout's poll finishing leaves a newer checkout's pending state") + func testOlderPoll_finishingKeepsNewerPendingState() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + stripePendingPollIntervalNs: 20_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, + superwall: superwall + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultResponses = + Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 5 + ) + [.success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed))] + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_old", productId: "prod_old") + let olderPoll = Task { + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + } + try? await Task.sleep(nanoseconds: 40_000_000) + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_new", productId: "prod_new") + await olderPoll.value + + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_new") + } + @Test("Foreground polling consumes attempts and clears pending after 5 tries") func testStripeForegroundPolling_consumesAttempts() async { @@ -2913,4 +3058,478 @@ struct WebEntitlementRedeemerTests { } #expect(mockNetwork.pollRedemptionResultCallCount == 1) } + + // MARK: - Web purchases finished like App Store purchases + + private struct WebPurchaseHarness { + let redeemer: WebEntitlementRedeemer + let paywallVc: PaywallViewControllerMock + let finishes: PaywallFinishRecorder + let webView: FakeWebView + let delegate: MockSuperwallDelegate + let network: NetworkMock + } + + private func makeWebPurchaseHarness( + result: RedemptionResult? = nil, + automaticallyDismiss: Bool = true, + paywall: Paywall = .stub() + ) async -> WebPurchaseHarness { + let cache = dependencyContainer.paywallManager.cache + let messageHandler = await PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = await FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let paywallVc = await PaywallViewControllerMock( + paywall: paywall, + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: cache, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + cache.save(paywallVc, forKey: "web-purchase") + cache.activePaywallVcKey = "web-purchase" + let finishes = await PaywallFinishRecorder() + await MainActor.run { + messageHandler.delegate = paywallVc + paywallVc.delegate = PaywallViewControllerDelegateAdapter( + swiftDelegate: finishes, + objcDelegate: nil + ) + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + options.paywalls.automaticallyDismiss = automaticallyDismiss + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let redemption = result ?? RedemptionResult.success( + code: "web_purchase_code", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [redemption], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: StorageMock(internalRedeemResponse: nil), + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + return WebPurchaseHarness( + redeemer: redeemer, + paywallVc: paywallVc, + finishes: finishes, + webView: webView, + delegate: mockDelegate, + network: mockNetwork + ) + } + + /// The event names the paywall's webview was sent. + @MainActor + private func sentEventNames(_ webView: FakeWebView) -> [String] { + webView.evaluatedScripts.flatMap { script -> [String] in + guard let start = script.range(of: "accept64('"), + let end = script.range(of: "')", range: start.upperBound.. Bool { + for _ in 0..<40 { + if await sentEventNames(webView).contains(name) { + return true + } + try? await Task.sleep(nanoseconds: 25_000_000) + } + return false + } + + @Test("A web purchase that should dismiss tells the paywall transaction_complete and dismisses as purchased") + func testStripeCheckoutComplete_purchase_dismisses() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let results = await harness.finishes.waitForResults() + #expect(results.count == 1) + if case let .purchased(product) = results.first { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected a purchased result, got \(String(describing: results.first))") + } + #expect(await harness.paywallVc.completedPurchaseProduct?.productIdentifier == "stripe|price_1") + } + + @Test("A web purchase that should not dismiss keeps the paywall so its actions run") + func testStripeCheckoutComplete_purchase_keepsPaywall() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + #expect(await harness.finishes.waitForResults().isEmpty) + #expect(await harness.paywallVc.loadingState == .ready) + } + + @Test("A web purchase never dismisses when automatic dismissal is off") + func testStripeCheckoutComplete_purchase_respectsAutomaticallyDismiss() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(automaticallyDismiss: false) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + #expect(await harness.finishes.waitForResults().isEmpty) + } + + @Test("A web purchase tracks the same events and redeem callbacks as the legacy completion") + func testStripeCheckoutComplete_purchase_keepsRedemptionContract() async { + guard #available(iOS 14.0, *) else { + return + } + let legacy = await makeWebPurchaseHarness() + await legacy.redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "stripe|price_1") + let legacyEvents = Set(legacy.delegate.eventsReceived.map { $0.backingData.objcEvent }) + + let purchase = await makeWebPurchaseHarness() + await purchase.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + let purchaseEvents = Set(purchase.delegate.eventsReceived.map { $0.backingData.objcEvent }) + + #expect(purchase.delegate.willRedeemCallCount == 1) + #expect(purchase.delegate.receivedResult?.code == "web_purchase_code") + #expect(purchaseEvents.contains(SuperwallEventObjc.redemptionComplete)) + #expect(purchaseEvents == legacyEvents) + } + + @Test("A legacy web checkout completion does not send transaction_complete") + func testStripeCheckoutComplete_restore_unchanged() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(harness.network.pollRedemptionResultCallCount == 1) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.paywallVc.completedPurchaseProduct == nil) + } + + @Test("A failed redemption finishes nothing, even for a web purchase") + func testStripeCheckoutComplete_purchase_failedRedemption() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness( + result: .invalidCode(code: "web_purchase_code") + ) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.finishes.waitForResults().isEmpty) + } + + @Test("A close after a purchase finishes the paywall as purchased") + @MainActor + func testResolvedDismissal_afterPurchase() { + let paywallVc = PaywallViewController( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: SWWebView( + isMac: false, + messageHandler: PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ), + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ), + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + + let before = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(before.result == .declined) + #expect(before.closeReason == .manualClose) + + paywallVc.markPurchaseCompleted(StoreProduct.blank(productIdentifier: "stripe|price_1")) + + let after = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + if case let .purchased(product) = after.result { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected purchased, got \(after.result)") + } + #expect(after.closeReason == .systemLogic) + + let restored = paywallVc.resolvedDismissal(result: .restored, closeReason: .systemLogic) + #expect(restored.result == .restored) + + // A close to show the next paywall keeps its reason, so that paywall keeps the state. + let forNextPaywall = paywallVc.resolvedDismissal( + result: .declined, + closeReason: .forNextPaywall + ) + #expect(forNextPaywall.result == .declined) + #expect(forNextPaywall.closeReason == .forNextPaywall) + } + + @Test("A purchase does not outlive its presentation: a later close is a decline") + @MainActor + func testCompletedPurchase_clearedWhenNextPresentationBegins() { + let paywallVc = PaywallViewController( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: SWWebView( + isMac: false, + messageHandler: PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ), + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ), + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + + paywallVc.markPurchaseCompleted(StoreProduct.blank(productIdentifier: "stripe|price_1")) + + // A sheet swiped away disappears before its close is reported, so the purchase is still + // there for that close. + paywallVc.viewDidDisappear(false) + let swiped = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(swiped.result != .declined) + + paywallVc.viewWillAppear(false) + + let later = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(later.result == .declined) + #expect(later.closeReason == .manualClose) + #expect(paywallVc.completedPurchaseProduct == nil) + } + + /// A paywall offering two tiers with different entitlements. A web purchase of one tier + /// activates only that tier's entitlement. + private func makeTieredPaywall() -> Paywall { + var paywall = Paywall.stub() + paywall.productIds = ["stripe|price_1", "stripe|price_2"] + dependencyContainer.entitlementsInfo.entitlementsByProductId["stripe|price_1"] = [.stub()] + dependencyContainer.entitlementsInfo.entitlementsByProductId["stripe|price_2"] = [ + Entitlement(id: "other_tier") + ] + return paywall + } + + @Test("A web purchase of one tier is not judged as a restore of every tier") + func testStripeCheckoutComplete_purchase_skipsRestoreValidation() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(paywall: makeTieredPaywall()) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let webEvents = await sentEventNames(harness.webView) + #expect(!webEvents.contains("restore_fail")) + #expect(!webEvents.contains("restore_complete")) + let events = harness.delegate.eventsReceived.map { $0.backingData.objcEvent } + #expect(!events.contains(SuperwallEventObjc.restoreFail)) + #expect(!events.contains(SuperwallEventObjc.restoreComplete)) + #expect(await harness.paywallVc.loadingState == .ready) + } + + @Test("A legacy web checkout completion of one tier still reports the restore as failed") + func testStripeCheckoutComplete_restore_keepsRestoreValidation() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(paywall: makeTieredPaywall()) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1" + ) + + let events = harness.delegate.eventsReceived.map { $0.backingData.objcEvent } + #expect(events.contains(SuperwallEventObjc.restoreFail)) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + } + + @Test("A web purchase recovered on foreground after a failed poll still finishes as a purchase") + func testStripeForegroundRecovery_keepsPurchaseCompletion() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + harness.network.pollRedemptionResultResponses.insert( + .failure(NetworkError.notAuthenticated), + at: 0 + ) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + #expect(harness.network.pollRedemptionResultCallCount == 1) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.paywallVc.loadingState == .ready) + + await harness.redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(harness.network.pollRedemptionResultCallCount == 2) + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let results = await harness.finishes.waitForResults() + if case let .purchased(product) = results.first { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected a purchased result, got \(String(describing: results.first))") + } + } + + @Test("Pending checkout state keeps its completion, and state saved without one restores") + func testPendingStripeCheckoutPollState_completionRoundTrip() throws { + let purchase = PendingStripeCheckoutPollState( + checkoutContextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + let data = try JSONEncoder().encode(purchase) + let decoded = try JSONDecoder().decode(PendingStripeCheckoutPollState.self, from: data) + #expect(decoded == purchase) + #expect(decoded.consumingForegroundAttempt().completion == purchase.completion) + + let legacyJson = """ + {"checkoutContextId":"ctx_1","productId":"stripe|price_1","remainingForegroundAttempts":5,"updatedAt":0} + """ + let legacy = try JSONDecoder().decode( + PendingStripeCheckoutPollState.self, + from: Data(legacyJson.utf8) + ) + #expect(legacy.completion == .restore) + #expect(legacy.checkoutContextId == "ctx_1") + } +} + +/// Records how a paywall finished. +@MainActor +final class PaywallFinishRecorder: PaywallViewControllerDelegate { + private(set) var results: [PaywallResult] = [] + + func paywall( + _ paywall: PaywallViewController, + didFinishWith result: PaywallResult, + shouldDismiss: Bool + ) { + results.append(result) + } + + /// Gives an asynchronous dismissal time to land. + func waitForResults() async -> [PaywallResult] { + for _ in 0..<20 where results.isEmpty { + try? await Task.sleep(nanoseconds: 25_000_000) + } + return results + } }