Skip to content

Commit

Permalink
[in_app_purchase_storekit] Add restore purchases and receipts (#7964)
Browse files Browse the repository at this point in the history
Add ability to restore purchases using StoreKit 2 apis.
  • Loading branch information
LouiseHsu authored Nov 7, 2024
1 parent 31859c0 commit 0105013
Show file tree
Hide file tree
Showing 13 changed files with 238 additions and 29 deletions.
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

0 comments on commit 0105013

Please sign in to comment.