Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
41 changes: 41 additions & 0 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ final class PurchaseHandler: ObservableObject {
@Published
fileprivate(set) var consecutiveCancellationRequestID: UUID?

/// Set to a new UUID each time the user taps a web checkout CTA, propagated via
/// ``WebCheckoutOpenedPreferenceKey``.
@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 +263,7 @@ final class PurchaseHandler: ObservableObject {
self.consecutiveCancellationRequestID = nil
self.purchaseResult = nil
self.restoredCustomerInfo = nil
self.deferredClearWebCheckoutOpened()
self.activePaywallSessionID = nil
}

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

/// Sets a new UUID on ``webCheckoutOpened`` so ``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 +880,24 @@ extension PurchaseHandler {
self.activePaywallSessionID = nil
}

/// Clears a pending web checkout signal without a full session reset, for when an exit offer is
/// about to reuse this same `PurchaseHandler`. Must run synchronously, unlike
/// `deferredClearWebCheckoutOpened`: the exit offer's paywall mounts in this same step, and a
/// deferred clear would let its brand new `onWebCheckoutOpened` observer see the stale signal as
/// its own fresh one.
@MainActor
func clearWebCheckoutOpened() {
self.webCheckoutOpened = nil
}

/// Deferred by a tick so a signal set earlier in the same synchronous step (e.g. right before a
/// dismiss) still reaches its SwiftUI render pass before being cleared.
private func deferredClearWebCheckoutOpened() {
DispatchQueue.main.async { [weak self] in
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 +1221,17 @@ struct RestoreErrorPreferenceKey: PreferenceKey {

}

@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
2 changes: 2 additions & 0 deletions RevenueCatUI/Templates/V2/PaywallsV2View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ struct PaywallsV2View: 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)
.disabled(self.purchaseHandler.actionInProgress)
.onDisappear {
// Standalone closes on disappear. A workflow page closes here only if it is still the
Expand Down
12 changes: 10 additions & 2 deletions RevenueCatUI/Templates/V2/ViewHelpers/NavigatetoURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import SwiftUI
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
enum Browser {

/// - Parameter completion: whether the URL actually opened. Not called for `.unknown`.
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 +36,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
10 changes: 10 additions & 0 deletions RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,10 @@ public protocol PaywallViewControllerDelegate: AnyObject {
@objc(paywallViewControllerDidCancelPurchase:)
optional func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController)

/// Notifies that the user tapped a web checkout CTA and left the app to complete payment externally.
@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 +829,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 +964,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 +985,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