blob: 361cb3af2d9e215497e3e25e0f44de29f0c7cf7c [file] [log] [blame]
// 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.
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../store_kit_wrappers.dart';
import '../channel.dart';
import '../in_app_purchase_storekit_platform.dart';
part 'sk_payment_queue_wrapper.g.dart';
/// A wrapper around
/// [`SKPaymentQueue`](https://developer.apple.com/documentation/storekit/skpaymentqueue?language=objc).
///
/// The payment queue contains payment related operations. It communicates with
/// the App Store and presents a user interface for the user to process and
/// authorize payments.
///
/// Full information on using `SKPaymentQueue` and processing purchases is
/// available at the [In-App Purchase Programming
/// Guide](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Introduction.html#//apple_ref/doc/uid/TP40008267).
class SKPaymentQueueWrapper {
/// Returns the default payment queue.
///
/// We do not support instantiating a custom payment queue, hence the
/// singleton. However, you can override the observer.
factory SKPaymentQueueWrapper() {
return _singleton;
}
SKPaymentQueueWrapper._();
static final SKPaymentQueueWrapper _singleton = SKPaymentQueueWrapper._();
SKPaymentQueueDelegateWrapper? _paymentQueueDelegate;
SKTransactionObserverWrapper? _observer;
/// Calls [`-[SKPaymentQueue transactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506026-transactions?language=objc)
Future<List<SKPaymentTransactionWrapper>> transactions() async {
return _getTransactionList((await channel
.invokeListMethod<dynamic>('-[SKPaymentQueue transactions]'))!);
}
/// Calls [`-[SKPaymentQueue canMakePayments:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506139-canmakepayments?language=objc).
static Future<bool> canMakePayments() async =>
(await channel
.invokeMethod<bool>('-[SKPaymentQueue canMakePayments:]')) ??
false;
/// Sets an observer to listen to all incoming transaction events.
///
/// This should be called and set as soon as the app launches in order to
/// avoid missing any purchase updates from the App Store. See the
/// documentation on StoreKit's [`-[SKPaymentQueue
/// addTransactionObserver:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506042-addtransactionobserver?language=objc).
void setTransactionObserver(SKTransactionObserverWrapper observer) {
_observer = observer;
channel.setMethodCallHandler(handleObserverCallbacks);
}
/// Instructs the iOS implementation to register a transaction observer and
/// start listening to it.
///
/// Call this method when the first listener is subscribed to the
/// [InAppPurchaseStoreKitPlatform.purchaseStream].
Future<void> startObservingTransactionQueue() => channel
.invokeMethod<void>('-[SKPaymentQueue startObservingTransactionQueue]');
/// Instructs the iOS implementation to remove the transaction observer and
/// stop listening to it.
///
/// Call this when there are no longer any listeners subscribed to the
/// [InAppPurchaseStoreKitPlatform.purchaseStream].
Future<void> stopObservingTransactionQueue() => channel
.invokeMethod<void>('-[SKPaymentQueue stopObservingTransactionQueue]');
/// Sets an implementation of the [SKPaymentQueueDelegateWrapper].
///
/// The [SKPaymentQueueDelegateWrapper] can be used to inform iOS how to
/// finish transactions when the storefront changes or if the price consent
/// sheet should be displayed when the price of a subscription has changed. If
/// no delegate is registered iOS will fallback to it's default configuration.
/// See the documentation on StoreKite's [`-[SKPaymentQueue delegate:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc).
///
/// When set to `null` the payment queue delegate will be removed and the
/// default behaviour will apply (see [documentation](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc)).
Future<void> setDelegate(SKPaymentQueueDelegateWrapper? delegate) async {
if (delegate == null) {
await channel.invokeMethod<void>('-[SKPaymentQueue removeDelegate]');
paymentQueueDelegateChannel.setMethodCallHandler(null);
} else {
await channel.invokeMethod<void>('-[SKPaymentQueue registerDelegate]');
paymentQueueDelegateChannel
.setMethodCallHandler(handlePaymentQueueDelegateCallbacks);
}
_paymentQueueDelegate = delegate;
}
/// Posts a payment to the queue.
///
/// This sends a purchase request to the App Store for confirmation.
/// Transaction updates will be delivered to the set
/// [SkTransactionObserverWrapper].
///
/// A couple preconditions need to be met before calling this method.
///
/// - At least one [SKTransactionObserverWrapper] should have been added to
/// the payment queue using [addTransactionObserver].
/// - The [payment.productIdentifier] needs to have been previously fetched
/// using [SKRequestMaker.startProductRequest] so that a valid `SKProduct`
/// has been cached in the platform side already. Because of this
/// [payment.productIdentifier] cannot be hardcoded.
///
/// This method calls StoreKit's [`-[SKPaymentQueue addPayment:]`]
/// (https://developer.apple.com/documentation/storekit/skpaymentqueue/1506036-addpayment?preferredLanguage=occ).
///
/// Also see [sandbox
/// testing](https://developer.apple.com/apple-pay/sandbox-testing/).
Future<void> addPayment(SKPaymentWrapper payment) async {
assert(_observer != null,
'[in_app_purchase]: Trying to add a payment without an observer. One must be set using `SkPaymentQueueWrapper.setTransactionObserver` before the app launches.');
final Map<String, dynamic> requestMap = payment.toMap();
await channel.invokeMethod<void>(
'-[InAppPurchasePlugin addPayment:result:]',
requestMap,
);
}
/// Finishes a transaction and removes it from the queue.
///
/// This method should be called after the given [transaction] has been
/// succesfully processed and its content has been delivered to the user.
/// Transaction status updates are propagated to [SkTransactionObserver].
///
/// This will throw a Platform exception if [transaction.transactionState] is
/// [SKPaymentTransactionStateWrapper.purchasing].
///
/// This method calls StoreKit's [`-[SKPaymentQueue
/// finishTransaction:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506003-finishtransaction?language=objc).
Future<void> finishTransaction(
SKPaymentTransactionWrapper transaction) async {
final Map<String, String?> requestMap = transaction.toFinishMap();
await channel.invokeMethod<void>(
'-[InAppPurchasePlugin finishTransaction:result:]',
requestMap,
);
}
/// Restore previously purchased transactions.
///
/// Use this to load previously purchased content on a new device.
///
/// This call triggers purchase updates on the set
/// [SKTransactionObserverWrapper] for previously made transactions. This will
/// invoke [SKTransactionObserverWrapper.restoreCompletedTransactions],
/// [SKTransactionObserverWrapper.paymentQueueRestoreCompletedTransactionsFinished],
/// and [SKTransactionObserverWrapper.updatedTransaction]. These restored
/// transactions need to be marked complete with [finishTransaction] once the
/// content is delivered, like any other transaction.
///
/// The `applicationUserName` should match the original
/// [SKPaymentWrapper.applicationUsername] used in [addPayment].
/// If no `applicationUserName` was used, `applicationUserName` should be null.
///
/// This method either triggers [`-[SKPayment
/// restoreCompletedTransactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506123-restorecompletedtransactions?language=objc)
/// or [`-[SKPayment restoreCompletedTransactionsWithApplicationUsername:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1505992-restorecompletedtransactionswith?language=objc)
/// depending on whether the `applicationUserName` is set.
Future<void> restoreTransactions({String? applicationUserName}) async {
await channel.invokeMethod<void>(
'-[InAppPurchasePlugin restoreTransactions:result:]',
applicationUserName);
}
/// Present Code Redemption Sheet
///
/// Use this to allow Users to enter and redeem Codes
///
/// This method triggers [`-[SKPayment
/// presentCodeRedemptionSheet]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3566726-presentcoderedemptionsheet?language=objc)
Future<void> presentCodeRedemptionSheet() async {
await channel.invokeMethod<void>(
'-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]');
}
/// Shows the price consent sheet if the user has not yet responded to a
/// subscription price change.
///
/// Use this function when you have registered a [SKPaymentQueueDelegateWrapper]
/// (using the [setDelegate] method) and returned `false` when the
/// `SKPaymentQueueDelegateWrapper.shouldShowPriceConsent()` method was called.
///
/// See documentation of StoreKit's [`-[SKPaymentQueue showPriceConsentIfNeeded]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded?language=objc).
Future<void> showPriceConsentIfNeeded() async {
await channel
.invokeMethod<void>('-[SKPaymentQueue showPriceConsentIfNeeded]');
}
/// Triage a method channel call from the platform and triggers the correct observer method.
///
/// This method is public for testing purposes only and should not be used
/// outside this class.
@visibleForTesting
Future<dynamic> handleObserverCallbacks(MethodCall call) async {
assert(_observer != null,
'[in_app_purchase]: (Fatal)The observer has not been set but we received a purchase transaction notification. Please ensure the observer has been set using `setTransactionObserver`. Make sure the observer is added right at the App Launch.');
final SKTransactionObserverWrapper observer = _observer!;
switch (call.method) {
case 'updatedTransactions':
{
final List<SKPaymentTransactionWrapper> transactions =
_getTransactionList(call.arguments as List<dynamic>);
return Future<void>(() {
observer.updatedTransactions(transactions: transactions);
});
}
case 'removedTransactions':
{
final List<SKPaymentTransactionWrapper> transactions =
_getTransactionList(call.arguments as List<dynamic>);
return Future<void>(() {
observer.removedTransactions(transactions: transactions);
});
}
case 'restoreCompletedTransactionsFailed':
{
final SKError error = SKError.fromJson(Map<String, dynamic>.from(
call.arguments as Map<dynamic, dynamic>));
return Future<void>(() {
observer.restoreCompletedTransactionsFailed(error: error);
});
}
case 'paymentQueueRestoreCompletedTransactionsFinished':
{
return Future<void>(() {
observer.paymentQueueRestoreCompletedTransactionsFinished();
});
}
case 'shouldAddStorePayment':
{
final Map<Object?, Object?> arguments =
call.arguments as Map<Object?, Object?>;
final SKPaymentWrapper payment = SKPaymentWrapper.fromJson(
(arguments['payment']! as Map<dynamic, dynamic>)
.cast<String, dynamic>());
final SKProductWrapper product = SKProductWrapper.fromJson(
(arguments['product']! as Map<dynamic, dynamic>)
.cast<String, dynamic>());
return Future<void>(() {
if (observer.shouldAddStorePayment(
payment: payment, product: product) ==
true) {
SKPaymentQueueWrapper().addPayment(payment);
}
});
}
default:
break;
}
throw PlatformException(
code: 'no_such_callback',
message: 'Did not recognize the observer callback ${call.method}.');
}
// Get transaction wrapper object list from arguments.
List<SKPaymentTransactionWrapper> _getTransactionList(
List<dynamic> transactionsData) {
return transactionsData.map<SKPaymentTransactionWrapper>((dynamic map) {
return SKPaymentTransactionWrapper.fromJson(
Map.castFrom<dynamic, dynamic, String, dynamic>(
map as Map<dynamic, dynamic>));
}).toList();
}
/// Triage a method channel call from the platform and triggers the correct
/// payment queue delegate method.
///
/// This method is public for testing purposes only and should not be used
/// outside this class.
@visibleForTesting
Future<dynamic> handlePaymentQueueDelegateCallbacks(MethodCall call) async {
assert(_paymentQueueDelegate != null,
'[in_app_purchase]: (Fatal)The payment queue delegate has not been set but we received a payment queue notification. Please ensure the payment queue has been set using `setDelegate`.');
final SKPaymentQueueDelegateWrapper delegate = _paymentQueueDelegate!;
switch (call.method) {
case 'shouldContinueTransaction':
final Map<Object?, Object?> arguments =
call.arguments as Map<Object?, Object?>;
final SKPaymentTransactionWrapper transaction =
SKPaymentTransactionWrapper.fromJson(
(arguments['transaction']! as Map<dynamic, dynamic>)
.cast<String, dynamic>());
final SKStorefrontWrapper storefront = SKStorefrontWrapper.fromJson(
(arguments['storefront']! as Map<dynamic, dynamic>)
.cast<String, dynamic>());
return delegate.shouldContinueTransaction(transaction, storefront);
case 'shouldShowPriceConsent':
return delegate.shouldShowPriceConsent();
default:
break;
}
throw PlatformException(
code: 'no_such_callback',
message:
'Did not recognize the payment queue delegate callback ${call.method}.');
}
}
/// Dart wrapper around StoreKit's
/// [NSError](https://developer.apple.com/documentation/foundation/nserror?language=objc).
@immutable
@JsonSerializable()
class SKError {
/// Creates a new [SKError] object with the provided information.
const SKError(
{required this.code, required this.domain, required this.userInfo});
/// Constructs an instance of this from a key-value map of data.
///
/// The map needs to have named string keys with values matching the names and
/// types of all of the members on this class. The `map` parameter must not be
/// null.
factory SKError.fromJson(Map<String, dynamic> map) {
return _$SKErrorFromJson(map);
}
/// Error [code](https://developer.apple.com/documentation/foundation/1448136-nserror_codes)
/// as defined in the Cocoa Framework.
@JsonKey(defaultValue: 0)
final int code;
/// Error
/// [domain](https://developer.apple.com/documentation/foundation/nscocoaerrordomain?language=objc)
/// as defined in the Cocoa Framework.
@JsonKey(defaultValue: '')
final String domain;
/// A map that contains more detailed information about the error.
///
/// Any key of the map must be a valid [NSErrorUserInfoKey](https://developer.apple.com/documentation/foundation/nserroruserinfokey?language=objc).
@JsonKey(defaultValue: <String, dynamic>{})
final Map<String, dynamic> userInfo;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is SKError &&
other.code == code &&
other.domain == domain &&
const DeepCollectionEquality.unordered()
.equals(other.userInfo, userInfo);
}
@override
int get hashCode => Object.hash(
code,
domain,
userInfo,
);
}
/// Dart wrapper around StoreKit's
/// [SKPayment](https://developer.apple.com/documentation/storekit/skpayment?language=objc).
///
/// Used as the parameter to initiate a payment. In general, a developer should
/// not need to create the payment object explicitly; instead, use
/// [SKPaymentQueueWrapper.addPayment] directly with a product identifier to
/// initiate a payment.
@immutable
@JsonSerializable(createToJson: true)
class SKPaymentWrapper {
/// Creates a new [SKPaymentWrapper] with the provided information.
const SKPaymentWrapper({
required this.productIdentifier,
this.applicationUsername,
this.requestData,
this.quantity = 1,
this.simulatesAskToBuyInSandbox = false,
this.paymentDiscount,
});
/// Constructs an instance of this from a key value map of data.
///
/// The map needs to have named string keys with values matching the names and
/// types of all of the members on this class. The `map` parameter must not be
/// null.
factory SKPaymentWrapper.fromJson(Map<String, dynamic> map) {
return _$SKPaymentWrapperFromJson(map);
}
/// Creates a Map object describes the payment object.
Map<String, dynamic> toMap() {
return <String, dynamic>{
'productIdentifier': productIdentifier,
'applicationUsername': applicationUsername,
'requestData': requestData,
'quantity': quantity,
'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox,
'paymentDiscount': paymentDiscount?.toMap(),
};
}
/// The id for the product that the payment is for.
@JsonKey(defaultValue: '')
final String productIdentifier;
/// An opaque id for the user's account.
///
/// Used to help the store detect irregular activity. See
/// [applicationUsername](https://developer.apple.com/documentation/storekit/skpayment/1506116-applicationusername?language=objc)
/// for more details. For example, you can use a one-way hash of the user’s
/// account name on your server. Don’t use the Apple ID for your developer
/// account, the user’s Apple ID, or the user’s plaintext account name on
/// your server.
final String? applicationUsername;
/// Reserved for future use.
///
/// The value must be null before sending the payment. If the value is not
/// null, the payment will be rejected.
///
// The iOS Platform provided this property but it is reserved for future use.
// We also provide this property to match the iOS platform. Converted to
// String from NSData from ios platform using UTF8Encoding. The / default is
// null.
final String? requestData;
/// The amount of the product this payment is for.
///
/// The default is 1. The minimum is 1. The maximum is 10.
///
/// If the object is invalid, the value could be 0.
@JsonKey(defaultValue: 0)
final int quantity;
/// Produces an "ask to buy" flow in the sandbox.
///
/// Setting it to `true` will cause a transaction to be in the state [SKPaymentTransactionStateWrapper.deferred],
/// which produce an "ask to buy" prompt that interrupts the the payment flow.
///
/// Default is `false`.
///
/// See https://developer.apple.com/in-app-purchase/ for a guide on Sandbox
/// testing.
final bool simulatesAskToBuyInSandbox;
/// The details of a discount that should be applied to the payment.
///
/// See [Implementing Promotional Offers in Your App](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app?language=objc)
/// for more information on generating keys and creating offers for
/// auto-renewable subscriptions. If set to `null` no discount will be
/// applied to this payment.
final SKPaymentDiscountWrapper? paymentDiscount;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is SKPaymentWrapper &&
other.productIdentifier == productIdentifier &&
other.applicationUsername == applicationUsername &&
other.quantity == quantity &&
other.simulatesAskToBuyInSandbox == simulatesAskToBuyInSandbox &&
other.requestData == requestData;
}
@override
int get hashCode => Object.hash(productIdentifier, applicationUsername,
quantity, simulatesAskToBuyInSandbox, requestData);
@override
String toString() => _$SKPaymentWrapperToJson(this).toString();
}
/// Dart wrapper around StoreKit's
/// [SKPaymentDiscount](https://developer.apple.com/documentation/storekit/skpaymentdiscount?language=objc).
///
/// Used to indicate a discount is applicable to a payment. The
/// [SKPaymentDiscountWrapper] instance should be assigned to the
/// [SKPaymentWrapper] object to which the discount should be applied.
/// Discount offers are set up in App Store Connect. See [Implementing Promotional Offers in Your App](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app?language=objc)
/// for more information.
@immutable
@JsonSerializable(createToJson: true)
class SKPaymentDiscountWrapper {
/// Creates a new [SKPaymentDiscountWrapper] with the provided information.
const SKPaymentDiscountWrapper({
required this.identifier,
required this.keyIdentifier,
required this.nonce,
required this.signature,
required this.timestamp,
});
/// Constructs an instance of this from a key value map of data.
///
/// The map needs to have named string keys with values matching the names and
/// types of all of the members on this class.
factory SKPaymentDiscountWrapper.fromJson(Map<String, dynamic> map) {
return _$SKPaymentDiscountWrapperFromJson(map);
}
/// Creates a Map object describes the payment object.
Map<String, dynamic> toMap() {
return <String, dynamic>{
'identifier': identifier,
'keyIdentifier': keyIdentifier,
'nonce': nonce,
'signature': signature,
'timestamp': timestamp,
};
}
/// The identifier of the discount offer.
///
/// The identifier must match one of the offers set up in App Store Connect.
final String identifier;
/// A string identifying the key that is used to generate the signature.
///
/// Keys are generated and downloaded from App Store Connect. See
/// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc)
/// for more information.
final String keyIdentifier;
/// A universal unique identifier (UUID) created together with the signature.
///
/// The UUID should be generated on your server when it creates the
/// `signature` for the payment discount. The UUID can be used once, a new
/// UUID should be created for each payment request. The string representation
/// of the UUID must be lowercase. See
/// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc)
/// for more information.
final String nonce;
/// A cryptographically signed string representing the to properties of the
/// promotional offer.
///
/// The signature is string signed with a private key and contains all the
/// properties of the promotional offer. To keep you private key secure the
/// signature should be created on a server. See [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc)
/// for more information.
final String signature;
/// The date and time the signature was created.
///
/// The timestamp should be formatted in Unix epoch time. See
/// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc)
/// for more information.
final int timestamp;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is SKPaymentDiscountWrapper &&
other.identifier == identifier &&
other.keyIdentifier == keyIdentifier &&
other.nonce == nonce &&
other.signature == signature &&
other.timestamp == timestamp;
}
@override
int get hashCode =>
Object.hash(identifier, keyIdentifier, nonce, signature, timestamp);
}