From 77d1f774e224c8f8fa6088bca0f9899528033887 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 12 Jun 2023 14:45:39 -0700 Subject: [PATCH] Always listen to updates --- Sources/Logging/Strings/PurchaseStrings.swift | 6 +- Sources/Logging/Strings/StoreKitStrings.swift | 5 + .../Purchases/PurchasesOrchestrator.swift | 3 +- .../StoreKit1/PaymentQueueWrapper.swift | 2 +- .../StoreKit2TransactionListener.swift | 14 ++- .../PurchasesOrchestratorTests.swift | 8 +- .../StoreKit2TransactionListenerTests.swift | 114 +++++++++++++----- ...StoreKit2TransactionListenerDelegate.swift | 7 +- 8 files changed, 113 insertions(+), 46 deletions(-) diff --git a/Sources/Logging/Strings/PurchaseStrings.swift b/Sources/Logging/Strings/PurchaseStrings.swift index 4a81302b73..7a30aa5b63 100644 --- a/Sources/Logging/Strings/PurchaseStrings.swift +++ b/Sources/Logging/Strings/PurchaseStrings.swift @@ -74,7 +74,7 @@ enum PurchaseStrings { case begin_refund_multiple_active_entitlements case begin_refund_customer_info_error(entitlementID: String?) case missing_cached_customer_info - case sk2_transactions_update_received_transaction(StoreTransaction) + case sk2_transactions_update_received_transaction(productID: String) case sk1_purchase_too_slow case sk2_purchase_too_slow case payment_queue_wrapper_delegate_call_sk1_enabled @@ -282,8 +282,8 @@ extension PurchaseStrings: CustomStringConvertible { case .missing_cached_customer_info: return "Requested a cached CustomerInfo but it's not available." - case let .sk2_transactions_update_received_transaction(transaction): - return "StoreKit.Transaction.updates: received transaction for product '\(transaction.productIdentifier)'" + case let .sk2_transactions_update_received_transaction(productID): + return "StoreKit.Transaction.updates: received transaction for product '\(productID)'" case .sk1_purchase_too_slow: return "StoreKit 1 purchase took longer than expected" diff --git a/Sources/Logging/Strings/StoreKitStrings.swift b/Sources/Logging/Strings/StoreKitStrings.swift index 893ef2d4ea..95cf7cef36 100644 --- a/Sources/Logging/Strings/StoreKitStrings.swift +++ b/Sources/Logging/Strings/StoreKitStrings.swift @@ -57,6 +57,8 @@ enum StoreKitStrings { case sk2_product_request_too_slow + case sk2_observing_transaction_updates + #if DEBUG case sk1_wrapper_notifying_delegate_of_existing_transactions(count: Int) @@ -140,6 +142,9 @@ extension StoreKitStrings: CustomStringConvertible { case .sk2_product_request_too_slow: return "StoreKit 2 product request took longer than expected" + case .sk2_observing_transaction_updates: + return "Observing StoreKit.Transaction.updates" + #if DEBUG case let .sk1_wrapper_notifying_delegate_of_existing_transactions(count): return "StoreKit1Wrapper: sending delegate \(count) existing transactions " + diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 51a0d49400..b4c4e6f93d 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -130,8 +130,9 @@ final class PurchasesOrchestrator { storeKit2TransactionListener.delegate = self storeKit2StorefrontListener.delegate = self + storeKit2TransactionListener.listenForTransactions() + if systemInfo.storeKit2Setting == .enabledForCompatibleDevices { - storeKit2TransactionListener.listenForTransactions() storeKit2StorefrontListener.listenForStorefrontChanges() } } diff --git a/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift b/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift index 1a77ff1cc6..931c1232bc 100644 --- a/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift +++ b/Sources/Purchasing/StoreKit1/PaymentQueueWrapper.swift @@ -129,7 +129,7 @@ extension PaymentQueueWrapper: SKPaymentQueueDelegate { extension PaymentQueueWrapper: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - // Ignored. Either `StoreKit1Wrapper` will handle this, or `StoreKit2TransactionListener` if `SK2` is enabled. + // Ignored. Either `StoreKit1Wrapper` or `StoreKit2TransactionListener` will handle this. } #if !os(watchOS) diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift index c597120e46..2d9ede4be1 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift @@ -24,6 +24,9 @@ protocol StoreKit2TransactionListenerDelegate: AnyObject { } +/// Observes `StoreKit.Transaction.updates`, which receives: +/// - Updates from outside `Product.purchase()`, like renewals +/// - Purchases from SwiftUI's paywalls. @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) class StoreKit2TransactionListener { @@ -31,6 +34,7 @@ class StoreKit2TransactionListener { typealias ResultData = (userCancelled: Bool, transaction: SK2Transaction?) private(set) var taskHandle: Task? + weak var delegate: StoreKit2TransactionListenerDelegate? init(delegate: StoreKit2TransactionListenerDelegate?) { @@ -38,6 +42,8 @@ class StoreKit2TransactionListener { } func listenForTransactions() { + Logger.debug(Strings.storeKit.sk2_observing_transaction_updates) + self.taskHandle?.cancel() self.taskHandle = Task(priority: .utility) { [weak self] in for await result in StoreKit.Transaction.updates { @@ -103,13 +109,13 @@ private extension StoreKit2TransactionListener { case let .verified(verifiedTransaction): if fromTransactionUpdate, let delegate = self.delegate { - let transaction = StoreTransaction(sk2Transaction: verifiedTransaction) - - Logger.debug(Strings.purchase.sk2_transactions_update_received_transaction(transaction)) + Logger.debug(Strings.purchase.sk2_transactions_update_received_transaction( + productID: verifiedTransaction.productID + )) try await delegate.storeKit2TransactionListener( self, - updatedTransaction: transaction + updatedTransaction: StoreTransaction(sk2Transaction: verifiedTransaction) ) } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index 0c56155818..3f78a08165 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -967,7 +967,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func testDoesNotListenForSK2TransactionsWithSK2Disabled() throws { + func testListensForSK2TransactionsWithSK2Disabled() throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() let transactionListener = MockStoreKit2TransactionListener() @@ -977,11 +977,11 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil)) - expect(transactionListener.invokedListenForTransactions) == false + expect(transactionListener.invokedListenForTransactions) == true } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func testDoesNotListenForSK2TransactionsWithSK2EnabledOnlyForOptimizations() throws { + func testListensForSK2TransactionsWithSK2EnabledOnlyForOptimizations() throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() let transactionListener = MockStoreKit2TransactionListener() @@ -991,7 +991,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { self.setUpOrchestrator(storeKit2TransactionListener: transactionListener, storeKit2StorefrontListener: StoreKit2StorefrontListener(delegate: nil)) - expect(transactionListener.invokedListenForTransactions) == false + expect(transactionListener.invokedListenForTransactions) == true } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) diff --git a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift index bcd0ee5c4c..6c9606b609 100644 --- a/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift +++ b/Tests/StoreKitUnitTests/StoreKit2/StoreKit2TransactionListenerTests.swift @@ -17,22 +17,32 @@ import StoreKit import StoreKitTest import XCTest -@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +// swiftlint:disable type_name + @MainActor -class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +class StoreKit2TransactionListenerBaseTests: StoreKitConfigTestCase { - private var listener: StoreKit2TransactionListener! = nil - private var delegate: MockStoreKit2TransactionListenerDelegate! = nil + fileprivate var listener: StoreKit2TransactionListener! = nil + fileprivate var delegate: MockStoreKit2TransactionListenerDelegate! = nil - override func setUpWithError() throws { - try super.setUpWithError() + override func setUp() async throws { + try await super.setUp() try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + // Unfinished transactions before beginning the test might lead to false positives / negatives + await self.verifyNoUnfinishedTransactions() + self.delegate = .init() self.listener = .init(delegate: self.delegate) } +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +class StoreKit2TransactionListenerTests: StoreKit2TransactionListenerBaseTests { + func testStopsListeningToTransactions() throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() @@ -113,30 +123,6 @@ class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { try await self.verifyUnfinishedTransaction(withId: purchasedTransaction.id) } - func testPurchasingInTheAppDoesNotNotifyDelegate() async throws { - self.listener.listenForTransactions() - - _ = try await self.fetchSk2Product().purchase() - - try await self.verifyTransactionsWereNotUpdated() - } - - func testPurchasingOutsideTheAppNotifiesDelegate() throws { - self.listener.listenForTransactions() - - try self.testSession.buyProduct(productIdentifier: Self.productID) - - expect(self.delegate.invokedTransactionUpdated).toEventually(beTrue()) - } - - func testNotifiesDelegateForExistingTransactions() throws { - try self.testSession.buyProduct(productIdentifier: Self.productID) - - self.listener.listenForTransactions() - - expect(self.delegate.invokedTransactionUpdated).toEventually(beTrue()) - } - func testHandlePurchaseResultDoesNotFinishTransaction() async throws { let (purchaseResult, _, purchasedTransaction) = try await self.purchase() @@ -189,8 +175,60 @@ class StoreKit2TransactionListenerTests: StoreKitConfigTestCase { } +// MARK: - Transaction.updates tests + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -private extension StoreKit2TransactionListenerTests { +class StoreKit2TransactionListenerTransactionUpdatesTests: StoreKit2TransactionListenerBaseTests { + + func testPurchasingInTheAppDoesNotNotifyDelegate() async throws { + self.listener.listenForTransactions() + + try await self.simulateAnyPurchase(finishTransaction: true) + try await self.verifyTransactionsWereNotUpdated() + } + + func testPurchasingOutsideTheAppNotifiesDelegate() throws { + self.listener.listenForTransactions() + + try self.testSession.buyProduct(productIdentifier: Self.productID) + + expect(self.delegate.invokedTransactionUpdated).toEventually(beTrue()) + } + + func testNotifiesDelegateForExistingTransactions() throws { + try self.testSession.buyProduct(productIdentifier: Self.productID) + + self.listener.listenForTransactions() + + expect(self.delegate.invokedTransactionUpdated).toEventually(beTrue()) + } + + @available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) + func testNotifiesDelegateForRenewals() async throws { + let logger = TestLogHandler() + + try await self.simulateAnyPurchase(finishTransaction: true) + + self.listener.listenForTransactions() + + try? self.testSession.forceRenewalOfSubscription(productIdentifier: Self.productID) + + try await self.waitForTransactionUpdated() + + expect(self.delegate.updatedTransactions) + .to(containElementSatisfying { transaction in + transaction.productIdentifier == Self.productID + }) + + logger.verifyMessageWasLogged(Strings.purchase.sk2_transactions_update_received_transaction( + productID: Self.productID + )) + } + +} + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +private extension StoreKit2TransactionListenerBaseTests { private enum Error: Swift.Error { case invalidResult(Product.PurchaseResult) @@ -220,4 +258,18 @@ private extension StoreKit2TransactionListenerTests { expect(self.delegate.invokedTransactionUpdated) == false } + func waitForTransactionUpdated( + file: FileString = #fileID, + line: UInt = #line + ) async throws { + try await asyncWait( + until: { await self.delegate.invokedTransactionUpdated == true }, + timeout: .seconds(4), + pollInterval: .milliseconds(100), + description: "Transaction update", + file: file, + line: line + ) + } + } diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift index d8cf23d71e..cdfe0e14d1 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListenerDelegate.swift @@ -19,8 +19,8 @@ final class MockStoreKit2TransactionListenerDelegate: StoreKit2TransactionListen var invokedTransactionUpdated: Bool { return self._invokedTransactionUpdated.value } var updatedTransactions: [StoreTransactionType] { return self._updatedTransactions.value } - private var _invokedTransactionUpdated: Atomic = false - private var _updatedTransactions: Atomic<[StoreTransactionType]> = .init([]) + private let _invokedTransactionUpdated: Atomic = false + private let _updatedTransactions: Atomic<[StoreTransactionType]> = .init([]) func storeKit2TransactionListener( _ listener: StoreKit2TransactionListener, @@ -31,3 +31,6 @@ final class MockStoreKit2TransactionListenerDelegate: StoreKit2TransactionListen } } + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension MockStoreKit2TransactionListenerDelegate: Sendable {}