Skip to content

Commit

Permalink
Paywalls: added Purchases.track(paywallEvent:)
Browse files Browse the repository at this point in the history
This uses `PaywallEventsManager` (#3159) to track events, and saves the presented `PaywallEvent` to be send along with `PostReceiptDataOperation`.
  • Loading branch information
NachoSoto committed Sep 8, 2023
1 parent bfcdf64 commit f0fdf57
Show file tree
Hide file tree
Showing 13 changed files with 263 additions and 25 deletions.
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@
4FFFE6C62AA9465000B2955C /* MockPaywallEventsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C52AA9465000B2955C /* MockPaywallEventsManager.swift */; };
4FFFE6C82AA9467800B2955C /* PaywallEventsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C72AA9467800B2955C /* PaywallEventsManagerTests.swift */; };
4FFFE6CA2AA946A700B2955C /* MockInternalAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C92AA946A700B2955C /* MockInternalAPI.swift */; };
4FFFE6E72AA948A600B2955C /* PaywallEventsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6E62AA948A600B2955C /* PaywallEventsIntegrationTests.swift */; };
57032ABF28C13CE4004FF47A /* StoreKit2SettingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57032ABE28C13CE4004FF47A /* StoreKit2SettingTests.swift */; };
57045B3829C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */; };
57045B3A29C51751001A5417 /* GetProductEntitlementMappingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */; };
Expand Down Expand Up @@ -1060,6 +1061,7 @@
4FFFE6C52AA9465000B2955C /* MockPaywallEventsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockPaywallEventsManager.swift; sourceTree = "<group>"; };
4FFFE6C72AA9467800B2955C /* PaywallEventsManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaywallEventsManagerTests.swift; sourceTree = "<group>"; };
4FFFE6C92AA946A700B2955C /* MockInternalAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockInternalAPI.swift; sourceTree = "<group>"; };
4FFFE6E62AA948A600B2955C /* PaywallEventsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventsIntegrationTests.swift; sourceTree = "<group>"; };
57032ABE28C13CE4004FF47A /* StoreKit2SettingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2SettingTests.swift; sourceTree = "<group>"; };
57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingDecodingTests.swift; sourceTree = "<group>"; };
57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductEntitlementMappingOperation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1914,6 +1916,7 @@
2CD2C541278CE0E0005D1CC2 /* RevenueCat_IntegrationPurchaseTesterConfiguration.storekit */,
2DE61A83264190830021CEA0 /* Constants.swift */,
2DE20B70264087FB004C597D /* Info.plist */,
4FFFE6E62AA948A600B2955C /* PaywallEventsIntegrationTests.swift */,
);
path = BackendIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -3824,6 +3827,7 @@
2DE20B6F264087FB004C597D /* StoreKitIntegrationTests.swift in Sources */,
4F83F6B62A5DB773003F90A5 /* TestCase.swift in Sources */,
4FCBA84F2A15391B004134BD /* SnapshotTesting+Extensions.swift in Sources */,
4FFFE6E72AA948A600B2955C /* PaywallEventsIntegrationTests.swift in Sources */,
4FA696BD2A0020A000D228B1 /* MainThreadMonitor.swift in Sources */,
2D3BFAD126DEA45C00370B11 /* MockSK1Product.swift in Sources */,
57DD426E2926B9A50026DF09 /* StoreKitTestHelpers.swift in Sources */,
Expand Down
9 changes: 9 additions & 0 deletions Sources/Logging/Strings/PaywallsStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ enum PaywallsStrings {
case warming_up_images(imageURLs: Set<URL>)
case error_prefetching_image(URL, Error)

case caching_presented_paywall
case clearing_presented_paywall

// MARK: - Events

case event_manager_not_initialized_not_available
Expand All @@ -46,6 +49,12 @@ extension PaywallsStrings: LogMessage {
case let .error_prefetching_image(url, error):
return "Error pre-fetching paywall image '\(url)': \((error as NSError).description)"

case .caching_presented_paywall:
return "PurchasesOrchestrator: caching presented paywall"

case .clearing_presented_paywall:
return "PurchasesOrchestrator: clearing presented paywall"

// MARK: - Events

case .event_manager_not_initialized_not_available:
Expand Down
9 changes: 7 additions & 2 deletions Sources/Logging/Strings/PurchaseStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ enum PurchaseStrings {
case transaction_poster_handling_transaction(transactionID: String,
productID: String,
transactionDate: Date,
offeringID: String?)
offeringID: String?,
paywallSessionID: UUID?)
case caching_presented_offering_identifier(offeringID: String, productID: String)
case payment_queue_wrapper_delegate_call_sk1_enabled
case restorepurchases_called_with_allow_sharing_appstore_account_false
Expand Down Expand Up @@ -293,14 +294,18 @@ extension PurchaseStrings: LogMessage {
case let .sk2_transactions_update_received_transaction(productID):
return "StoreKit.Transaction.updates: received transaction for product '\(productID)'"

case let .transaction_poster_handling_transaction(transactionID, productID, date, offeringID):
case let .transaction_poster_handling_transaction(transactionID, productID, date, offeringID, paywallSessionID):
var message = "TransactionPoster: handling transaction '\(transactionID)' " +
"for product '\(productID)' (date: \(date))"

if let offeringIdentifier = offeringID {
message += " in Offering '\(offeringIdentifier)'"
}

if let paywallSessionID {
message += " with paywall session '\(paywallSessionID)'"
}

return message

case let .caching_presented_offering_identifier(offeringID, productID):
Expand Down
85 changes: 66 additions & 19 deletions Sources/Networking/Operations/PostReceiptDataOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,6 @@ import Foundation

final class PostReceiptDataOperation: CacheableNetworkOperation {

struct PostData {

let appUserID: String
let receiptData: Data
let isRestore: Bool
let productData: ProductRequestData?
let presentedOfferingIdentifier: String?
let observerMode: Bool
let initiationSource: ProductRequestData.InitiationSource
let subscriberAttributesByKey: SubscriberAttribute.Dictionary?
let aadAttributionToken: String?
/// - Note: this is only used for the backend to disambiguate receipts created in `SKTestSession`s.
let testReceiptIdentifier: String?

}

private let postData: PostData
private let configuration: AppUserConfiguration
private let customerInfoResponseHandler: CustomerInfoResponseHandler
Expand Down Expand Up @@ -131,6 +115,37 @@ final class PostReceiptDataOperation: CacheableNetworkOperation {

}

extension PostReceiptDataOperation {

struct PostData {

let appUserID: String
let receiptData: Data
let isRestore: Bool
let productData: ProductRequestData?
let presentedOfferingIdentifier: String?
let paywall: Paywall?
let observerMode: Bool
let initiationSource: ProductRequestData.InitiationSource
let subscriberAttributesByKey: SubscriberAttribute.Dictionary?
let aadAttributionToken: String?
/// - Note: this is only used for the backend to disambiguate receipts created in `SKTestSession`s.
let testReceiptIdentifier: String?

}

struct Paywall {

var sessionID: String
var revision: Int
var displayMode: PaywallViewMode
var darkMode: Bool
var localeIdentifier: String

}

}

extension PostReceiptDataOperation.PostData {

init(
Expand All @@ -146,6 +161,7 @@ extension PostReceiptDataOperation.PostData {
isRestore: data.source.isRestore,
productData: productData,
presentedOfferingIdentifier: data.presentedOfferingID,
paywall: data.paywall,
observerMode: observerMode,
initiationSource: data.source.initiationSource,
subscriberAttributesByKey: data.unsyncedAttributes,
Expand All @@ -156,6 +172,20 @@ extension PostReceiptDataOperation.PostData {

}

private extension PurchasedTransactionData {

var paywall: PostReceiptDataOperation.Paywall? {
guard let paywall = self.presentedPaywall else { return nil }

return .init(sessionID: paywall.sessionIdentifier.uuidString,
revision: paywall.paywallRevision,
displayMode: paywall.displayMode,
darkMode: paywall.darkMode,
localeIdentifier: paywall.localeIdentifier)
}

}

// MARK: - Private

private extension PostReceiptDataOperation {
Expand Down Expand Up @@ -183,7 +213,7 @@ private extension PostReceiptDataOperation {

}

// MARK: - Request Data
// MARK: - Codable

extension PostReceiptDataOperation.PostData: Encodable {

Expand All @@ -197,6 +227,7 @@ extension PostReceiptDataOperation.PostData: Encodable {
case attributes
case aadAttributionToken
case presentedOfferingIdentifier
case paywall
case testReceiptIdentifier = "test_receipt_identifier"

}
Expand All @@ -214,8 +245,8 @@ extension PostReceiptDataOperation.PostData: Encodable {
try productData.encode(to: encoder)
}

try container.encodeIfPresent(self.presentedOfferingIdentifier,
forKey: .presentedOfferingIdentifier)
try container.encodeIfPresent(self.presentedOfferingIdentifier, forKey: .presentedOfferingIdentifier)
try container.encodeIfPresent(self.paywall, forKey: .paywall)

try container.encodeIfPresent(
self.subscriberAttributesByKey
Expand All @@ -232,6 +263,22 @@ extension PostReceiptDataOperation.PostData: Encodable {

}

extension PostReceiptDataOperation.Paywall: Codable {

private enum CodingKeys: String, CodingKey {

case sessionID = "sessionId"
case revision
case displayMode
case darkMode
case localeIdentifier

}

}

// MARK: - HTTPRequestBody

extension PostReceiptDataOperation.PostData: HTTPRequestBody {

var contentForSignature: [(key: String, value: String)] {
Expand Down
46 changes: 46 additions & 0 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
private let offlineEntitlementsManager: OfflineEntitlementsManager
private let productsManager: ProductsManagerType
private let customerInfoManager: CustomerInfoManager
private let paywallEventsManager: PaywallEventsManagerType?
private let trialOrIntroPriceEligibilityChecker: CachingTrialOrIntroPriceEligibilityChecker
private let purchasedProductsFetcher: PurchasedProductsFetcherType?
private let purchasesOrchestrator: PurchasesOrchestrator
Expand Down Expand Up @@ -339,6 +340,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
transactionFetcher: StoreKit2TransactionFetcher(),
transactionPoster: transactionPoster,
systemInfo: systemInfo)

let attributionDataMigrator = AttributionDataMigrator()
let subscriberAttributesManager = SubscriberAttributesManager(backend: backend,
deviceCache: deviceCache,
Expand All @@ -351,6 +353,23 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
attributeSyncing: subscriberAttributesManager,
appUserID: appUserID)

let paywallEventsManager: PaywallEventsManagerType?
do {
if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) {
paywallEventsManager = PaywallEventsManager(
internalAPI: backend.internalAPI,
userProvider: identityManager,
store: try PaywallEventStore.createDefault()
)
} else {
Logger.verbose(Strings.paywalls.event_manager_not_initialized_not_available)
paywallEventsManager = nil
}
} catch {
Logger.verbose(Strings.paywalls.event_manager_failed_to_initialize(error))
paywallEventsManager = nil
}

let attributionPoster = AttributionPoster(deviceCache: deviceCache,
currentUserProvider: identityManager,
backend: backend,
Expand Down Expand Up @@ -453,6 +472,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
subscriberAttributes: subscriberAttributes,
operationDispatcher: operationDispatcher,
customerInfoManager: customerInfoManager,
paywallEventsManager: paywallEventsManager,
productsManager: productsManager,
offeringsManager: offeringsManager,
offlineEntitlementsManager: offlineEntitlementsManager,
Expand All @@ -479,6 +499,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
subscriberAttributes: Attribution,
operationDispatcher: OperationDispatcher,
customerInfoManager: CustomerInfoManager,
paywallEventsManager: PaywallEventsManagerType?,
productsManager: ProductsManagerType,
offeringsManager: OfferingsManager,
offlineEntitlementsManager: OfflineEntitlementsManager,
Expand Down Expand Up @@ -526,6 +547,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
self.attribution = subscriberAttributes
self.operationDispatcher = operationDispatcher
self.customerInfoManager = customerInfoManager
self.paywallEventsManager = paywallEventsManager
self.productsManager = productsManager
self.offeringsManager = offeringsManager
self.offlineEntitlementsManager = offlineEntitlementsManager
Expand Down Expand Up @@ -1031,6 +1053,30 @@ public extension Purchases {

// swiftlint:enable missing_docs

// MARK: - Paywalls

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
public extension Purchases {

/// Used by `RevenueCatUI` to keep track of ``PaywallEvent``s.
func track(paywallEvent: PaywallEvent) async {
switch paywallEvent {
case let .view(data):
self.purchasesOrchestrator.cachePresentedPaywall(data)

case .close:
self.purchasesOrchestrator.clearPresentedPaywall()

case .cancel:
// No special handling, simply track the event below.
break
}

await self.paywallEventsManager?.track(paywallEvent: paywallEvent)
}

}

// MARK: Configuring Purchases

public extension Purchases {
Expand Down
19 changes: 19 additions & 0 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ final class PurchasesOrchestrator {

private let _allowSharingAppStoreAccount: Atomic<Bool?> = nil
private let presentedOfferingIDsByProductID: Atomic<[String: String]> = .init([:])
private let presentedPaywall: Atomic<PaywallEvent.Data?> = nil
private let purchaseCompleteCallbacksByProductID: Atomic<[String: PurchaseCompletedBlock]> = .init([:])

private var appUserID: String { self.currentUserProvider.currentAppUserID }
Expand Down Expand Up @@ -546,6 +547,16 @@ final class PurchasesOrchestrator {
self.presentedOfferingIDsByProductID.modify { $0[productIdentifier] = identifier }
}

func cachePresentedPaywall(_ paywall: PaywallEvent.Data) {
Logger.verbose(Strings.paywalls.caching_presented_paywall)
self.presentedPaywall.value = paywall
}

func clearPresentedPaywall() {
Logger.verbose(Strings.paywalls.clearing_presented_paywall)
self.presentedPaywall.value = nil
}

#if os(iOS) || os(macOS) || VISION_OS

@available(watchOS, unavailable)
Expand Down Expand Up @@ -1077,6 +1088,7 @@ private extension PurchasesOrchestrator {
storefront: StorefrontType?,
restored: Bool) {
let offeringID = self.getAndRemovePresentedOfferingIdentifier(for: purchasedTransaction)
let paywall = self.getAndRemovePresentedPaywall()
let unsyncedAttributes = self.unsyncedAttributes
let adServicesToken = self.attribution.unsyncedAdServicesToken

Expand All @@ -1085,6 +1097,7 @@ private extension PurchasesOrchestrator {
data: .init(
appUserID: self.appUserID,
presentedOfferingID: offeringID,
presentedPaywall: paywall,
unsyncedAttributes: unsyncedAttributes,
aadAttributionToken: adServicesToken,
storefront: storefront,
Expand Down Expand Up @@ -1143,6 +1156,10 @@ private extension PurchasesOrchestrator {
return self.getAndRemovePresentedOfferingIdentifier(for: transaction.productIdentifier)
}

func getAndRemovePresentedPaywall() -> PaywallEvent.Data? {
return self.presentedPaywall.getAndSet(nil)
}

/// Computes a `ProductRequestData` for an active subscription found in the receipt,
/// or `nil` if there is any issue fetching it.
func createProductRequestData(
Expand Down Expand Up @@ -1207,6 +1224,7 @@ extension PurchasesOrchestrator {
) async throws -> CustomerInfo {
let storefront = await Storefront.currentStorefront
let offeringID = self.getAndRemovePresentedOfferingIdentifier(for: transaction)
let paywall = self.getAndRemovePresentedPaywall()
let unsyncedAttributes = self.unsyncedAttributes
let adServicesToken = self.attribution.unsyncedAdServicesToken

Expand All @@ -1215,6 +1233,7 @@ extension PurchasesOrchestrator {
data: .init(
appUserID: self.appUserID,
presentedOfferingID: offeringID,
presentedPaywall: paywall,
unsyncedAttributes: unsyncedAttributes,
aadAttributionToken: adServicesToken,
storefront: storefront,
Expand Down
Loading

0 comments on commit f0fdf57

Please sign in to comment.