Skip to content

Commit 0105013

Browse files
authored
[in_app_purchase_storekit] Add restore purchases and receipts (#7964)
Add ability to restore purchases using StoreKit 2 apis.
1 parent 31859c0 commit 0105013

13 files changed

+238
-29
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.3.18+4
2+
3+
* Adds StoreKit 2 support for restoring purchases.
4+
15
## 0.3.18+3
26

37
* Updates Pigeon for non-nullable collection type support.

packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift renamed to packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchasePlugin+StoreKit2.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,43 @@ extension InAppPurchasePlugin: InAppPurchase2API {
9696
@MainActor in
9797
do {
9898
let transactionsMsgs = await rawTransactions().map {
99-
$0.convertToPigeon()
99+
$0.convertToPigeon(receipt: nil)
100100
}
101101
completion(.success(transactionsMsgs))
102102
}
103103
}
104104
}
105105

106+
func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void) {
107+
Task { [weak self] in
108+
guard let self = self else { return }
109+
do {
110+
var unverifiedPurchases: [UInt64: (receipt: String, error: Error?)] = [:]
111+
for await completedPurchase in Transaction.currentEntitlements {
112+
switch completedPurchase {
113+
case .verified(let purchase):
114+
self.sendTransactionUpdate(
115+
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)")
116+
case .unverified(let failedPurchase, let error):
117+
unverifiedPurchases[failedPurchase.id] = (
118+
receipt: completedPurchase.jwsRepresentation, error: error
119+
)
120+
}
121+
}
122+
if !unverifiedPurchases.isEmpty {
123+
completion(
124+
.failure(
125+
PigeonError(
126+
code: "storekit2_restore_failed",
127+
message:
128+
"This purchase could not be restored.",
129+
details: unverifiedPurchases)))
130+
}
131+
completion(.success(Void()))
132+
}
133+
}
134+
}
135+
106136
/// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish
107137
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
108138
Task {
@@ -136,9 +166,10 @@ extension InAppPurchasePlugin: InAppPurchase2API {
136166
}
137167

138168
/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
139-
func sendTransactionUpdate(transaction: Transaction) {
140-
let transactionMessage = transaction.convertToPigeon()
141-
transactionCallbackAPI?.onTransactionsUpdated(newTransaction: transactionMessage) { result in
169+
private func sendTransactionUpdate(transaction: Transaction, receipt: String? = nil) {
170+
let transactionMessage = transaction.convertToPigeon(receipt: receipt)
171+
self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
172+
result in
142173
switch result {
143174
case .success: break
144175
case .failure(let error):

packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/StoreKit2Translators.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ extension Product.PurchaseResult {
186186

187187
@available(iOS 15.0, macOS 12.0, *)
188188
extension Transaction {
189-
func convertToPigeon(restoring: Bool = false) -> SK2TransactionMessage {
189+
func convertToPigeon(receipt: String?) -> SK2TransactionMessage {
190190

191191
let dateFromatter: DateFormatter = DateFormatter()
192192
dateFromatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
@@ -198,7 +198,8 @@ extension Transaction {
198198
purchaseDate: dateFromatter.string(from: purchaseDate),
199199
purchasedQuantity: Int64(purchasedQuantity),
200200
appAccountToken: appAccountToken?.uuidString,
201-
restoring: restoring
201+
restoring: receipt != nil,
202+
receiptData: receipt
202203
)
203204
}
204205
}

packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/sk2_pigeon.g.swift

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.2), do not edit directly.
4+
// Autogenerated from Pigeon (v22.6.0), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
import Foundation
@@ -315,6 +315,7 @@ struct SK2TransactionMessage {
315315
var purchasedQuantity: Int64
316316
var appAccountToken: String? = nil
317317
var restoring: Bool
318+
var receiptData: String? = nil
318319
var error: SK2ErrorMessage? = nil
319320

320321
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -326,7 +327,8 @@ struct SK2TransactionMessage {
326327
let purchasedQuantity = pigeonVar_list[4] as! Int64
327328
let appAccountToken: String? = nilOrValue(pigeonVar_list[5])
328329
let restoring = pigeonVar_list[6] as! Bool
329-
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[7])
330+
let receiptData: String? = nilOrValue(pigeonVar_list[7])
331+
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[8])
330332

331333
return SK2TransactionMessage(
332334
id: id,
@@ -336,6 +338,7 @@ struct SK2TransactionMessage {
336338
purchasedQuantity: purchasedQuantity,
337339
appAccountToken: appAccountToken,
338340
restoring: restoring,
341+
receiptData: receiptData,
339342
error: error
340343
)
341344
}
@@ -348,6 +351,7 @@ struct SK2TransactionMessage {
348351
purchasedQuantity,
349352
appAccountToken,
350353
restoring,
354+
receiptData,
351355
error,
352356
]
353357
}
@@ -508,6 +512,7 @@ protocol InAppPurchase2API {
508512
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
509513
func startListeningToTransactions() throws
510514
func stopListeningToTransactions() throws
515+
func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void)
511516
}
512517

513518
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -645,12 +650,30 @@ class InAppPurchase2APISetup {
645650
} else {
646651
stopListeningToTransactionsChannel.setMessageHandler(nil)
647652
}
653+
let restorePurchasesChannel = FlutterBasicMessageChannel(
654+
name:
655+
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases\(channelSuffix)",
656+
binaryMessenger: binaryMessenger, codec: codec)
657+
if let api = api {
658+
restorePurchasesChannel.setMessageHandler { _, reply in
659+
api.restorePurchases { result in
660+
switch result {
661+
case .success:
662+
reply(wrapResult(nil))
663+
case .failure(let error):
664+
reply(wrapError(error))
665+
}
666+
}
667+
}
668+
} else {
669+
restorePurchasesChannel.setMessageHandler(nil)
670+
}
648671
}
649672
}
650673
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
651674
protocol InAppPurchase2CallbackAPIProtocol {
652675
func onTransactionsUpdated(
653-
newTransaction newTransactionArg: SK2TransactionMessage,
676+
newTransactions newTransactionsArg: [SK2TransactionMessage],
654677
completion: @escaping (Result<Void, PigeonError>) -> Void)
655678
}
656679
class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol {
@@ -664,14 +687,14 @@ class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol {
664687
return sk2_pigeonPigeonCodec.shared
665688
}
666689
func onTransactionsUpdated(
667-
newTransaction newTransactionArg: SK2TransactionMessage,
690+
newTransactions newTransactionsArg: [SK2TransactionMessage],
668691
completion: @escaping (Result<Void, PigeonError>) -> Void
669692
) {
670693
let channelName: String =
671694
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated\(messageChannelSuffix)"
672695
let channel = FlutterBasicMessageChannel(
673696
name: channelName, binaryMessenger: binaryMessenger, codec: codec)
674-
channel.sendMessage([newTransactionArg] as [Any?]) { response in
697+
channel.sendMessage([newTransactionsArg] as [Any?]) { response in
675698
guard let listResponse = response as? [Any?] else {
676699
completion(.failure(createConnectionError(withChannelName: channelName)))
677700
return

packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,28 @@ final class InAppPurchase2PluginTests: XCTestCase {
226226
}
227227
await fulfillment(of: [expectation], timeout: 5)
228228
}
229+
230+
func testRestoreProductSuccess() async throws {
231+
let purchaseExpectation = self.expectation(description: "Purchase request should succeed")
232+
let restoreExpectation = self.expectation(description: "Restore request should succeed")
233+
234+
plugin.purchase(id: "subscription_silver", options: nil) { result in
235+
switch result {
236+
case .success(_):
237+
purchaseExpectation.fulfill()
238+
case .failure(let error):
239+
XCTFail("Purchase should NOT fail. Failed with \(error)")
240+
}
241+
}
242+
plugin.restorePurchases { result in
243+
switch result {
244+
case .success():
245+
restoreExpectation.fulfill()
246+
case .failure(let error):
247+
XCTFail("Restore purchases should NOT fail. Failed with \(error)")
248+
}
249+
}
250+
251+
await fulfillment(of: [restoreExpectation, purchaseExpectation], timeout: 5)
252+
}
229253
}

packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform {
5151

5252
/// Callback handler for transaction status changes for StoreKit2 transactions
5353
@visibleForTesting
54-
static SK2TransactionObserverWrapper get sk2transactionObserver =>
54+
static SK2TransactionObserverWrapper get sk2TransactionObserver =>
5555
_sk2transactionObserver;
5656

5757
/// Registers this class as the default instance of [InAppPurchasePlatform].
@@ -149,6 +149,9 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform {
149149

150150
@override
151151
Future<void> restorePurchases({String? applicationUserName}) async {
152+
if (_useStoreKit2) {
153+
return SK2Transaction.restorePurchases();
154+
}
152155
return _sk1transactionObserver
153156
.restoreTransactions(
154157
queue: _skPaymentQueueWrapper,

packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.2), do not edit directly.
4+
// Autogenerated from Pigeon (v22.6.0), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// 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, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
77

@@ -303,6 +303,7 @@ class SK2TransactionMessage {
303303
this.purchasedQuantity = 1,
304304
this.appAccountToken,
305305
this.restoring = false,
306+
this.receiptData,
306307
this.error,
307308
});
308309

@@ -320,6 +321,8 @@ class SK2TransactionMessage {
320321

321322
bool restoring;
322323

324+
String? receiptData;
325+
323326
SK2ErrorMessage? error;
324327

325328
Object encode() {
@@ -331,6 +334,7 @@ class SK2TransactionMessage {
331334
purchasedQuantity,
332335
appAccountToken,
333336
restoring,
337+
receiptData,
334338
error,
335339
];
336340
}
@@ -345,7 +349,8 @@ class SK2TransactionMessage {
345349
purchasedQuantity: result[4]! as int,
346350
appAccountToken: result[5] as String?,
347351
restoring: result[6]! as bool,
348-
error: result[7] as SK2ErrorMessage?,
352+
receiptData: result[7] as String?,
353+
error: result[8] as SK2ErrorMessage?,
349354
);
350355
}
351356
}
@@ -685,12 +690,36 @@ class InAppPurchase2API {
685690
return;
686691
}
687692
}
693+
694+
Future<void> restorePurchases() async {
695+
final String pigeonVar_channelName =
696+
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases$pigeonVar_messageChannelSuffix';
697+
final BasicMessageChannel<Object?> pigeonVar_channel =
698+
BasicMessageChannel<Object?>(
699+
pigeonVar_channelName,
700+
pigeonChannelCodec,
701+
binaryMessenger: pigeonVar_binaryMessenger,
702+
);
703+
final List<Object?>? pigeonVar_replyList =
704+
await pigeonVar_channel.send(null) as List<Object?>?;
705+
if (pigeonVar_replyList == null) {
706+
throw _createConnectionError(pigeonVar_channelName);
707+
} else if (pigeonVar_replyList.length > 1) {
708+
throw PlatformException(
709+
code: pigeonVar_replyList[0]! as String,
710+
message: pigeonVar_replyList[1] as String?,
711+
details: pigeonVar_replyList[2],
712+
);
713+
} else {
714+
return;
715+
}
716+
}
688717
}
689718

690719
abstract class InAppPurchase2CallbackAPI {
691720
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
692721

693-
void onTransactionsUpdated(SK2TransactionMessage newTransaction);
722+
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions);
694723

695724
static void setUp(
696725
InAppPurchase2CallbackAPI? api, {
@@ -713,12 +742,12 @@ abstract class InAppPurchase2CallbackAPI {
713742
assert(message != null,
714743
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null.');
715744
final List<Object?> args = (message as List<Object?>?)!;
716-
final SK2TransactionMessage? arg_newTransaction =
717-
(args[0] as SK2TransactionMessage?);
718-
assert(arg_newTransaction != null,
719-
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null, expected non-null SK2TransactionMessage.');
745+
final List<SK2TransactionMessage>? arg_newTransactions =
746+
(args[0] as List<Object?>?)?.cast<SK2TransactionMessage>();
747+
assert(arg_newTransactions != null,
748+
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null, expected non-null List<SK2TransactionMessage>.');
720749
try {
721-
api.onTransactionsUpdated(arg_newTransaction!);
750+
api.onTransactionsUpdated(arg_newTransactions!);
722751
return wrapResponse(empty: true);
723752
} on PlatformException catch (e) {
724753
return wrapResponse(error: e);

packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ class SK2Transaction {
8686
static void stopListeningToTransactions() {
8787
_hostApi.stopListeningToTransactions();
8888
}
89+
90+
/// Restore previously completed purchases.
91+
static Future<void> restorePurchases() async {
92+
await _hostApi.restorePurchases();
93+
}
8994
}
9095

9196
extension on SK2TransactionMessage {
@@ -127,8 +132,9 @@ class SK2TransactionObserverWrapper implements InAppPurchase2CallbackAPI {
127132
final StreamController<List<PurchaseDetails>> transactionsCreatedController;
128133

129134
@override
130-
void onTransactionsUpdated(SK2TransactionMessage newTransaction) {
131-
transactionsCreatedController
132-
.add(<PurchaseDetails>[newTransaction.convertToDetails()]);
135+
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions) {
136+
transactionsCreatedController.add(newTransactions
137+
.map((SK2TransactionMessage e) => e.convertToDetails())
138+
.toList());
133139
}
134140
}

packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ class SK2TransactionMessage {
144144
this.purchasedQuantity = 1,
145145
this.appAccountToken,
146146
this.error,
147+
this.receiptData,
147148
this.restoring = false});
148149
final int id;
149150
final int originalId;
@@ -152,6 +153,7 @@ class SK2TransactionMessage {
152153
final int purchasedQuantity;
153154
final String? appAccountToken;
154155
final bool restoring;
156+
final String? receiptData;
155157
final SK2ErrorMessage? error;
156158
}
157159

@@ -189,9 +191,12 @@ abstract class InAppPurchase2API {
189191
void startListeningToTransactions();
190192

191193
void stopListeningToTransactions();
194+
195+
@async
196+
void restorePurchases();
192197
}
193198

194199
@FlutterApi()
195200
abstract class InAppPurchase2CallbackAPI {
196-
void onTransactionsUpdated(SK2TransactionMessage newTransaction);
201+
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions);
197202
}

packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: in_app_purchase_storekit
22
description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework.
33
repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22
5-
version: 0.3.18+3
5+
version: 0.3.18+4
66

77
environment:
88
sdk: ^3.3.0

0 commit comments

Comments
 (0)