Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,7 @@
887A61342C1D168B00E1A461 /* SnapshotTesting+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SnapshotTesting+Extensions.swift"; sourceTree = "<group>"; };
887A61352C1D168B00E1A461 /* TestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestCase.swift; sourceTree = "<group>"; };
887A61372C1D168B00E1A461 /* PurchaseHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchaseHandlerTests.swift; sourceTree = "<group>"; };
45010544C32C2A21A2F323D7 /* OnWebCheckoutOpenedModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnWebCheckoutOpenedModifierTests.swift; sourceTree = "<group>"; };
B10A136C03A9FF34FB805EFA /* ExitOfferPresenterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExitOfferPresenterTests.swift; sourceTree = "<group>"; };
887A620F2C1D168B00E1A461 /* OtherPaywallViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OtherPaywallViewTests.swift; sourceTree = "<group>"; };
887A62102C1D168B00E1A461 /* PaywallViewDynamicTypeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaywallViewDynamicTypeTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5873,6 +5874,7 @@
57F4B2C12F8E10A600BB46C9 /* ExitOfferHelperTests.swift */,
16E146B42E99FF640089B609 /* MockStoreTransaction.swift */,
887A61372C1D168B00E1A461 /* PurchaseHandlerTests.swift */,
45010544C32C2A21A2F323D7 /* OnWebCheckoutOpenedModifierTests.swift */,
B10A136C03A9FF34FB805EFA /* ExitOfferPresenterTests.swift */,
7AA86A1D9F5AE92988B20230 /* WorkflowContextTests.swift */,
ECEA10EF63BC10F9BE6DB9F5 /* WorkflowPreviewTests.swift */,
Expand Down
2 changes: 2 additions & 0 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@ struct LoadedOfferingPaywallView: View {
value: self.purchaseHandler.purchaseError as NSError?)
.preference(key: RestoreErrorPreferenceKey.self,
value: self.purchaseHandler.restoreError as NSError?)
.preference(key: WebCheckoutOpenedPreferenceKey.self,
value: self.purchaseHandler.webCheckoutOpened)
}

@ViewBuilder
Expand Down
36 changes: 36 additions & 0 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ final class PurchaseHandler: ObservableObject {
@Published
fileprivate(set) var consecutiveCancellationRequestID: UUID?

/// Set to a new UUID each time the user taps a web checkout CTA.
/// Used to propagate the ``WebCheckoutOpenedPreferenceKey`` so callers can distinguish
/// "web checkout opened" from a regular cancellation.
@Published
fileprivate(set) var webCheckoutOpened: UUID?

/// Whether a purchase was successfully completed in the current session.
/// Convenience property for checking if we should skip exit offers.
var hasPurchasedInSession: Bool {
Expand Down Expand Up @@ -258,6 +264,7 @@ final class PurchaseHandler: ObservableObject {
self.consecutiveCancellationRequestID = nil
self.purchaseResult = nil
self.restoredCustomerInfo = nil
self.webCheckoutOpened = nil
self.activePaywallSessionID = nil
}

Expand Down Expand Up @@ -856,6 +863,14 @@ extension PurchaseHandler {
self.restoredCustomerInfo = .init(customerInfo: customerInfo, success: success)
}

/// Signals that the user tapped a web checkout CTA and the paywall is about to be dismissed
/// to open the browser. Sets a new UUID on ``webCheckoutOpened`` so the
/// ``WebCheckoutOpenedPreferenceKey`` fires.
@MainActor
func signalWebCheckoutOpened() {
self.webCheckoutOpened = UUID()
}

func trackPaywallImpression(_ eventData: PaywallEvent.Data) {
self.activePaywallSessionID = eventData.sessionIdentifier
self.paywallEventTracker.trackPaywallImpression(eventData)
Expand All @@ -868,6 +883,14 @@ extension PurchaseHandler {
self.activePaywallSessionID = nil
}

/// Clears a pending web checkout signal without a full session reset. Used when an exit offer is
/// about to be presented reusing this same `PurchaseHandler`, so the exit offer's paywall doesn't
/// spuriously observe a stale, already-handled signal from the paywall it replaced.
@MainActor
func clearWebCheckoutOpened() {
self.webCheckoutOpened = nil
}
Comment thread
cursor[bot] marked this conversation as resolved.

func componentInteractionLogger(sessionID: PaywallEvent.SessionID) -> ComponentInteractionLogger {
return self.paywallEventTracker.componentInteractionLogger(sessionID: sessionID)
}
Expand Down Expand Up @@ -1191,6 +1214,19 @@ struct RestoreErrorPreferenceKey: PreferenceKey {

}

/// Preference key emitted whenever the user taps a web checkout CTA.
/// Each new UUID represents a distinct tap, allowing consecutive taps to both fire the callback.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct WebCheckoutOpenedPreferenceKey: PreferenceKey {

static var defaultValue: UUID?

static func reduce(value: inout UUID?, nextValue: () -> UUID?) {
value = nextValue()
}

}

// MARK: Environment keys

/// `EnvironmentKey` for storing closure triggered when paywall should be dismissed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,19 +276,14 @@ struct ButtonComponentView: View {

private func openWebPaywallLink(url: URL, method: PaywallComponent.ButtonComponent.URLMethod) {
self.purchaseHandler.invalidateCustomerInfoCache()
#if os(watchOS)
// watchOS doesn't support openURL with a completion handler, so we're just opening the URL.
openURL(url)
#else
openURL(url) { success in
if success {
Logger.debug(Strings.successfully_opened_url_external_browser(url.absoluteString))
} else {
Logger.error(Strings.failed_to_open_url_external_browser(url.absoluteString))
}
Browser.navigateTo(url: url,
method: method,
openURL: self.openURL,
inAppBrowserURL: self.$inAppBrowserURL) { opened in
guard opened else { return }
self.purchaseHandler.signalWebCheckoutOpened()
onDismiss()
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
#endif
onDismiss()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,14 @@ struct PurchaseButtonComponentView: View {
Browser.navigateTo(url: url,
method: method,
openURL: self.openURL,
inAppBrowserURL: self.$inAppBrowserURL)
inAppBrowserURL: self.$inAppBrowserURL) { opened in
guard opened else { return }

if launchWebCheckout.autoDismiss {
self.onDismiss()
self.purchaseHandler.signalWebCheckoutOpened()

if launchWebCheckout.autoDismiss {
self.onDismiss()
}
}
}

Expand Down
13 changes: 11 additions & 2 deletions RevenueCatUI/Templates/V2/ViewHelpers/NavigatetoURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ import SwiftUI
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
enum Browser {

/// - Parameter completion: called with whether the URL was actually opened (or the in-app browser
/// sheet presented). Not called for `.unknown`, since nothing is opened in that case.
static func navigateTo(
url: URL,
method: PaywallComponent.ButtonComponent.URLMethod,
openURL: OpenURLAction,
inAppBrowserURL: Binding<URL?>
inAppBrowserURL: Binding<URL?>,
completion: ((Bool) -> Void)? = nil
) {
switch method {
case .inAppBrowser:
Expand All @@ -34,38 +37,44 @@ enum Browser {
} else {
Logger.error(Strings.failed_to_open_url_external_browser(url.absoluteString))
}
completion?(success)
}
#else
inAppBrowserURL.wrappedValue = url
completion?(true)
#endif
case .externalBrowser:
#if os(watchOS)
// watchOS doesn't support openURL with a completion handler, so we're just opening the URL.
openURL(url)
completion?(true)
#else
openURL(url) { success in
if success {
Logger.debug(Strings.successfully_opened_url_external_browser(url.absoluteString))
} else {
Logger.error(Strings.failed_to_open_url_external_browser(url.absoluteString))
}
completion?(success)
}
#endif
case .deepLink:
#if os(watchOS)
// watchOS doesn't support openURL with a completion handler, so we're just opening the URL.
openURL(url)
completion?(true)
#else
openURL(url) { success in
if success {
Logger.debug(Strings.successfully_opened_url_deep_link(url.absoluteString))
} else {
Logger.error(Strings.failed_to_open_url_deep_link(url.absoluteString))
}
completion?(success)
}
#endif
case .unknown:
break
completion?(false)
}
}

Expand Down
12 changes: 12 additions & 0 deletions RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,12 @@ public protocol PaywallViewControllerDelegate: AnyObject {
@objc(paywallViewControllerDidCancelPurchase:)
optional func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController)

/// Notifies that the user tapped a web checkout CTA and has left the app to complete payment
/// externally. This is distinct from ``paywallViewControllerDidCancelPurchase(_:)``: the
/// user has not cancelled; they have initiated a purchase flow outside of the app.
@objc(paywallViewControllerDidOpenWebCheckout:)
optional func paywallViewControllerDidOpenWebCheckout(_ controller: PaywallViewController)

/// Notifies that the purchase operation has failed in a ``PaywallViewController``.
@objc(paywallViewController:didFailPurchasingWithError:)
optional func paywallViewController(_ controller: PaywallViewController,
Expand Down Expand Up @@ -825,6 +831,10 @@ private extension PaywallViewController {
guard let self else { return }
self.delegate?.paywallViewControllerDidCancelPurchase?(self)
},
webCheckoutOpened: { [weak self] in
guard let self else { return }
self.delegate?.paywallViewControllerDidOpenWebCheckout?(self)
},
restoreCompleted: { [weak self] customerInfo in
guard let self else { return }
self.delegate?.paywallViewController?(self, didFinishRestoringWith: customerInfo)
Expand Down Expand Up @@ -956,6 +966,7 @@ private struct PaywallContainerView: View {
let purchaseStarted: PurchaseOfPackageStartedHandler
let purchaseCompleted: PurchaseCompletedHandler
let purchaseCancelled: PurchaseCancelledHandler
let webCheckoutOpened: WebCheckoutOpenedHandler
let restoreCompleted: PurchaseOrRestoreCompletedHandler
let purchaseFailure: PurchaseFailureHandler
let restoreStarted: RestoreStartedHandler
Expand All @@ -976,6 +987,7 @@ private struct PaywallContainerView: View {
.onPurchaseStarted(self.purchaseStarted)
.onPurchaseCompleted(self.purchaseCompleted)
.onPurchaseCancelled(self.purchaseCancelled)
.onWebCheckoutOpened(self.webCheckoutOpened)
.onPurchaseFailure(self.purchaseFailure)
.onRestoreStarted(self.restoreStarted)
.onRestoreCompleted(self.restoreCompleted)
Expand Down
Loading