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 20ae28ac9cfc..80f16bd17cff 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.4.11 + +* Adds latestTransaction to SK2Transaction wrapper and InAppPurchaseStoreKitPlatformAddition. + +## 0.4.10+1 + +* Fixes SK2Transaction to expose the real purchased quantity instead of defaulting to 1. + ## 0.4.10 * Clarifies `completePurchase` usage and the consequences of unfinished transactions in the README and API docstrings. 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 38e5f026615b..bf9ece061074 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 @@ -418,6 +418,29 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + func latestTransaction( + productId: String, completion: @escaping (Result) -> Void + ) { + Task { + let verificationResult = await Transaction.latest(for: productId) + guard let verificationResult = verificationResult else { + completion(.success(nil)) + return + } + let transaction: Transaction + switch verificationResult { + case .verified(let verifiedTransaction): + transaction = verifiedTransaction + case .unverified(let unverifiedTransaction, _): + transaction = unverifiedTransaction + } + completion( + .success( + transaction.convertToPigeon( + receipt: verificationResult.jwsRepresentation, status: .purchased))) + } + } + /// This Task listens to Transation.updates as shown here /// https://developer.apple.com/documentation/storekit/transaction/3851206-updates /// This function should be called as soon as the app starts to avoid missing any Transactions done outside of the app. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift index dc75c7dae891..82259cae43d6 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.10), do not edit directly. +// Autogenerated from Pigeon (v26.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -760,6 +760,8 @@ protocol InAppPurchase2API { func countryCode(completion: @escaping (Result) -> Void) func sync(completion: @escaping (Result) -> Void) func presentOfferCodeRedeemSheet(completion: @escaping (Result) -> Void) + func latestTransaction( + productId: String, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -1027,6 +1029,26 @@ class InAppPurchase2APISetup { } else { presentOfferCodeRedeemSheetChannel.setMessageHandler(nil) } + let latestTransactionChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.latestTransaction\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + latestTransactionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let productIdArg = args[0] as! String + api.latestTransaction(productId: productIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + latestTransactionChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m index 7be9e75eb517..ae7ef1937a2e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m @@ -733,11 +733,11 @@ void SetUpFIAInAppPurchaseAPIWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FIAGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(startProductRequestProductIdentifiers: - completion:)], - @"FIAInAppPurchaseAPI api (%@) doesn't respond to " - @"@selector(startProductRequestProductIdentifiers:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(startProductRequestProductIdentifiers:completion:)], + @"FIAInAppPurchaseAPI api (%@) doesn't respond to " + @"@selector(startProductRequestProductIdentifiers:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSArray *arg_productIdentifiers = GetNullableObjectAtIndex(args, 0); diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform_addition.dart index 9d4169a4cb0e..a9825420e8e2 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform_addition.dart @@ -18,6 +18,13 @@ class InAppPurchaseStoreKitPlatformAddition extends InAppPurchasePlatformAdditio return AppStore().sync(); } + /// Gets the customer's most recent transaction for a product. + /// + /// This is only supported when StoreKit 2 is enabled. + Future latestTransaction(String productId) { + return SK2Transaction.latestTransaction(productId); + } + /// Present Code Redemption Sheet. /// /// Available on devices running iOS 14 and iPadOS 14 and later. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart index cbbdbb5aef80..0d8cc431067a 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.10), do not edit directly. +// Autogenerated from Pigeon (v26.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -1023,6 +1023,25 @@ class InAppPurchase2API { _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); } + + Future latestTransaction(String productId) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.latestTransaction$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([productId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as SK2TransactionMessage?; + } } abstract class InAppPurchase2CallbackAPI { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart index 660242745942..ff6126539a90 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart @@ -116,6 +116,14 @@ class SK2Transaction { static Future restorePurchases() async { await hostApi2.restorePurchases(); } + + /// A wrapper around [Transaction.latest(for:)] + /// https://developer.apple.com/documentation/storekit/transaction-latest_for__ + /// Gets the customer's most recent transaction for an In-App Purchase. + static Future latestTransaction(String productId) async { + final SK2TransactionMessage? msg = await hostApi2.latestTransaction(productId); + return msg?.convertFromPigeon(); + } } extension on SK2TransactionMessage { @@ -126,6 +134,7 @@ extension on SK2TransactionMessage { productId: productId, purchaseDate: purchaseDate ?? '', expirationDate: expirationDate, + quantity: purchasedQuantity, appAccountToken: appAccountToken, receiptData: receiptData, jsonRepresentation: jsonRepresentation, diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart index fca52d2a402e..4eca7f91896e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart @@ -261,6 +261,9 @@ abstract class InAppPurchase2API { @async void presentOfferCodeRedeemSheet(); + + @async + SK2TransactionMessage? latestTransaction(String productId); } @FlutterApi() 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 4a3746860f68..441ec10e2f05 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.10 +version: 0.4.11 environment: sdk: ^3.10.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 33af9503f736..22e0ed52655c 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 @@ -319,6 +319,8 @@ class FakeStoreKit2Platform implements InAppPurchase2API { late bool testTransactionFail; late int testTransactionCancel; late List finishedTransactions; + List transactionsList = []; + List unfinishedTransactionsList = []; PlatformException? queryProductException; bool isListenerRegistered = false; @@ -348,6 +350,28 @@ class FakeStoreKit2Platform implements InAppPurchase2API { eligibleWinBackOffers = >{}; eligibleIntroductoryOffers = {}; simulatedPurchaseResult = SK2ProductPurchaseResultMessage.success; + transactionsList = [ + SK2TransactionMessage( + id: 123, + originalId: 123, + productId: 'product_id', + purchaseDate: '12-12', + purchasedQuantity: 2, + status: SK2PurchaseStatusMessage.purchased, + ), + ]; + unfinishedTransactionsList = [ + SK2TransactionMessage( + id: 123, + originalId: 123, + productId: 'product_id', + purchaseDate: '12-12', + receiptData: 'fake_jws_representation', + appAccountToken: 'fake_app_account_token', + purchasedQuantity: 3, + status: SK2PurchaseStatusMessage.purchased, + ), + ]; } SK2TransactionMessage createRestoredTransaction( @@ -449,30 +473,12 @@ class FakeStoreKit2Platform implements InAppPurchase2API { @override Future> transactions() { - return Future>.value([ - SK2TransactionMessage( - id: 123, - originalId: 123, - productId: 'product_id', - purchaseDate: '12-12', - status: SK2PurchaseStatusMessage.purchased, - ), - ]); + return Future>.value(transactionsList); } @override Future> unfinishedTransactions() { - return Future>.value([ - SK2TransactionMessage( - id: 123, - originalId: 123, - productId: 'product_id', - purchaseDate: '12-12', - receiptData: 'fake_jws_representation', - appAccountToken: 'fake_app_account_token', - status: SK2PurchaseStatusMessage.purchased, - ), - ]); + return Future>.value(unfinishedTransactionsList); } @override @@ -550,6 +556,16 @@ class FakeStoreKit2Platform implements InAppPurchase2API { @override Future presentOfferCodeRedeemSheet() async {} + + @override + Future latestTransaction(String productId) async { + for (final SK2TransactionMessage tx in transactionsList) { + if (tx.productId == productId) { + return tx; + } + } + return null; + } } SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) { 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 883a6c468698..72101235e20a 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 @@ -655,5 +655,52 @@ void main() { expect(transactions.first.appAccountToken, isNotNull); expect(transactions.first.appAccountToken, 'fake_app_account_token'); }); + + test('should expose purchased quantity in unfinished transactions', () async { + final List transactions = await SK2Transaction.unfinishedTransactions(); + + expect(transactions, isNotEmpty); + expect(transactions.first.quantity, 3); + }); + }); + + group('transactions', () { + test('should return transactions', () async { + final List transactions = await SK2Transaction.transactions(); + + expect(transactions, isNotEmpty); + expect(transactions.first.id, '123'); + expect(transactions.first.productId, 'product_id'); + expect(transactions.first.quantity, 2); + }); + }); + + group('latestTransaction', () { + test('should return latest transaction when product has one', () async { + final SK2Transaction? transaction = await SK2Transaction.latestTransaction('product_id'); + + expect(transaction, isNotNull); + expect(transaction!.id, '123'); + expect(transaction.productId, 'product_id'); + expect(transaction.quantity, 2); + }); + + test('should return null when product has no transactions', () async { + final SK2Transaction? transaction = await SK2Transaction.latestTransaction( + 'non_existent_product', + ); + + expect(transaction, isNull); + }); + + test('should return latest transaction via platform addition', () async { + final addition = + InAppPurchasePlatformAddition.instance! as InAppPurchaseStoreKitPlatformAddition; + final SK2Transaction? transaction = await addition.latestTransaction('product_id'); + + expect(transaction, isNotNull); + expect(transaction!.id, '123'); + expect(transaction.productId, 'product_id'); + }); }); }