Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, in_app_purchase_storekit.PigeonError>) -> 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 = []
}
}

Expand Down Expand Up @@ -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)"
)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PurchaseDetails>[details],
);
}

return true;
}
await _skPaymentQueueWrapper.addPayment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
Map<String, Set<String>> eligibleWinBackOffers = <String, Set<String>>{};
Map<String, bool> eligibleIntroductoryOffers = <String, bool>{};

/// 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 = <String>{'123', '456'};
validProducts = <String, SK2Product>{};
Expand All @@ -375,6 +380,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
}
eligibleWinBackOffers = <String, Set<String>>{};
eligibleIntroductoryOffers = <String, bool>{};
simulatedPurchaseResult = SK2ProductPurchaseResultMessage.success;
}

SK2TransactionMessage createRestoredTransaction(
Expand Down Expand Up @@ -423,13 +429,18 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
SK2ProductPurchaseOptionsMessage? options,
}) {
lastPurchaseOptions = options;
final SK2TransactionMessage transaction = createPendingTransaction(id);

InAppPurchaseStoreKitPlatform.sk2TransactionObserver.onTransactionsUpdated(
<SK2TransactionMessage>[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(<SK2TransactionMessage>[transaction]);
}

return Future<SK2ProductPurchaseResultMessage>.value(
SK2ProductPurchaseResultMessage.success,
simulatedPurchaseResult,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<PurchaseDetails>>();
final Stream<List<PurchaseDetails>> stream =
iapStoreKitPlatform.purchaseStream;

late StreamSubscription<List<PurchaseDetails>> subscription;
subscription = stream.listen((
List<PurchaseDetails> purchaseDetailsList,
) {
completer.complete(purchaseDetailsList);
subscription.cancel();
});

final purchaseParam = AppStorePurchaseParam(
productDetails: AppStoreProduct2Details.fromSK2Product(
dummyProductWrapper,
),
applicationUserName: 'appName',
);
await iapStoreKitPlatform.buyNonConsumable(
purchaseParam: purchaseParam,
);

final List<PurchaseDetails> 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<List<PurchaseDetails>>();
final Stream<List<PurchaseDetails>> stream =
iapStoreKitPlatform.purchaseStream;

late StreamSubscription<List<PurchaseDetails>> subscription;
subscription = stream.listen((
List<PurchaseDetails> purchaseDetailsList,
) {
completer.complete(purchaseDetailsList);
subscription.cancel();
});

final purchaseParam = AppStorePurchaseParam(
productDetails: AppStoreProduct2Details.fromSK2Product(
dummyProductWrapper,
),
applicationUserName: 'appName',
);
await iapStoreKitPlatform.buyNonConsumable(
purchaseParam: purchaseParam,
);

final List<PurchaseDetails> 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<List<PurchaseDetails>>();
final Stream<List<PurchaseDetails>> stream =
iapStoreKitPlatform.purchaseStream;

late StreamSubscription<List<PurchaseDetails>> subscription;
subscription = stream.listen((
List<PurchaseDetails> purchaseDetailsList,
) {
completer.complete(purchaseDetailsList);
subscription.cancel();
});

final purchaseParam = AppStorePurchaseParam(
productDetails: AppStoreProduct2Details.fromSK2Product(
dummyProductWrapper,
),
applicationUserName: 'appName',
);
await iapStoreKitPlatform.buyNonConsumable(
purchaseParam: purchaseParam,
);

final List<PurchaseDetails> 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', () {
Expand Down