diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index b3c9557f21b..ed8451d219d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,5 +1,7 @@ -## NEXT +## 0.4.7+1 +* Fixes StoreKit 2 purchase flow to send cancelled/pending/unverified results to `purchaseStream`. +* Fixes StoreKit 2 consumable purchases being reported as restored, which left transactions unfinished and blocked repeat buys. * Fixes Xcode 26.2 analyzer warnings in example app tests. ## 0.4.7 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index f6d72996517..6f9d92ec4a9 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -261,7 +261,8 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch completedPurchase { case .verified(let purchase): self.sendTransactionUpdate( - transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)") + transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)", + isRestoring: true) case .unverified(let failedPurchase, let error): unverifiedPurchases[failedPurchase.id] = ( receipt: completedPurchase.jwsRepresentation, error: error @@ -354,8 +355,10 @@ extension InAppPurchasePlugin: InAppPurchase2API { } /// Sends an transaction back to Dart. Access these transactions with `purchaseStream` - private func sendTransactionUpdate(transaction: Transaction, receipt: String? = nil) { - let transactionMessage = transaction.convertToPigeon(receipt: receipt) + private func sendTransactionUpdate( + transaction: Transaction, receipt: String? = nil, isRestoring: Bool = false + ) { + let transactionMessage = transaction.convertToPigeon(receipt: receipt, isRestoring: isRestoring) Task { @MainActor in self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) { result in diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift index 19f18688e20..387512eb442 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift @@ -192,7 +192,7 @@ extension Product.PurchaseResult { @available(iOS 15.0, macOS 12.0, *) extension Transaction { - func convertToPigeon(receipt: String?) -> SK2TransactionMessage { + func convertToPigeon(receipt: String?, isRestoring: Bool = false) -> SK2TransactionMessage { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" @@ -205,7 +205,7 @@ extension Transaction { expirationDate: expirationDate.map { dateFormatter.string(from: $0) }, purchasedQuantity: Int64(purchasedQuantity), appAccountToken: appAccountToken?.uuidString, - restoring: receipt != nil, + restoring: isRestoring, receiptData: receipt, jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self) ) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 2a34c959bc8..c991b9c64fe 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -8,12 +8,20 @@ import XCTest @testable import in_app_purchase_storekit final class FakeIAP2Callback: InAppPurchase2CallbackAPIProtocol { + var receivedTransactions: [in_app_purchase_storekit.SK2TransactionMessage] = [] + func onTransactionsUpdated( newTransactions newTransactionsArg: [in_app_purchase_storekit.SK2TransactionMessage], completion: @escaping (Result) -> Void ) { // We should only write to a flutter channel from the main thread. XCTAssertTrue(Thread.isMainThread) + receivedTransactions.append(contentsOf: newTransactionsArg) + completion(.success(())) + } + + func reset() { + receivedTransactions = [] } } @@ -514,4 +522,82 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [expectation], timeout: 5) } + // MARK: - Tests for restoring flag correctness (Commit 2fcc79db3) + + func testNewPurchaseHasRestoringFalse() async throws { + let purchaseExpectation = self.expectation(description: "Purchase should succeed") + + let callback = plugin.transactionCallbackAPI as! FakeIAP2Callback + callback.reset() + + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success: + purchaseExpectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [purchaseExpectation], timeout: 5) + + // Give some time for the transaction callback to be called + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertFalse( + callback.receivedTransactions.isEmpty, "Should have received transaction updates") + for transaction in callback.receivedTransactions { + XCTAssertFalse( + transaction.restoring, + "New purchase should have restoring = false, but got restoring = \(transaction.restoring)" + ) + } + } + + func testRestoredPurchaseHasRestoringTrue() async throws { + // First make a purchase + let purchaseExpectation = self.expectation(description: "Purchase should succeed") + + plugin.purchase(id: "subscription_silver", options: nil) { result in + switch result { + case .success: + purchaseExpectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [purchaseExpectation], timeout: 5) + + // Reset callback to clear purchase transactions + let callback = plugin.transactionCallbackAPI as! FakeIAP2Callback + callback.reset() + + // Now restore purchases + let restoreExpectation = self.expectation(description: "Restore should succeed") + + plugin.restorePurchases { result in + switch result { + case .success: + restoreExpectation.fulfill() + case .failure(let error): + XCTFail("Restore should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [restoreExpectation], timeout: 5) + + // Give some time for the transaction callback to be called + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertFalse( + callback.receivedTransactions.isEmpty, "Should have received restored transaction updates") + for transaction in callback.receivedTransactions { + XCTAssertTrue( + transaction.restoring, + "Restored purchase should have restoring = true, but got restoring = \(transaction.restoring)" + ) + } + } + } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart index 05c0262027e..ae37d28e340 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart @@ -178,11 +178,38 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { ); } - await SK2Product.purchase( + final SK2ProductPurchaseResult result = await SK2Product.purchase( purchaseParam.productDetails.id, options: options, ); + // For userCancelled and pending results, manually send update to the stream + // since native side only sends transaction for success cases (both verified and unverified). + // Note: unverified is handled by native side as it's part of .success case in StoreKit. + if (result == SK2ProductPurchaseResult.userCancelled || + result == SK2ProductPurchaseResult.pending) { + final PurchaseStatus status = + result == SK2ProductPurchaseResult.userCancelled + ? PurchaseStatus.canceled + : PurchaseStatus.pending; + + final details = SK2PurchaseDetails( + productID: purchaseParam.productDetails.id, + purchaseID: null, + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: kIAPSource, + ), + transactionDate: null, + status: status, + ); + + _sk2transactionObserver.transactionsCreatedController.add( + [details], + ); + } + return true; } await _skPaymentQueueWrapper.addPayment( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index 5882c914518..b52c552a820 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.7 +version: 0.4.7+1 environment: sdk: ^3.9.0 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart index a188d3f7ece..d724bcf380d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart @@ -358,6 +358,11 @@ class FakeStoreKit2Platform implements InAppPurchase2API { Map> eligibleWinBackOffers = >{}; Map eligibleIntroductoryOffers = {}; + /// Simulates purchase result for testing non-success scenarios. + /// Set to userCancelled, pending, or unverified to test those cases. + SK2ProductPurchaseResultMessage simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.success; + void reset() { validProductIDs = {'123', '456'}; validProducts = {}; @@ -375,6 +380,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API { } eligibleWinBackOffers = >{}; eligibleIntroductoryOffers = {}; + simulatedPurchaseResult = SK2ProductPurchaseResultMessage.success; } SK2TransactionMessage createRestoredTransaction( @@ -423,13 +429,18 @@ class FakeStoreKit2Platform implements InAppPurchase2API { SK2ProductPurchaseOptionsMessage? options, }) { lastPurchaseOptions = options; - final SK2TransactionMessage transaction = createPendingTransaction(id); - InAppPurchaseStoreKitPlatform.sk2TransactionObserver.onTransactionsUpdated( - [transaction], - ); + // Native side sends transaction update for success cases (both verified and unverified) + // Only userCancelled and pending don't send transaction updates + if (simulatedPurchaseResult == SK2ProductPurchaseResultMessage.success || + simulatedPurchaseResult == SK2ProductPurchaseResultMessage.unverified) { + final SK2TransactionMessage transaction = createPendingTransaction(id); + InAppPurchaseStoreKitPlatform.sk2TransactionObserver + .onTransactionsUpdated([transaction]); + } + return Future.value( - SK2ProductPurchaseResultMessage.success, + simulatedPurchaseResult, ); } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart index 35191b98a20..42655a168fb 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart @@ -343,6 +343,116 @@ void main() { expect(lastPurchaseOptions.quantity, 1); }, ); + + test( + 'user cancelled purchase should emit canceled status to purchaseStream', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.userCancelled; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final purchaseParam = AppStorePurchaseParam( + productDetails: AppStoreProduct2Details.fromSK2Product( + dummyProductWrapper, + ), + applicationUserName: 'appName', + ); + await iapStoreKitPlatform.buyNonConsumable( + purchaseParam: purchaseParam, + ); + + final List result = await completer.future; + expect(result.length, 1); + expect(result.first.productID, dummyProductWrapper.id); + expect(result.first.status, PurchaseStatus.canceled); + expect(result.first.pendingCompletePurchase, false); + }, + ); + + test( + 'pending purchase should emit pending status to purchaseStream', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.pending; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final purchaseParam = AppStorePurchaseParam( + productDetails: AppStoreProduct2Details.fromSK2Product( + dummyProductWrapper, + ), + applicationUserName: 'appName', + ); + await iapStoreKitPlatform.buyNonConsumable( + purchaseParam: purchaseParam, + ); + + final List result = await completer.future; + expect(result.length, 1); + expect(result.first.productID, dummyProductWrapper.id); + expect(result.first.status, PurchaseStatus.pending); + expect(result.first.pendingCompletePurchase, false); + }, + ); + + test( + 'unverified purchase should receive transaction from native side', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.unverified; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final purchaseParam = AppStorePurchaseParam( + productDetails: AppStoreProduct2Details.fromSK2Product( + dummyProductWrapper, + ), + applicationUserName: 'appName', + ); + await iapStoreKitPlatform.buyNonConsumable( + purchaseParam: purchaseParam, + ); + + final List result = await completer.future; + expect(result.length, 1); + expect(result.first.productID, dummyProductWrapper.id); + // Native side sends the transaction for unverified case + // The transaction comes with purchased status from native side + expect(result.first.status, PurchaseStatus.purchased); + expect(result.first.pendingCompletePurchase, true); + }, + ); }); group('restore purchases', () {