diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 1cf0473036..85303993f9 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -27,6 +27,8 @@ enum Strings { case not_displaying_paywall case dismissing_paywall + case attempted_to_track_event_with_missing_data + } extension Strings: CustomStringConvertible { @@ -55,6 +57,9 @@ extension Strings: CustomStringConvertible { case .dismissing_paywall: return "Dismissing PaywallView" + + case .attempted_to_track_event_with_missing_data: + return "Attempted to track event with missing data" } } diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 9e9adcfda3..bede710de5 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -186,7 +186,8 @@ internal enum TestData { colors: .init(light: Self.lightColors, dark: Self.darkColors) ), localization: Self.localization1, - assetBaseURL: Self.paywallAssetBaseURL + assetBaseURL: Self.paywallAssetBaseURL, + revision: 5 ) static let offeringWithIntroOffer = Offering( @@ -560,19 +561,19 @@ extension PurchaseHandler { ) } restorePurchases: { return TestData.customerInfo + } trackEvent: { event in + Logger.debug("Tracking event: \(event)") } } static func cancelling() -> Self { - return self.init { _ in - return ( - transaction: nil, - customerInfo: TestData.customerInfo, - userCancelled: true - ) - } restorePurchases: { - return TestData.customerInfo - } + return .mock() + .map { block in { + var result = try await block($0) + result.userCancelled = true + return result + } + } restore: { $0 } } /// Creates a copy of this `PurchaseHandler` with a delay. @@ -589,8 +590,11 @@ extension PurchaseHandler { } } } + } +// MARK: - + extension PaywallColor: ExpressibleByStringLiteral { /// Creates a `PaywallColor` with a string literal diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index 4f9cdbce97..514dd19d2d 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -187,6 +187,9 @@ struct LoadedOfferingPaywallView: View { private let mode: PaywallViewMode private let fonts: PaywallFontProvider + @State + private var session: (lastPaywall: DisplayedPaywall, id: PaywallEvent.SessionID) + @StateObject private var introEligibility: IntroEligibilityViewModel @ObservedObject @@ -195,6 +198,9 @@ struct LoadedOfferingPaywallView: View { @Environment(\.locale) private var locale + @Environment(\.colorScheme) + private var colorScheme + init( offering: Offering, activelySubscribedProductIdentifiers: Set, @@ -215,6 +221,13 @@ struct LoadedOfferingPaywallView: View { wrappedValue: .init(introEligibilityChecker: introEligibility) ) self._purchaseHandler = .init(initialValue: purchaseHandler) + + // Each `PaywallView` impression gets its own session. + // See also `updateSessionIfNeeded`. + self._session = .init(initialValue: ( + lastPaywall: .init(offering: offering, paywall: paywall), + id: .init() + )) } var body: some View { @@ -233,6 +246,8 @@ struct LoadedOfferingPaywallView: View { .preference(key: RestoredCustomerInfoPreferenceKey.self, value: self.purchaseHandler.restoredCustomerInfo) .disabled(self.purchaseHandler.actionInProgress) + .onAppear { self.purchaseHandler.trackPaywallView(self.eventData) } + .onDisappear { self.purchaseHandler.trackPaywallClose(self.eventData) } switch self.mode { case .fullScreen: @@ -245,6 +260,44 @@ struct LoadedOfferingPaywallView: View { } } + private var eventData: PaywallEvent.Data { + self.updateSessionIfNeeded() + + return .init( + offering: self.offering, + paywall: self.paywall, + sessionID: self.session.id, + displayMode: self.mode, + locale: .current, + darkMode: self.colorScheme == .dark + ) + } + + private func updateSessionIfNeeded() { + let newPaywall: DisplayedPaywall = .init(offering: self.offering, paywall: self.paywall) + guard self.session.lastPaywall != newPaywall else { return } + + self.session.lastPaywall = newPaywall + self.session.id = .init() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private extension LoadedOfferingPaywallView { + + struct DisplayedPaywall: Equatable { + var offeringIdentifier: String + var paywallTemplate: String + var revision: Int + + init(offering: Offering, paywall: PaywallData) { + self.offeringIdentifier = offering.identifier + self.paywallTemplate = paywall.templateName + self.revision = paywall.revision + } + } + } // MARK: - diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 340be76972..3021f12fd9 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -15,17 +15,19 @@ import RevenueCat import StoreKit import SwiftUI -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) final class PurchaseHandler: ObservableObject { typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData typealias RestoreBlock = @Sendable () async throws -> CustomerInfo + typealias TrackEventBlock = @Sendable (PaywallEvent) async -> Void /// `false` if this `PurchaseHandler` is not backend by a configured `Purchases`instance. let isConfigured: Bool private let purchaseBlock: PurchaseBlock private let restoreBlock: RestoreBlock + private let trackEventBlock: TrackEventBlock /// Whether a purchase or restore is currently in progress @Published @@ -47,22 +49,28 @@ final class PurchaseHandler: ObservableObject { @Published fileprivate(set) var restoredCustomerInfo: CustomerInfo? + private var eventData: PaywallEvent.Data? + convenience init(purchases: Purchases = .shared) { self.init(isConfigured: true) { package in return try await purchases.purchase(package: package) } restorePurchases: { return try await purchases.restorePurchases() + } trackEvent: { event in + await purchases.track(paywallEvent: event) } } init( isConfigured: Bool = true, purchase: @escaping PurchaseBlock, - restorePurchases: @escaping RestoreBlock + restorePurchases: @escaping RestoreBlock, + trackEvent: @escaping TrackEventBlock ) { self.isConfigured = isConfigured self.purchaseBlock = purchase self.restoreBlock = restorePurchases + self.trackEventBlock = trackEvent } static func `default`() -> Self { @@ -74,7 +82,7 @@ final class PurchaseHandler: ObservableObject { throw ErrorCode.configurationError } restorePurchases: { throw ErrorCode.configurationError - } + } trackEvent: { _ in } } } @@ -91,7 +99,9 @@ extension PurchaseHandler { let result = try await self.purchaseBlock(package) - if !result.userCancelled { + if result.userCancelled { + self.trackCancelledPurchase() + } else { withAnimation(Constants.defaultAnimation) { self.purchased = true self.purchasedCustomerInfo = result.customerInfo @@ -116,13 +126,60 @@ extension PurchaseHandler { return customerInfo } + func trackPaywallView(_ eventData: PaywallEvent.Data) { + self.eventData = eventData + self.track(.view(eventData)) + } + + func trackPaywallClose(_ eventData: PaywallEvent.Data) { + self.track(.close(eventData)) + } + + fileprivate func trackCancelledPurchase() { + guard let data = self.eventData else { + Logger.warning(Strings.attempted_to_track_event_with_missing_data) + return + } + + self.track(.cancel(data.withCurrentDate())) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension PurchaseHandler { + /// Creates a copy of this `PurchaseHandler` wrapping the purchase and restore blocks. func map( purchase: @escaping (@escaping PurchaseBlock) -> PurchaseBlock, restore: @escaping (@escaping RestoreBlock) -> RestoreBlock ) -> Self { return .init(purchase: purchase(self.purchaseBlock), - restorePurchases: restore(self.restoreBlock)) + restorePurchases: restore(self.restoreBlock), + trackEvent: self.trackEventBlock) + } + + func map( + trackEvent: @escaping (@escaping TrackEventBlock) -> TrackEventBlock + ) -> Self { + return .init( + purchase: self.purchaseBlock, + restorePurchases: self.restoreBlock, + trackEvent: trackEvent(self.trackEventBlock) + ) + } + +} + +// MARK: - Private + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private extension PurchaseHandler { + + func track(_ event: PaywallEvent) { + Task.detached(priority: .background) { [block = self.trackEventBlock] in + await block(event) + } } } @@ -150,3 +207,17 @@ struct RestoredCustomerInfoPreferenceKey: PreferenceKey { } } + +// MARK: - + +private extension PaywallEvent.Data { + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func withCurrentDate() -> Self { + var copy = self + copy.date = .now + + return copy + } + +} diff --git a/RevenueCatUI/Views/LoadingPaywallView.swift b/RevenueCatUI/Views/LoadingPaywallView.swift index 6a70038125..b7e444ecca 100644 --- a/RevenueCatUI/Views/LoadingPaywallView.swift +++ b/RevenueCatUI/Views/LoadingPaywallView.swift @@ -71,6 +71,9 @@ private extension LoadingPaywallView { }, restorePurchases: { fatalError("Should not be able to purchase") + }, + trackEvent: { _ in + // Ignoring events from loading paywall view } ) diff --git a/Tests/RevenueCatUITests/PaywallViewEventsTests.swift b/Tests/RevenueCatUITests/PaywallViewEventsTests.swift new file mode 100644 index 0000000000..700959ef18 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallViewEventsTests.swift @@ -0,0 +1,198 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallViewEventsTests.swift +// +// Created by Nacho Soto on 9/7/23. + +import Nimble +import RevenueCat +@testable import RevenueCatUI +import SwiftUI +import XCTest + +#if !os(macOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +class PaywallViewEventsTests: TestCase { + + private var events: [PaywallEvent] = [] + private var handler: PurchaseHandler! + + private let mode: PaywallViewMode = .random + private let scheme: ColorScheme = Bool.random() ? .dark : .light + + private var viewEventExpectation: XCTestExpectation! + private var closeEventExpectation: XCTestExpectation! + private var cancelEventExpectation: XCTestExpectation! + + override func setUp() { + super.setUp() + + self.handler = + .cancelling() + .map { _ in + return { [weak self] event in + await self?.track(event) + } + } + + self.viewEventExpectation = .init(description: "View event") + self.closeEventExpectation = .init(description: "Close event") + self.cancelEventExpectation = .init(description: "Cancel event") + } + + func testPaywallViewEvent() async throws { + let expectation = XCTestExpectation() + + try self.createView() + .onAppear { expectation.fulfill() } + .addToHierarchy() + + await self.fulfillment(of: [expectation], timeout: 1) + + expect(self.events).to(containElementSatisfying { $0.eventType == .view }) + + let event = try XCTUnwrap(self.events.first { $0.eventType == .view }) + self.verifyEventData(event.data) + } + + func testPaywallCloseEvent() async throws { + try self.createView() + .addToHierarchy() + + await self.fulfillment(of: [self.closeEventExpectation], timeout: 1) + + expect(self.events).to(haveCount(2)) + expect(self.events).to(containElementSatisfying { $0.eventType == .close }) + + let event = try XCTUnwrap(self.events.first { $0.eventType == .view }) + self.verifyEventData(event.data) + } + + func testCloseEventHasSameSessionID() async throws { + try self.createView() + .addToHierarchy() + + await self.fulfillment(of: [self.closeEventExpectation], timeout: 1) + + expect(self.events).to(haveCount(2)) + expect(self.events.map(\.eventType)) == [.view, .close] + expect(Set(self.events.map(\.data.sessionIdentifier))).to(haveCount(1)) + } + + func testCancelledPurchase() async throws { + try self.createView() + .addToHierarchy() + + _ = try await self.handler.purchase(package: try XCTUnwrap(Self.offering.monthly)) + + await self.fulfillment(of: [self.cancelEventExpectation, self.closeEventExpectation], + timeout: 1) + + expect(self.events).to(haveCount(3)) + expect(self.events.map(\.eventType)).to(contain([.view, .cancel, .close])) + expect(Set(self.events.map(\.data.sessionIdentifier))).to(haveCount(1)) + + let data = try XCTUnwrap(self.events.first { $0.eventType == .cancel }).data + self.verifyEventData(data) + } + + func testDifferentPaywallsCreateSeparateSessionIdentifiers() async throws { + self.viewEventExpectation.expectedFulfillmentCount = 2 + self.closeEventExpectation.expectedFulfillmentCount = 2 + + let firstCloseExpectation = XCTestExpectation(description: "First paywall was closed") + + try self.createView() + .onDisappear { firstCloseExpectation.fulfill() } + .addToHierarchy() + + await self.fulfillment(of: [firstCloseExpectation], timeout: 1) + + try self.createView() + .addToHierarchy() + + await self.fulfillment(of: [self.viewEventExpectation, self.closeEventExpectation], timeout: 1) + + expect(self.events).to(haveCount(4)) + expect(self.events.map(\.eventType)) == [.view, .close, .view, .close] + expect(Set(self.events.map(\.data.sessionIdentifier))).to(haveCount(2)) + } + + private static let offering = TestData.offeringWithNoIntroOffer + +} + +// MARK: - + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PaywallViewEventsTests { + + func track(_ event: PaywallEvent) { + self.events.append(event) + + switch event { + case .view: self.viewEventExpectation.fulfill() + case .cancel: self.cancelEventExpectation.fulfill() + case .close: self.closeEventExpectation.fulfill() + } + } + + func createView() -> some View { + PaywallView( + offering: Self.offering.withLocalImages, + customerInfo: TestData.customerInfo, + mode: self.mode, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: self.handler + ) + .environment(\.colorScheme, self.scheme) + } + + func verifyEventData(_ data: PaywallEvent.Data) { + expect(data.offeringIdentifier) == Self.offering.identifier + expect(data.paywallRevision) == Self.offering.paywall?.revision + expect(data.displayMode) == self.mode + expect(data.localeIdentifier) == Locale.current.identifier + expect(data.darkMode) == (self.scheme == .dark) + } + +} + +private extension PaywallViewMode { + + static var random: Self { + return Self.allCases.randomElement()! + } + +} + +private extension PaywallEvent { + + enum EventType { + + case view + case cancel + case close + + } + + var eventType: EventType { + switch self { + case .view: return .view + case .cancel: return .cancel + case .close: return .close + } + } + +} + +#endif