diff --git a/Sources/Attribution/AttributionPoster.swift b/Sources/Attribution/AttributionPoster.swift index 1b1322b0b4..f05a949470 100644 --- a/Sources/Attribution/AttributionPoster.swift +++ b/Sources/Attribution/AttributionPoster.swift @@ -128,15 +128,37 @@ final class AttributionPoster { @available(tvOS, unavailable) @available(watchOS, unavailable) func postAdServicesTokenIfNeeded() { - guard latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices) == nil else { - return + guard let attributionToken = self.adServicesTokenToPostIfNeeded else { return } + + self.post(adServicesToken: attributionToken) + } + + var adServicesTokenToPostIfNeeded: String? { + #if os(tvOS) || os(watchOS) + return nil + #else + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return nil } - guard let attributionToken = attributionFetcher.adServicesToken else { - return + guard self.latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices) == nil else { + return nil } - self.post(adServicesToken: attributionToken) + return self.attributionFetcher.adServicesToken + #endif + } + + @discardableResult + func markAdServicesToken(_ token: String, asSyncedFor userID: String) -> [AttributionNetwork: String] { + Logger.info(Strings.attribution.adservices_marking_as_synced(appUserID: userID)) + + var newDictToCache = self.deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: userID) + newDictToCache[AttributionNetwork.adServices] = token + + self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: userID) + + return newDictToCache } func postPostponedAttributionDataIfNeeded() { @@ -167,11 +189,9 @@ final class AttributionPoster { let currentAppUserID = self.currentUserProvider.currentAppUserID // set the cache in advance to avoid multiple post calls - var newDictToCache = self.deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: currentAppUserID) - newDictToCache[AttributionNetwork.adServices] = adServicesToken - self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: currentAppUserID) + var newDictToCache = self.markAdServicesToken(adServicesToken, asSyncedFor: currentAppUserID) - backend.post(adServicesToken: adServicesToken, appUserID: currentAppUserID) { error in + self.backend.post(adServicesToken: adServicesToken, appUserID: currentAppUserID) { error in guard let error = error else { Logger.debug(Strings.attribution.adservices_token_post_succeeded) return diff --git a/Sources/Logging/Strings/AttributionStrings.swift b/Sources/Logging/Strings/AttributionStrings.swift index 9b6183a5fc..94a9353748 100644 --- a/Sources/Logging/Strings/AttributionStrings.swift +++ b/Sources/Logging/Strings/AttributionStrings.swift @@ -43,6 +43,7 @@ enum AttributionStrings { case adservices_token_fetch_failed(error: Error) case adservices_token_post_failed(error: BackendError) case adservices_token_post_succeeded + case adservices_marking_as_synced(appUserID: String) case adservices_token_unavailable_in_simulator case latest_attribution_sent_user_defaults_invalid(networkKey: String) case copying_attributes(oldAppUserID: String, newAppUserID: String) @@ -138,6 +139,9 @@ extension AttributionStrings: CustomStringConvertible { case .adservices_token_post_succeeded: return "AdServices attribution token successfully posted" + case let .adservices_marking_as_synced(userID): + return "Marking AdServices attribution token as synced for App User ID: \(userID)" + case .adservices_token_unavailable_in_simulator: return "AdServices attribution token is not available in the simulator" diff --git a/Sources/Networking/Operations/PostReceiptDataOperation.swift b/Sources/Networking/Operations/PostReceiptDataOperation.swift index 220ba92111..fa4d47c5b4 100644 --- a/Sources/Networking/Operations/PostReceiptDataOperation.swift +++ b/Sources/Networking/Operations/PostReceiptDataOperation.swift @@ -25,6 +25,7 @@ final class PostReceiptDataOperation: CacheableNetworkOperation { let observerMode: Bool let initiationSource: ProductRequestData.InitiationSource let subscriberAttributesByKey: SubscriberAttribute.Dictionary? + let aadAttributionToken: String? } @@ -142,7 +143,8 @@ extension PostReceiptDataOperation.PostData { presentedOfferingIdentifier: data.presentedOfferingID, observerMode: observerMode, initiationSource: data.source.initiationSource, - subscriberAttributesByKey: data.unsyncedAttributes + subscriberAttributesByKey: data.unsyncedAttributes, + aadAttributionToken: data.aadAttributionToken ) } @@ -184,6 +186,7 @@ extension PostReceiptDataOperation.PostData: Encodable { case observerMode case initiationSource case attributes + case aadAttributionToken case presentedOfferingIdentifier } @@ -210,6 +213,8 @@ extension PostReceiptDataOperation.PostData: Encodable { .map(AnyEncodable.init), forKey: .attributes ) + + try container.encodeIfPresent(self.aadAttributionToken, forKey: .aadAttributionToken) } } diff --git a/Sources/Purchasing/Purchases/Attribution.swift b/Sources/Purchasing/Purchases/Attribution.swift index 06c8c4bd8b..096f21a21a 100644 --- a/Sources/Purchasing/Purchases/Attribution.swift +++ b/Sources/Purchasing/Purchases/Attribution.swift @@ -28,6 +28,8 @@ import Foundation private let subscriberAttributesManager: SubscriberAttributesManager private let currentUserProvider: CurrentUserProvider private let attributionPoster: AttributionPoster + private let systemInfo: SystemInfo + private var appUserID: String { self.currentUserProvider.currentAppUserID } private var automaticAdServicesAttributionTokenCollection: Bool = false @@ -35,10 +37,12 @@ import Foundation init(subscriberAttributesManager: SubscriberAttributesManager, currentUserProvider: CurrentUserProvider, - attributionPoster: AttributionPoster) { + attributionPoster: AttributionPoster, + systemInfo: SystemInfo) { self.subscriberAttributesManager = subscriberAttributesManager self.currentUserProvider = currentUserProvider self.attributionPoster = attributionPoster + self.systemInfo = systemInfo super.init() @@ -58,17 +62,26 @@ public extension Attribution { */ @objc func enableAdServicesAttributionTokenCollection() { self.automaticAdServicesAttributionTokenCollection = true + self.postAdServicesTokenIfNeeded() } internal func postAdServicesTokenIfNeeded() { - if self.automaticAdServicesAttributionTokenCollection { + if self.automaticAdServicesAttributionTokenCollection, + self.automaticAdServicesTokenPostingEnabled { self.attributionPoster.postAdServicesTokenIfNeeded() } } + private var automaticAdServicesTokenPostingEnabled: Bool { + /// In custom entitlements computation mode, ad services token is sent only through `PostReceiptOperation` + return !self.systemInfo.dangerousSettings.customEntitlementComputation + } + } +#if !CUSTOM_ENTITLEMENTS_COMPUTATION + public extension Attribution { /** @@ -352,6 +365,8 @@ public extension Attribution { } +#endif + // @unchecked because: // - It contains mutable state (`weak var delegate`). extension Attribution: @unchecked Sendable {} @@ -387,6 +402,14 @@ extension Attribution { self.subscriberAttributesManager.unsyncedAttributesByKey(appUserID: appUserID) } + var unsyncedAdServicesToken: String? { + guard self.automaticAdServicesAttributionTokenCollection else { + return nil + } + + return self.attributionPoster.adServicesTokenToPostIfNeeded + } + @discardableResult func syncAttributesForAllUsers(currentAppUserID: String, syncedAttribute: (@Sendable (PurchasesError?) -> Void)? = nil, @@ -400,6 +423,10 @@ extension Attribution { self.subscriberAttributesManager.markAttributesAsSynced(attributesToSync, appUserID: appUserID) } + func markAdServicesTokenAsSynced(_ token: String, appUserID: String) { + self.attributionPoster.markAdServicesToken(token, asSyncedFor: appUserID) + } + } protocol AttributionDelegate: AnyObject, Sendable { diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 3cb492faac..c2da88647f 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -211,12 +211,8 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void /// Current version of the ``Purchases`` framework. @objc public static var frameworkVersion: String { SystemInfo.frameworkVersion } - #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION - @objc public let attribution: Attribution - #endif - @objc public var finishTransactions: Bool { get { self.systemInfo.finishTransactions } set { self.systemInfo.finishTransactions = newValue } @@ -351,7 +347,8 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void subscriberAttributesManager: subscriberAttributesManager) let subscriberAttributes = Attribution(subscriberAttributesManager: subscriberAttributesManager, currentUserProvider: identityManager, - attributionPoster: attributionPoster) + attributionPoster: attributionPoster, + systemInfo: systemInfo) let introCalculator = IntroEligibilityCalculator(productsManager: productsManager, receiptParser: receiptParser) let offeringsManager = OfferingsManager(deviceCache: deviceCache, operationDispatcher: operationDispatcher, @@ -500,9 +497,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void self.userDefaults = userDefaults self.notificationCenter = notificationCenter self.systemInfo = systemInfo - #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION self.attribution = subscriberAttributes - #endif self.operationDispatcher = operationDispatcher self.customerInfoManager = customerInfoManager self.productsManager = productsManager diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 36892cd98a..c558035c09 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -903,6 +903,7 @@ private extension PurchasesOrchestrator { func markSyncedIfNeeded( subscriberAttributes: SubscriberAttribute.Dictionary?, + adServicesToken: String?, error: BackendError? ) { if let error = error { @@ -916,6 +917,9 @@ private extension PurchasesOrchestrator { } self.attribution.markAttributesAsSynced(subscriberAttributes, appUserID: self.appUserID) + if let adServicesToken = adServicesToken { + self.attribution.markAdServicesTokenAsSynced(adServicesToken, appUserID: self.appUserID) + } } // swiftlint:disable:next function_body_length @@ -931,6 +935,8 @@ private extension PurchasesOrchestrator { let currentAppUserID = self.appUserID let unsyncedAttributes = self.unsyncedAttributes + let adServicesToken = self.attribution.unsyncedAdServicesToken + // Refresh the receipt and post to backend, this will allow the transactions to be transferred. // https://rev.cat/apple-restoring-purchased-products self.receiptFetcher.receiptData(refreshPolicy: receiptRefreshPolicy) { receiptData in @@ -978,6 +984,7 @@ private extension PurchasesOrchestrator { observerMode: self.observerMode) { result in self.handleReceiptPost(result: result, subscriberAttributes: unsyncedAttributes, + adServicesToken: adServicesToken, completion: completion) } } @@ -987,10 +994,12 @@ private extension PurchasesOrchestrator { func handleReceiptPost(result: Result, subscriberAttributes: SubscriberAttribute.Dictionary, + adServicesToken: String?, completion: (@Sendable (Result) -> Void)?) { self.handlePostReceiptResult( result, - subscriberAttributes: subscriberAttributes + subscriberAttributes: subscriberAttributes, + adServicesToken: adServicesToken ) if let completion = completion { @@ -1001,12 +1010,14 @@ private extension PurchasesOrchestrator { } func handlePostReceiptResult(_ result: Result, - subscriberAttributes: SubscriberAttribute.Dictionary) { + subscriberAttributes: SubscriberAttribute.Dictionary, + adServicesToken: String?) { if let customerInfo = result.value { self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: self.appUserID) } self.markSyncedIfNeeded(subscriberAttributes: subscriberAttributes, + adServicesToken: adServicesToken, error: result.error) } @@ -1015,6 +1026,7 @@ private extension PurchasesOrchestrator { restored: Bool) { let offeringID = self.getAndRemovePresentedOfferingIdentifier(for: purchasedTransaction) let unsyncedAttributes = self.unsyncedAttributes + let adServicesToken = self.attribution.unsyncedAdServicesToken self.transactionPoster.handlePurchasedTransaction( purchasedTransaction, @@ -1022,12 +1034,15 @@ private extension PurchasesOrchestrator { appUserID: self.appUserID, presentedOfferingID: offeringID, unsyncedAttributes: unsyncedAttributes, + aadAttributionToken: adServicesToken, storefront: storefront, source: self.purchaseSource(for: purchasedTransaction.productIdentifier, restored: restored) ) ) { result in - self.handlePostReceiptResult(result, subscriberAttributes: unsyncedAttributes) + self.handlePostReceiptResult(result, + subscriberAttributes: unsyncedAttributes, + adServicesToken: adServicesToken) if let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: purchasedTransaction) { self.operationDispatcher.dispatchOnMainActor { @@ -1135,6 +1150,7 @@ extension PurchasesOrchestrator { let storefront = await Storefront.currentStorefront let offeringID = self.getAndRemovePresentedOfferingIdentifier(for: transaction) let unsyncedAttributes = self.unsyncedAttributes + let adServicesToken = self.attribution.unsyncedAdServicesToken return try await withCheckedThrowingContinuation { continuation in self.transactionPoster.handlePurchasedTransaction( @@ -1143,12 +1159,15 @@ extension PurchasesOrchestrator { appUserID: self.appUserID, presentedOfferingID: offeringID, unsyncedAttributes: unsyncedAttributes, + aadAttributionToken: adServicesToken, storefront: storefront, source: .init(isRestore: self.allowSharingAppStoreAccount, initiationSource: initiationSource) ) ) { result in - self.handlePostReceiptResult(result, subscriberAttributes: unsyncedAttributes) + self.handlePostReceiptResult(result, + subscriberAttributes: unsyncedAttributes, + adServicesToken: adServicesToken) continuation.resume(with: result.mapError(\.asPurchasesError)) } diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 698c13509b..dabb3c0af9 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -27,6 +27,7 @@ struct PurchasedTransactionData { var appUserID: String var presentedOfferingID: String? var unsyncedAttributes: SubscriberAttribute.Dictionary? + var aadAttributionToken: String? var storefront: StorefrontType? var source: PurchaseSource diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/CustomEntitlementComputationSwiftAPITester.xcodeproj/project.pbxproj b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/CustomEntitlementComputationSwiftAPITester.xcodeproj/project.pbxproj index b6e3fa4302..3eab8185ab 100644 --- a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/CustomEntitlementComputationSwiftAPITester.xcodeproj/project.pbxproj +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/CustomEntitlementComputationSwiftAPITester.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 2DD778ED270E23460079CBD4 /* OfferingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D614C726EBE7EA007DDB75 /* OfferingAPI.swift */; }; 2DD778EE270E23460079CBD4 /* EntitlementInfosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D614C526EBE7EA007DDB75 /* EntitlementInfosAPI.swift */; }; 2DD778EF270E23460079CBD4 /* EntitlementInfoAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D614C826EBE7EA007DDB75 /* EntitlementInfoAPI.swift */; }; + 4F592A502A1FDC6F00851F36 /* AttributionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F592A4F2A1FDC6F00851F36 /* AttributionAPI.swift */; }; 570EC3B229F9A0830036A023 /* RevenueCat_CustomEntitlementComputation in Frameworks */ = {isa = PBXBuildFile; productRef = 570EC3B129F9A0830036A023 /* RevenueCat_CustomEntitlementComputation */; }; 570FAF562864EE1D00D3C769 /* NonSubscriptionTransactionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570FAF552864EE1D00D3C769 /* NonSubscriptionTransactionAPI.swift */; }; 5738F40C27866DD00096D623 /* StoreProductDiscountAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5738F40B27866DD00096D623 /* StoreProductDiscountAPI.swift */; }; @@ -50,6 +51,7 @@ /* Begin PBXFileReference section */ 2C396F5B281C64AF00669657 /* AdServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdServices.framework; path = System/Library/Frameworks/AdServices.framework; sourceTree = SDKROOT; }; 2DD778D0270E233F0079CBD4 /* SwiftAPITester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftAPITester.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F592A4F2A1FDC6F00851F36 /* AttributionAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionAPI.swift; sourceTree = ""; }; 570FAF552864EE1D00D3C769 /* NonSubscriptionTransactionAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonSubscriptionTransactionAPI.swift; sourceTree = ""; }; 5738F40B27866DD00096D623 /* StoreProductDiscountAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductDiscountAPI.swift; sourceTree = ""; }; 5738F429278673A80096D623 /* SubscriptionPeriodAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPeriodAPI.swift; sourceTree = ""; }; @@ -116,6 +118,7 @@ A55F62B726EAFFD200A1B466 /* SwiftAPITester */ = { isa = PBXGroup; children = ( + 4F592A4F2A1FDC6F00851F36 /* AttributionAPI.swift */, B32554412825E5EA00DA62EA /* ConfigurationAPI.swift */, A5D614C426EBE7EA007DDB75 /* CustomerInfoAPI.swift */, A5D614C826EBE7EA007DDB75 /* EntitlementInfoAPI.swift */, @@ -238,6 +241,7 @@ B32554422825E5EA00DA62EA /* ConfigurationAPI.swift in Sources */, 5738F40C27866DD00096D623 /* StoreProductDiscountAPI.swift in Sources */, 5740FCD52996D7B800E049F9 /* VerificationResultAPI.swift in Sources */, + 4F592A502A1FDC6F00851F36 /* AttributionAPI.swift in Sources */, 5758EE582786542200B3B703 /* StoreTransactionAPI.swift in Sources */, B3A4C834280DE72600D4AE17 /* PromotionalOfferAPI.swift in Sources */, 5738F42A278673A80096D623 /* SubscriptionPeriodAPI.swift in Sources */, diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/AttributionAPI.swift b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/AttributionAPI.swift new file mode 100644 index 0000000000..533f31ad98 --- /dev/null +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/AttributionAPI.swift @@ -0,0 +1,14 @@ +// +// AttributionAPI.swift +// SwiftAPITester +// +// Created by Nacho Soto on 5/25/23. +// + +import RevenueCat_CustomEntitlementComputation + +private var attribution: Attribution! + +func checkAttributionAPI() { + attribution.enableAdServicesAttributionTokenCollection() +} diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift index 26445bd4bb..bc7cc63b79 100644 --- a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -31,6 +31,8 @@ func checkPurchasesAPI() { checkPurchasesPurchasingAPI(purchases: purch) checkPurchasesSupportAPI(purchases: purch) + let _: Attribution = purch.attribution + if #available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) { _ = Task { await checkAsyncMethods(purchases: purch) diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/main.swift b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/main.swift index ac620b68e0..e445f4af77 100644 --- a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/main.swift +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/main.swift @@ -14,6 +14,8 @@ import Foundation func main() -> Int { + checkAttributionAPI() + checkEntitlementInfoAPI() checkEntitlementInfoEnums() checkEntitlementInfosAPI() diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index 8ff345e1da..f67a42dcc4 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -25,6 +25,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { private var systemInfo: MockSystemInfo! private var subscriberAttributesManager: MockSubscriberAttributesManager! private var attribution: Attribution! + private var attributionFetcher: MockAttributionFetcher! private var operationDispatcher: MockOperationDispatcher! private var receiptFetcher: MockReceiptFetcher! private var receiptParser: MockReceiptParser! @@ -41,12 +42,13 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { private var orchestrator: PurchasesOrchestrator! + private static let mockUserID = "appUserID" + override func setUpWithError() throws { try super.setUpWithError() try self.setUpSystemInfo() - let mockUserID = "appUserID" self.productsManager = MockProductsManager(systemInfo: self.systemInfo, requestTimeout: Configuration.storeKitRequestTimeoutDefault) self.operationDispatcher = MockOperationDispatcher() @@ -73,30 +75,24 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { transactionPoster: self.transactionPoster, systemInfo: self.systemInfo ) - self.currentUserProvider = MockCurrentUserProvider(mockAppUserID: mockUserID) + self.currentUserProvider = MockCurrentUserProvider(mockAppUserID: Self.mockUserID) self.transactionsManager = MockTransactionsManager(receiptParser: MockReceiptParser()) - let attributionFetcher = MockAttributionFetcher(attributionFactory: MockAttributionTypeFactory(), - systemInfo: self.systemInfo) + self.attributionFetcher = MockAttributionFetcher(attributionFactory: MockAttributionTypeFactory(), + systemInfo: self.systemInfo) self.subscriberAttributesManager = MockSubscriberAttributesManager( backend: self.backend, deviceCache: self.deviceCache, operationDispatcher: MockOperationDispatcher(), - attributionFetcher: attributionFetcher, + attributionFetcher: self.attributionFetcher, attributionDataMigrator: MockAttributionDataMigrator()) - let attributionPoster = AttributionPoster(deviceCache: self.deviceCache, - currentUserProvider: self.currentUserProvider, - backend: self.backend, - attributionFetcher: attributionFetcher, - subscriberAttributesManager: self.subscriberAttributesManager) - self.attribution = Attribution(subscriberAttributesManager: self.subscriberAttributesManager, - currentUserProvider: MockCurrentUserProvider(mockAppUserID: mockUserID), - attributionPoster: attributionPoster) self.mockManageSubsHelper = MockManageSubscriptionsHelper(systemInfo: self.systemInfo, customerInfoManager: self.customerInfoManager, currentUserProvider: self.currentUserProvider) self.mockBeginRefundRequestHelper = MockBeginRefundRequestHelper(systemInfo: self.systemInfo, customerInfoManager: self.customerInfoManager, currentUserProvider: self.currentUserProvider) + self.setUpStoreKit1Wrapper() + self.setUpAttribution() self.setUpOrchestrator() self.setUpStoreKit2Listener() } @@ -132,6 +128,19 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { self.paymentQueueWrapper = .left(self.storeKit1Wrapper) } + fileprivate func setUpAttribution() { + let attributionPoster = AttributionPoster(deviceCache: self.deviceCache, + currentUserProvider: self.currentUserProvider, + backend: self.backend, + attributionFetcher: self.attributionFetcher, + subscriberAttributesManager: self.subscriberAttributesManager) + + self.attribution = Attribution(subscriberAttributesManager: self.subscriberAttributesManager, + currentUserProvider: MockCurrentUserProvider(mockAppUserID: Self.mockUserID), + attributionPoster: attributionPoster, + systemInfo: self.systemInfo) + } + fileprivate func setUpOrchestrator() { self.orchestrator = PurchasesOrchestrator(productsManager: self.productsManager, paymentQueueWrapper: self.paymentQueueWrapper, @@ -222,6 +231,89 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.backend.invokedPostReceiptDataParameters?.transactionData.source.initiationSource) == .purchase } + func testPurchaseSK1PackageDoesNotPostAdServicesTokenIfNotEnabled() async throws { + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + + self.attributionFetcher.adServicesTokenToReturn = "token" + + let product = try await self.fetchSk1Product() + let storeProduct = StoreProduct(sk1Product: product) + + let package = Package(identifier: "package", + packageType: .monthly, + storeProduct: storeProduct, + offeringIdentifier: "offering") + + let payment = self.storeKit1Wrapper.payment(with: product) + + _ = await withCheckedContinuation { continuation in + self.orchestrator.purchase( + sk1Product: product, + payment: payment, + package: package, + wrapper: self.storeKit1Wrapper + ) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + expect(self.backend.invokedPostReceiptDataCount) == 1 + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.aadAttributionToken).to(beNil()) + } + + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func testPurchaseSK1PackageWithSubscriberAttributesAndAdServicesToken() async throws { + try AvailabilityChecks.skipIfTVOrWatchOS() + try AvailabilityChecks.iOS14_3APIAvailableOrSkipTest() + + // Test for custom entitlement computation mode. + // Without that mode, the token is posted upon calling `enableAdServicesAttributionTokenCollection` + self.systemInfo = .init(finishTransactions: true, customEntitlementsComputation: true) + self.setUpAttribution() + self.setUpOrchestrator() + + let token = "token" + let attributes: SubscriberAttribute.Dictionary = [ + "attribute_1": .init(attribute: .campaign, value: "campaign"), + "attribute_2": .init(attribute: .email, value: "email") + ] + + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + + self.attributionFetcher.adServicesTokenToReturn = "token" + self.subscriberAttributesManager.stubbedUnsyncedAttributesByKeyResult = attributes + self.attribution.enableAdServicesAttributionTokenCollection() + + let product = try await self.fetchSk1Product() + let storeProduct = StoreProduct(sk1Product: product) + + let package = Package(identifier: "package", + packageType: .monthly, + storeProduct: storeProduct, + offeringIdentifier: "offering") + + let payment = self.storeKit1Wrapper.payment(with: product) + + _ = await withCheckedContinuation { continuation in + self.orchestrator.purchase( + sk1Product: product, + payment: payment, + package: package, + wrapper: self.storeKit1Wrapper + ) { transaction, customerInfo, error, userCancelled in + continuation.resume(returning: (transaction, customerInfo, error, userCancelled)) + } + } + + expect(self.backend.invokedPostReceiptDataCount) == 1 + expect(self.backend.invokedPostReceiptDataParameters?.productData).toNot(beNil()) + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.aadAttributionToken) == token + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.unsyncedAttributes) == attributes + } + func testSK1PurchaseDoesNotAlwaysRefreshReceiptInProduction() async throws { self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) @@ -510,6 +602,87 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { expect(self.backend.invokedPostReceiptDataParameters?.transactionData.presentedOfferingID) == "offering" } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) + func testPurchaseSK2PackageDoesNotPostAdServicesTokenIfNotEnabled() async throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + try AvailabilityChecks.skipIfTVOrWatchOS() + + let mockListener = try XCTUnwrap( + self.orchestrator.storeKit2TransactionListener as? MockStoreKit2TransactionListener + ) + + self.attributionFetcher.adServicesTokenToReturn = "token" + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + mockListener.mockTransaction = .init(try await self.simulateAnyPurchase()) + + let product = try await self.fetchSk2Product() + + let package = Package(identifier: "package", + packageType: .monthly, + storeProduct: StoreProduct(sk2Product: product), + offeringIdentifier: "offering") + + _ = try await self.orchestrator.purchase(sk2Product: product, package: package, promotionalOffer: nil) + + expect(self.receiptFetcher.receiptDataCalled) == true + expect(self.receiptFetcher.receiptDataReceivedRefreshPolicy) == .always + + expect(self.backend.invokedPostReceiptDataCount) == 1 + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.aadAttributionToken).to(beNil()) + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func testPurchaseSK2PackagePostsAdServicesTokenAndAttributes() async throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + try AvailabilityChecks.skipIfTVOrWatchOS() + + // Test for custom entitlement computation mode. + // Without that mode, the token is posted upon calling `enableAdServicesAttributionTokenCollection` + self.systemInfo = .init(finishTransactions: true, customEntitlementsComputation: true) + self.setUpAttribution() + self.setUpOrchestrator() + self.setUpStoreKit2Listener() + + let mockListener = try XCTUnwrap( + self.orchestrator.storeKit2TransactionListener as? MockStoreKit2TransactionListener + ) + + let token = "token" + let attributes: SubscriberAttribute.Dictionary = [ + "attribute_1": .init(attribute: .campaign, value: "campaign"), + "attribute_2": .init(attribute: .email, value: "email") + ] + + self.attributionFetcher.adServicesTokenToReturn = "token" + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + + self.attributionFetcher.adServicesTokenToReturn = "token" + self.subscriberAttributesManager.stubbedUnsyncedAttributesByKeyResult = attributes + self.attribution.enableAdServicesAttributionTokenCollection() + + mockListener.mockTransaction = .init(try await self.simulateAnyPurchase()) + + let product = try await self.fetchSk2Product() + + let package = Package(identifier: "package", + packageType: .monthly, + storeProduct: StoreProduct(sk2Product: product), + offeringIdentifier: "offering") + + _ = try await self.orchestrator.purchase(sk2Product: product, package: package, promotionalOffer: nil) + + expect(self.receiptFetcher.receiptDataCalled) == true + expect(self.receiptFetcher.receiptDataReceivedRefreshPolicy) == .always + + expect(self.backend.invokedPostReceiptDataCount) == 1 + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.aadAttributionToken) == token + expect(self.backend.invokedPostReceiptDataParameters?.transactionData.unsyncedAttributes) == attributes + } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func testPurchaseSK2PackageRetriesReceiptFetchIfEnabled() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() diff --git a/Tests/StoreKitUnitTests/TestHelpers/AvailabilityChecks.swift b/Tests/StoreKitUnitTests/TestHelpers/AvailabilityChecks.swift index 5fd509abf8..4dfd5e03e5 100644 --- a/Tests/StoreKitUnitTests/TestHelpers/AvailabilityChecks.swift +++ b/Tests/StoreKitUnitTests/TestHelpers/AvailabilityChecks.swift @@ -37,6 +37,12 @@ enum AvailabilityChecks { } } + static func iOS14_3APIAvailableOrSkipTest() throws { + guard #available(iOS 14.3, tvOS 14.3, macOS 11.1, *) else { + throw XCTSkip("Required API is not available for this test.") + } + } + static func iOS15APIAvailableOrSkipTest() throws { guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) else { throw XCTSkip("Required API is not available for this test.") @@ -50,4 +56,10 @@ enum AvailabilityChecks { } } + static func skipIfTVOrWatchOS() throws { + #if os(watchOS) || os(tvOS) + throw XCTSkip("Test not for watchOS or tvOS") + #endif + } + } diff --git a/Tests/UnitTests/Attribution/AttributionPosterTests.swift b/Tests/UnitTests/Attribution/AttributionPosterTests.swift index c1bfafbe69..5b428bb936 100644 --- a/Tests/UnitTests/Attribution/AttributionPosterTests.swift +++ b/Tests/UnitTests/Attribution/AttributionPosterTests.swift @@ -306,6 +306,16 @@ class AdServicesAttributionPosterTests: BaseAttributionPosterTests { try AvailabilityChecks.iOS14APIAvailableOrSkipTest() } + func testAdServicesTokenToPostIfNeededReturnsNilIfAlreadySent() { + self.backend.stubbedPostAdServicesTokenCompletionResult = .success(()) + + expect(self.attributionPoster.adServicesTokenToPostIfNeeded).toNot(beNil()) + + self.attributionPoster.postAdServicesTokenIfNeeded() + + expect(self.attributionPoster.adServicesTokenToPostIfNeeded).to(beNil()) + } + func testPostAdServicesTokenIfNeededSkipsIfAlreadySent() { backend.stubbedPostAdServicesTokenCompletionResult = .success(()) diff --git a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift index 80ae0f7c01..c4225fe2f6 100644 --- a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift @@ -76,7 +76,8 @@ class BasePurchasesTests: TestCase { subscriberAttributesManager: self.subscriberAttributesManager) self.attribution = Attribution(subscriberAttributesManager: self.subscriberAttributesManager, currentUserProvider: self.identityManager, - attributionPoster: self.attributionPoster) + attributionPoster: self.attributionPoster, + systemInfo: self.systemInfo) self.mockOfflineEntitlementsManager = MockOfflineEntitlementsManager() self.customerInfoManager = CustomerInfoManager(offlineEntitlementsManager: self.mockOfflineEntitlementsManager, operationDispatcher: self.mockOperationDispatcher, diff --git a/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift b/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift index 50675aecfa..e08cd50988 100644 --- a/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift +++ b/Tests/UnitTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift @@ -97,6 +97,28 @@ class BackendSubscriberAttributesTests: TestCase { expect(self.mockHTTPClient.calls).toEventually(haveCount(1)) } + func testPostReceiptWithAdServicesToken() throws { + let token = "token" + + waitUntil { completion in + self.backend.post(receiptData: self.receiptData, + productData: nil, + transactionData: .init( + appUserID: self.appUserID, + presentedOfferingID: nil, + unsyncedAttributes: [:], + aadAttributionToken: token, + storefront: nil, + source: .init(isRestore: false, initiationSource: .restore) + ), + observerMode: false) { _ in + completion() + } + } + + expect(self.mockHTTPClient.calls).to(haveCount(1)) + } + func testPostReceiptWithSubscriberAttributesReturnsBadJson() throws { let subscriberAttributesByKey: [String: SubscriberAttribute] = [ subscriberAttribute1.key: subscriberAttribute1, diff --git a/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift b/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift index 9d302e58b5..288ef3512f 100644 --- a/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift +++ b/Tests/UnitTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift @@ -105,7 +105,8 @@ class PurchasesSubscriberAttributesTests: TestCase { subscriberAttributesManager: mockSubscriberAttributesManager) self.attribution = Attribution(subscriberAttributesManager: self.mockSubscriberAttributesManager, currentUserProvider: self.mockIdentityManager, - attributionPoster: self.mockAttributionPoster) + attributionPoster: self.mockAttributionPoster, + systemInfo: self.systemInfo) self.mockOfflineEntitlementsManager = MockOfflineEntitlementsManager() self.mockPurchasedProductsFetcher = MockPurchasedProductsFetcher() self.mockReceiptFetcher = MockReceiptFetcher( @@ -745,6 +746,42 @@ class PurchasesSubscriberAttributesTests: TestCase { mockIdentityManager.currentAppUserID } + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func testPostReceiptMarksAdServicesTokenSyncedIfBackendSuccessfullySynced() throws { + try AvailabilityChecks.iOS14_3APIAvailableOrSkipTest() + try AvailabilityChecks.skipIfTVOrWatchOS() + + self.setupPurchases() + + let token = "token" + + self.mockAttributionFetcher.adServicesTokenToReturn = token + self.attribution.enableAdServicesAttributionTokenCollection() + + let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: "com.product.id1")) + self.purchases.purchase(product: product) { (_, _, _, _) in } + + let transaction = MockTransaction() + transaction.mockPayment = self.mockStoreKit1Wrapper.payment! + transaction.mockState = .purchasing + + self.mockStoreKit1Wrapper.delegate?.storeKit1Wrapper(self.mockStoreKit1Wrapper, updatedTransaction: transaction) + + self.mockBackend.stubbedPostReceiptResult = .success(try CustomerInfo(data: self.emptyCustomerInfoData)) + + transaction.mockState = .purchased + self.mockStoreKit1Wrapper.delegate?.storeKit1Wrapper(self.mockStoreKit1Wrapper, updatedTransaction: transaction) + + expect(self.mockBackend.invokedPostReceiptData).toEventually(equal(true)) + expect(self.mockDeviceCache.invokedSetLatestNetworkAndAdvertisingIdsSent) == true + expect(self.mockDeviceCache.invokedSetLatestNetworkAndAdvertisingIdsSentCount) == 1 + expect(self.mockDeviceCache.invokedSetLatestNetworkAndAdvertisingIdsSentParameters) == ( + [.adServices: token], self.mockIdentityManager.currentAppUserID + ) + } + @available(iOS 12.2, macOS 10.14.4, watchOS 6.2, macCatalyst 13.0, tvOS 12.2, *) func testPostReceiptDoesntMarkSubscriberAttributesSyncedIfBackendNotSuccessfullySynced() { setupPurchases() diff --git a/Tests/UnitTests/SubscriberAttributes/SubscriberAttributesManagerTests.swift b/Tests/UnitTests/SubscriberAttributes/SubscriberAttributesManagerTests.swift index 04875a17b9..742ec5c39c 100644 --- a/Tests/UnitTests/SubscriberAttributes/SubscriberAttributesManagerTests.swift +++ b/Tests/UnitTests/SubscriberAttributes/SubscriberAttributesManagerTests.swift @@ -437,7 +437,7 @@ class SubscriberAttributesManagerTests: TestCase { expect(self.mockDeviceCache.invokedStoreSubscriberAttributesCount) == 0 } - // mark - sync attributes for all users + // MARK: - sync attributes for all users func testSyncAttributesForAllUsersSyncsForEveryUserWithUnsyncedAttributes() { let userID1 = "userID1" diff --git a/Tests/UnitTests/SubscriberAttributes/__Snapshots__/BackendSubscriberAttributesTests/iOS16-testPostReceiptWithAdServicesToken.1.json b/Tests/UnitTests/SubscriberAttributes/__Snapshots__/BackendSubscriberAttributesTests/iOS16-testPostReceiptWithAdServicesToken.1.json new file mode 100644 index 0000000000..2ef6718635 --- /dev/null +++ b/Tests/UnitTests/SubscriberAttributes/__Snapshots__/BackendSubscriberAttributesTests/iOS16-testPostReceiptWithAdServicesToken.1.json @@ -0,0 +1,23 @@ +{ + "headers" : { + "Authorization" : "Bearer the api key" + }, + "request" : { + "body" : { + "aad_attribution_token" : "token", + "app_user_id" : "abc123", + "attributes" : { + "$attConsentStatus" : { + "updated_at_ms" : 1678307200000, + "value" : "authorized" + } + }, + "fetch_token" : "YW4gYXdlc29tZSByZWNlaXB0", + "initiation_source" : "restore", + "is_restore" : false, + "observer_mode" : false + }, + "method" : "POST", + "url" : "https://api.revenuecat.com/v1/receipts" + } +} \ No newline at end of file