From 901e74d0a36eb0b1c4926b8d6b093a613a48b4f8 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 22 Jul 2024 21:03:18 +0200 Subject: [PATCH 1/3] Call AppTransaction.refresh() in certain scenarios if AppTransaction.shared is invalid: - After a purchase - When calling restorePurchaes This will always show an authentication prompt --- .../Purchases/PurchasesOrchestrator.swift | 24 +++--- .../Purchases/TransactionPoster.swift | 2 +- .../StoreKit2/SK2AppTransaction.swift | 4 +- .../StoreKit2TransactionFetcher.swift | 73 +++++++++++++------ .../PurchasesOrchestratorCommonTests.swift | 6 +- .../PurchasesOrchestratorSK1Tests.swift | 12 +-- .../PurchasesOrchestratorSK2Tests.swift | 23 ++++-- .../MockStoreKit2TransactionFetcher.swift | 21 ++++-- 8 files changed, 109 insertions(+), 56 deletions(-) diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 6ddd4a36ed..79c2b19db3 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -223,14 +223,14 @@ final class PurchasesOrchestrator { } func restorePurchases(completion: (@Sendable (Result) -> Void)?) { - self.syncPurchases(receiptRefreshPolicy: .always, + self.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore, completion: completion) } func syncPurchases(completion: (@Sendable (Result) -> Void)? = nil) { - self.syncPurchases(receiptRefreshPolicy: .never, + self.syncPurchases(receiptRefreshAllowed: false, isRestore: allowSharingAppStoreAccount, initiationSource: .restore, completion: completion) @@ -1022,7 +1022,7 @@ private extension PurchasesOrchestrator { } } - func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, + func syncPurchases(receiptRefreshAllowed: Bool, isRestore: Bool, initiationSource: ProductRequestData.InitiationSource, completion: (@Sendable (Result) -> Void)?) { @@ -1034,11 +1034,12 @@ private extension PurchasesOrchestrator { if self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable, #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { - self.syncPurchasesSK2(isRestore: isRestore, + self.syncPurchasesSK2(refreshPolicy: receiptRefreshAllowed ? .onlyIfEmpty : .never, + isRestore: isRestore, initiationSource: initiationSource, completion: completion) } else { - self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshPolicy, + self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshAllowed ? .always : .never, isRestore: isRestore, initiationSource: initiationSource, completion: completion) @@ -1114,7 +1115,8 @@ private extension PurchasesOrchestrator { // swiftlint:disable function_body_length @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - private func syncPurchasesSK2(isRestore: Bool, + private func syncPurchasesSK2(refreshPolicy: AppTransactionRefreshPolicy, + isRestore: Bool, initiationSource: ProductRequestData.InitiationSource, completion: (@Sendable (Result) -> Void)?) { let currentAppUserID = self.appUserID @@ -1123,7 +1125,6 @@ private extension PurchasesOrchestrator { self.attribution.unsyncedAdServicesToken { adServicesToken in _ = Task { let transaction = await self.transactionFetcher.firstVerifiedTransaction - let appTransactionJWS = await self.transactionFetcher.appTransactionJWS guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else { // No transactions are present. If we have the originalPurchaseDate and originalApplicationVersion @@ -1139,6 +1140,9 @@ private extension PurchasesOrchestrator { return } + let appTransactionJWS = await self.transactionFetcher.appTransactionJWS( + refreshPolicy: refreshPolicy) + self.backend.post(receipt: .empty, productData: nil, transactionData: .init(appUserID: currentAppUserID, @@ -1157,6 +1161,7 @@ private extension PurchasesOrchestrator { } let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation) + let appTransactionJWS = await self.transactionFetcher.appTransactionJWS(refreshPolicy: refreshPolicy) self.createProductRequestData(with: transaction.productIdentifier) { productRequestData in let transactionData: PurchasedTransactionData = .init( @@ -1511,11 +1516,12 @@ extension PurchasesOrchestrator { .get() } - func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy, + // Only used internally in tests + func syncPurchases(receiptRefreshAllowed: Bool, isRestore: Bool, initiationSource: ProductRequestData.InitiationSource) async throws -> CustomerInfo { return try await Async.call { completion in - self.syncPurchases(receiptRefreshPolicy: receiptRefreshPolicy, + self.syncPurchases(receiptRefreshAllowed: receiptRefreshAllowed, isRestore: isRestore, initiationSource: initiationSource, completion: completion) diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 478dde946a..2ba2ca4fb5 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -106,7 +106,7 @@ final class TransactionPoster: TransactionPosterType { switch result { case .success(let encodedReceipt): self.product(with: productIdentifier) { product in - self.transactionFetcher.appTransactionJWS { appTransaction in + self.transactionFetcher.appTransactionJWS(refreshPolicy: .onlyIfEmpty) { appTransaction in self.postReceipt(transaction: transaction, purchasedTransactionData: data, receipt: encodedReceipt, diff --git a/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift b/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift index f60af69b33..f53ec098e1 100644 --- a/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift +++ b/Sources/Purchasing/StoreKit2/SK2AppTransaction.swift @@ -18,13 +18,15 @@ import StoreKit internal struct SK2AppTransaction { @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) - init(appTransaction: AppTransaction) { + init(appTransaction: AppTransaction, jwsRepresentation: String) { self.bundleId = appTransaction.bundleID self.originalApplicationVersion = appTransaction.originalAppVersion self.originalPurchaseDate = appTransaction.originalPurchaseDate self.environment = .init(environment: appTransaction.environment) + self.jwsRepresentation = jwsRepresentation } + let jwsRepresentation: String let bundleId: String let originalApplicationVersion: String? let originalPurchaseDate: Date? diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift index 7d32e65f7c..37c9e89326 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift @@ -14,6 +14,16 @@ import Foundation import StoreKit +/// Determines the behavior when fetching the AppTransaction +enum AppTransactionRefreshPolicy { + + // Calls refresh() only if AppTransaction.shared returns empty + case onlyIfEmpty + // Never calls refresh() + case never + +} + protocol StoreKit2TransactionFetcherType: Sendable { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) @@ -31,9 +41,9 @@ protocol StoreKit2TransactionFetcherType: Sendable { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) var firstVerifiedTransaction: StoreTransaction? { get async } - var appTransactionJWS: String? { get async } + func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy) async -> String? - func appTransactionJWS(_ completionHandler: @escaping (String?) -> Void) + func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy, _ completionHandler: @escaping (String?) -> Void) } @@ -85,7 +95,7 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { func fetchReceipt(containing transaction: StoreTransactionType) async -> StoreKit2Receipt { async let transactions = verifiedTransactions(containing: transaction) async let subscriptionStatuses = subscriptionStatusBySubscriptionGroupId - async let appTransaction = appTransaction + async let appTransaction = appTransaction(refreshPolicy: .onlyIfEmpty) return await .init( environment: .xcode, @@ -110,13 +120,11 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { /// /// - Returns: A `String` containing the JWS representation of the app transaction, /// or `nil` if the feature is unavailable on the current platform version. - var appTransactionJWS: String? { - get async { - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - return try? await AppTransaction.shared.jwsRepresentation - } else { - return nil - } + func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy) async -> String? { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return await appTransaction(refreshPolicy: refreshPolicy)?.jwsRepresentation + } else { + return nil } } @@ -130,10 +138,10 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { /// if the feature is unavailable on the current platform version. /// - Parameter result: A `String?` containing the JWS representation of the app transaction, /// or `nil` if unavailable. - func appTransactionJWS(_ completion: @escaping (String?) -> Void) { + func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy, _ completion: @escaping (String?) -> Void) { Async.call(with: completion) { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - return try? await AppTransaction.shared.jwsRepresentation + return await self.appTransaction(refreshPolicy: refreshPolicy)?.jwsRepresentation } else { return nil } @@ -181,7 +189,8 @@ extension StoreKit.VerificationResult where SignedType == StoreKit.AppTransactio var verifiedAppTransaction: SK2AppTransaction? { switch self { - case let .verified(transaction): return .init(appTransaction: transaction) + case let .verified(transaction): + return .init(appTransaction: transaction, jwsRepresentation: self.jwsRepresentation) case let .unverified(transaction, error): Logger.warn( Strings.storeKit.sk2_unverified_transaction(identifier: transaction.bundleID, error) @@ -279,17 +288,12 @@ extension StoreKit2TransactionFetcher { } } - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - private var appTransaction: SK2AppTransaction? { + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + private var refreshedAppTransaction: SK2AppTransaction? { get async { do { - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - let transaction = try await StoreKit.AppTransaction.shared - return transaction.verifiedAppTransaction - } else { - Logger.warn(Strings.storeKit.sk2_app_transaction_unavailable) - return nil - } + let transaction = try await StoreKit.AppTransaction.refresh() + return transaction.verifiedAppTransaction } catch { Logger.warn(Strings.storeKit.sk2_error_fetching_app_transaction(error)) return nil @@ -297,4 +301,29 @@ extension StoreKit2TransactionFetcher { } } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func appTransaction(refreshPolicy: AppTransactionRefreshPolicy) async -> SK2AppTransaction? { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + do { + let transaction = try await StoreKit.AppTransaction.shared + if let transaction = transaction.verifiedAppTransaction { + return transaction + } else { + return await refreshedAppTransaction + } + } catch { + switch refreshPolicy { + case .onlyIfEmpty: + return await refreshedAppTransaction + case .never: + Logger.warn(Strings.storeKit.sk2_error_fetching_app_transaction(error)) + return nil + } + } + } else { + Logger.warn(Strings.storeKit.sk2_app_transaction_unavailable) + return nil + } + } + } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorCommonTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorCommonTests.swift index 55ae5626e1..9a44c5563f 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorCommonTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorCommonTests.swift @@ -166,7 +166,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests { func testRestorePurchasesDoesNotLogWarningIfAllowSharingAppStoreAccountIsNotDefined() async throws { self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo - _ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never, + _ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false, isRestore: false, initiationSource: .restore) @@ -182,7 +182,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests { self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo - _ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never, + _ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false, isRestore: false, initiationSource: .restore) @@ -198,7 +198,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests { self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo - _ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never, + _ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false, isRestore: false, initiationSource: .restore) diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift index fedbea1d5c..2989919866 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift @@ -582,7 +582,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo self.receiptParser.stubbedReceiptHasTransactionsResult = false - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) expect(self.backend.invokedPostReceiptData).to(beFalse()) @@ -596,7 +596,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = CustomerInfo.missingOriginalPurchaseDate self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) @@ -617,7 +617,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr func testSyncPurchasesPostsReceipt() async throws { self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) @@ -631,7 +631,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = nil self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) @@ -652,7 +652,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr func testSyncPurchasesCallsSuccessDelegateMethod() async throws { self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) @@ -665,7 +665,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr self.backend.stubbedPostReceiptResult = .failure(expectedError) do { - _ = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + _ = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) fail("Expected error") diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift index ee1632319c..5a1681fe6b 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift @@ -570,10 +570,12 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.productsManager.stubbedSk2StoreProductsResult = .success([product]) self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty + expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataParameters?.data) == .jws(transaction.jwsRepresentation!) expect(customerInfo) == mockCustomerInfo @@ -620,9 +622,10 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.productsManager.stubbedSk2StoreProductsResult = .success([product]) self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataParameters?.data) == .sk2receipt(receipt) @@ -634,11 +637,12 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.mockTransactionFetcher.stubbedFirstVerifiedTransaction = nil self.customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) expect(self.backend.invokedPostReceiptData).to(beFalse()) + expect(self.mockTransactionFetcher.receivedRefreshPolicy).to(beNil()) expect(self.customerInfoManager.invokedCachedCustomerInfo).to(beTrue()) expect(self.customerInfoManager.invokedCachedCustomerInfoCount) == 1 @@ -653,12 +657,13 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = CustomerInfo.missingOriginalPurchaseDate self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) expect(self.customerInfoManager.invokedCachedCustomerInfo).to(beTrue()) expect(self.customerInfoManager.invokedCachedCustomerInfoCount) == 1 + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataCount) == 1 @@ -680,12 +685,13 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = CustomerInfo.missingOriginalApplicationVersion self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) expect(self.customerInfoManager.invokedCachedCustomerInfo).to(beTrue()) expect(self.customerInfoManager.invokedCachedCustomerInfoCount) == 1 + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataCount) == 1 @@ -707,12 +713,13 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.customerInfoManager.stubbedCachedCustomerInfoResult = nil self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: true, initiationSource: .restore) expect(self.customerInfoManager.invokedCachedCustomerInfo).to(beTrue()) expect(self.customerInfoManager.invokedCachedCustomerInfoCount) == 1 + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataCount) == 1 @@ -732,7 +739,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo) - let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) @@ -748,7 +755,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.backend.stubbedPostReceiptResult = .failure(expectedError) do { - _ = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always, + _ = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true, isRestore: false, initiationSource: .purchase) fail("Expected error") diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift index ec48058545..62a32988dc 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionFetcher.swift @@ -22,6 +22,7 @@ final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { private let _stubbedHasPendingConsumablePurchase: Atomic = false private let _stubbedReceipt: Atomic = .init(nil) private let _stubbedAppTransactionJWS: Atomic = .init(nil) + private let _receivedRefreshPolicy: Atomic = .init(nil) var stubbedUnfinishedTransactions: [StoreTransaction] { get { return self._stubbedUnfinishedTransactions.value } @@ -55,6 +56,11 @@ final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { } } + var receivedRefreshPolicy: AppTransactionRefreshPolicy? { + get { return self._receivedRefreshPolicy.value } + set { self._receivedRefreshPolicy.value = newValue } + } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func fetchReceipt(containing transaction: StoreTransactionType) async -> StoreKit2Receipt { return self.stubbedReceipt! @@ -74,14 +80,17 @@ final class MockStoreKit2TransactionFetcher: StoreKit2TransactionFetcherType { } } - var appTransactionJWS: String? { - get async { - return self.stubbedAppTransactionJWS - } + func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy) async -> String? { + self.receivedRefreshPolicy = refreshPolicy + return self.stubbedAppTransactionJWS } - func appTransactionJWS(_ completion: @escaping (String?) -> Void) { - completion(self.stubbedAppTransactionJWS) + func appTransactionJWS( + refreshPolicy: AppTransactionRefreshPolicy, + _ completionHandler: @escaping (String?) -> Void + ) { + self.receivedRefreshPolicy = refreshPolicy + completionHandler(self.stubbedAppTransactionJWS) } // MARK: - From 27762597f1a8cb3d2b83b9490863e5c77df1c468 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 22 Jul 2024 21:16:18 +0200 Subject: [PATCH 2/3] only refresh AppTransaction after purchase --- Sources/Purchasing/Purchases/TransactionPoster.swift | 5 ++++- Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 2ba2ca4fb5..525e72f3b2 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -106,7 +106,10 @@ final class TransactionPoster: TransactionPosterType { switch result { case .success(let encodedReceipt): self.product(with: productIdentifier) { product in - self.transactionFetcher.appTransactionJWS(refreshPolicy: .onlyIfEmpty) { appTransaction in + // Only allow refreshing the AppTransaction if the source was a user action (e.g. purchase) + let refreshPolicy: AppTransactionRefreshPolicy = + data.source.initiationSource == .purchase ? .onlyIfEmpty : .never + self.transactionFetcher.appTransactionJWS(refreshPolicy: refreshPolicy) { appTransaction in self.postReceipt(transaction: transaction, purchasedTransactionData: data, receipt: encodedReceipt, diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift index 5a1681fe6b..a33c2602b1 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorSK2Tests.swift @@ -54,6 +54,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr package: package, promotionalOffer: nil) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(self.backend.invokedPostReceiptDataCount) == 1 expect(self.backend.invokedPostReceiptData).to(beTrue()) expect(self.backend.invokedPostReceiptDataParameters?.data) == .jws(transaction.jwsRepresentation!) @@ -230,6 +231,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr self.productsManager.stubbedSk2StoreProductsResult = .success([product]) let result = try await orchestrator.purchase(sk2Product: product, package: nil, promotionalOffer: nil) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .onlyIfEmpty expect(result.transaction) == transaction.verifiedStoreTransaction expect(self.backend.invokedPostReceiptDataCount) == 1 expect(self.backend.invokedPostReceiptDataParameters?.productData).toNot(beNil()) @@ -468,6 +470,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr updatedTransaction: transaction ) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .never expect(transaction.finishInvoked) == true expect(self.backend.invokedPostReceiptData) == true expect(self.backend.invokedPostReceiptDataParameters?.transactionData.source.isRestore) == false @@ -543,6 +546,7 @@ class PurchasesOrchestratorSK2Tests: BasePurchasesOrchestratorTests, PurchasesOr updatedTransaction: transaction ) + expect(self.mockTransactionFetcher.receivedRefreshPolicy) == .never expect(transaction.finishInvoked) == false expect(self.backend.invokedPostReceiptData) == true expect(self.backend.invokedPostReceiptDataParameters?.transactionData.source.isRestore) == false From b28bc136ef504798b255bc1fc9fd248a0e23b999 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 22 Jul 2024 21:55:02 +0200 Subject: [PATCH 3/3] clarify comment --- Sources/Purchasing/Purchases/TransactionPoster.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 525e72f3b2..d1f6b00ab2 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -106,7 +106,8 @@ final class TransactionPoster: TransactionPosterType { switch result { case .success(let encodedReceipt): self.product(with: productIdentifier) { product in - // Only allow refreshing the AppTransaction if the source was a user action (e.g. purchase) + // Only allow refreshing the AppTransaction if the source was a user action + // (e.g. purchase) to prevent showing a login prompt unnecessarily let refreshPolicy: AppTransactionRefreshPolicy = data.source.initiationSource == .purchase ? .onlyIfEmpty : .never self.transactionFetcher.appTransactionJWS(refreshPolicy: refreshPolicy) { appTransaction in