Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[in_app_purchase_storekit] Add restore purchases and receipts #7964

Merged
merged 11 commits into from
Nov 7, 2024
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.3.18+4

* Adds StoreKit 2 support for restoring purchases.

## 0.3.18+3

* Updates Pigeon for non-nullable collection type support.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,43 @@ extension InAppPurchasePlugin: InAppPurchase2API {
@MainActor in
do {
let transactionsMsgs = await rawTransactions().map {
$0.convertToPigeon()
$0.convertToPigeon(receipt: nil)
}
completion(.success(transactionsMsgs))
}
}
}

func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void) {
Task { [weak self] in
guard let self = self else { return }
do {
var unverifiedPurchases: [UInt64: (receipt: String, error: Error?)] = [:]
for await completedPurchase in Transaction.currentEntitlements {
switch completedPurchase {
case .verified(let purchase):
self.sendTransactionUpdate(
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)")
case .unverified(let failedPurchase, let error):
unverifiedPurchases[failedPurchase.id] = (
receipt: completedPurchase.jwsRepresentation, error: error
)
}
}
if !unverifiedPurchases.isEmpty {
completion(
.failure(
PigeonError(
code: "storekit2_restore_failed",
message:
"This purchase could not be restored.",
details: unverifiedPurchases)))
}
completion(.success(Void()))
}
}
}

/// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
Task {
Expand Down Expand Up @@ -136,9 +166,10 @@ extension InAppPurchasePlugin: InAppPurchase2API {
}

/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
func sendTransactionUpdate(transaction: Transaction) {
let transactionMessage = transaction.convertToPigeon()
transactionCallbackAPI?.onTransactionsUpdated(newTransaction: transactionMessage) { result in
private func sendTransactionUpdate(transaction: Transaction, receipt: String? = nil) {
let transactionMessage = transaction.convertToPigeon(receipt: receipt)
self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
result in
switch result {
case .success: break
case .failure(let error):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ extension Product.PurchaseResult {

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

let dateFromatter: DateFormatter = DateFormatter()
dateFromatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
Expand All @@ -198,7 +198,8 @@ extension Transaction {
purchaseDate: dateFromatter.string(from: purchaseDate),
purchasedQuantity: Int64(purchasedQuantity),
appAccountToken: appAccountToken?.uuidString,
restoring: restoring
restoring: receipt != nil,
receiptData: receipt
)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Autogenerated from Pigeon (v22.4.2), do not edit directly.
// Autogenerated from Pigeon (v22.6.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon

import Foundation
Expand Down Expand Up @@ -315,6 +315,7 @@ struct SK2TransactionMessage {
var purchasedQuantity: Int64
var appAccountToken: String? = nil
var restoring: Bool
var receiptData: String? = nil
var error: SK2ErrorMessage? = nil

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

return SK2TransactionMessage(
id: id,
Expand All @@ -336,6 +338,7 @@ struct SK2TransactionMessage {
purchasedQuantity: purchasedQuantity,
appAccountToken: appAccountToken,
restoring: restoring,
receiptData: receiptData,
error: error
)
}
Expand All @@ -348,6 +351,7 @@ struct SK2TransactionMessage {
purchasedQuantity,
appAccountToken,
restoring,
receiptData,
error,
]
}
Expand Down Expand Up @@ -508,6 +512,7 @@ protocol InAppPurchase2API {
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
func startListeningToTransactions() throws
func stopListeningToTransactions() throws
func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void)
}

/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
Expand Down Expand Up @@ -645,12 +650,30 @@ class InAppPurchase2APISetup {
} else {
stopListeningToTransactionsChannel.setMessageHandler(nil)
}
let restorePurchasesChannel = FlutterBasicMessageChannel(
name:
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases\(channelSuffix)",
binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
restorePurchasesChannel.setMessageHandler { _, reply in
api.restorePurchases { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
restorePurchasesChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol InAppPurchase2CallbackAPIProtocol {
func onTransactionsUpdated(
newTransaction newTransactionArg: SK2TransactionMessage,
newTransactions newTransactionsArg: [SK2TransactionMessage],
completion: @escaping (Result<Void, PigeonError>) -> Void)
}
class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol {
Expand All @@ -664,14 +687,14 @@ class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol {
return sk2_pigeonPigeonCodec.shared
}
func onTransactionsUpdated(
newTransaction newTransactionArg: SK2TransactionMessage,
newTransactions newTransactionsArg: [SK2TransactionMessage],
completion: @escaping (Result<Void, PigeonError>) -> Void
) {
let channelName: String =
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(
name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage([newTransactionArg] as [Any?]) { response in
channel.sendMessage([newTransactionsArg] as [Any?]) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,28 @@ final class InAppPurchase2PluginTests: XCTestCase {
}
await fulfillment(of: [expectation], timeout: 5)
}

func testRestoreProductSuccess() async throws {
let purchaseExpectation = self.expectation(description: "Purchase request should succeed")
let restoreExpectation = self.expectation(description: "Restore request 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)")
}
}
plugin.restorePurchases { result in
switch result {
case .success():
restoreExpectation.fulfill()
case .failure(let error):
XCTFail("Restore purchases should NOT fail. Failed with \(error)")
}
}

await fulfillment(of: [restoreExpectation, purchaseExpectation], timeout: 5)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform {

/// Callback handler for transaction status changes for StoreKit2 transactions
@visibleForTesting
static SK2TransactionObserverWrapper get sk2transactionObserver =>
static SK2TransactionObserverWrapper get sk2TransactionObserver =>
_sk2transactionObserver;

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

@override
Future<void> restorePurchases({String? applicationUserName}) async {
if (_useStoreKit2) {
return SK2Transaction.restorePurchases();
}
return _sk1transactionObserver
.restoreTransactions(
queue: _skPaymentQueueWrapper,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Autogenerated from Pigeon (v22.4.2), do not edit directly.
// Autogenerated from Pigeon (v22.6.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, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

Expand Down Expand Up @@ -303,6 +303,7 @@ class SK2TransactionMessage {
this.purchasedQuantity = 1,
this.appAccountToken,
this.restoring = false,
this.receiptData,
this.error,
});

Expand All @@ -320,6 +321,8 @@ class SK2TransactionMessage {

bool restoring;

String? receiptData;

SK2ErrorMessage? error;

Object encode() {
Expand All @@ -331,6 +334,7 @@ class SK2TransactionMessage {
purchasedQuantity,
appAccountToken,
restoring,
receiptData,
error,
];
}
Expand All @@ -345,7 +349,8 @@ class SK2TransactionMessage {
purchasedQuantity: result[4]! as int,
appAccountToken: result[5] as String?,
restoring: result[6]! as bool,
error: result[7] as SK2ErrorMessage?,
receiptData: result[7] as String?,
error: result[8] as SK2ErrorMessage?,
);
}
}
Expand Down Expand Up @@ -685,12 +690,36 @@ class InAppPurchase2API {
return;
}
}

Future<void> restorePurchases() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_channel.send(null) as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}

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

void onTransactionsUpdated(SK2TransactionMessage newTransaction);
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions);

static void setUp(
InAppPurchase2CallbackAPI? api, {
Expand All @@ -713,12 +742,12 @@ abstract class InAppPurchase2CallbackAPI {
assert(message != null,
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null.');
final List<Object?> args = (message as List<Object?>?)!;
final SK2TransactionMessage? arg_newTransaction =
(args[0] as SK2TransactionMessage?);
assert(arg_newTransaction != null,
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null, expected non-null SK2TransactionMessage.');
final List<SK2TransactionMessage>? arg_newTransactions =
(args[0] as List<Object?>?)?.cast<SK2TransactionMessage>();
assert(arg_newTransactions != null,
'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null, expected non-null List<SK2TransactionMessage>.');
try {
api.onTransactionsUpdated(arg_newTransaction!);
api.onTransactionsUpdated(arg_newTransactions!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ class SK2Transaction {
static void stopListeningToTransactions() {
_hostApi.stopListeningToTransactions();
}

/// Restore previously completed purchases.
static Future<void> restorePurchases() async {
await _hostApi.restorePurchases();
}
}

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

@override
void onTransactionsUpdated(SK2TransactionMessage newTransaction) {
transactionsCreatedController
.add(<PurchaseDetails>[newTransaction.convertToDetails()]);
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions) {
transactionsCreatedController.add(newTransactions
.map((SK2TransactionMessage e) => e.convertToDetails())
.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class SK2TransactionMessage {
this.purchasedQuantity = 1,
this.appAccountToken,
this.error,
this.receiptData,
this.restoring = false});
final int id;
final int originalId;
Expand All @@ -152,6 +153,7 @@ class SK2TransactionMessage {
final int purchasedQuantity;
final String? appAccountToken;
final bool restoring;
final String? receiptData;
final SK2ErrorMessage? error;
}

Expand Down Expand Up @@ -189,9 +191,12 @@ abstract class InAppPurchase2API {
void startListeningToTransactions();

void stopListeningToTransactions();

@async
void restorePurchases();
}

@FlutterApi()
abstract class InAppPurchase2CallbackAPI {
void onTransactionsUpdated(SK2TransactionMessage newTransaction);
void onTransactionsUpdated(List<SK2TransactionMessage> newTransactions);
}
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.3.18+3
version: 0.3.18+4

environment:
sdk: ^3.3.0
Expand Down
Loading