blob: 2e4ed5b0f927d44deb260b0bb3f6c6876b25d75e [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:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';
import '../billing_client_wrappers.dart';
import '../in_app_purchase_android.dart';
/// [IAPError.code] code for failed purchases.
const String kPurchaseErrorCode = 'purchase_error';
/// [IAPError.code] code used when a consuming a purchased item fails.
const String kConsumptionFailedErrorCode = 'consume_purchase_failed';
/// [IAPError.code] code used when a query for previous transaction has failed.
const String kRestoredPurchaseErrorCode = 'restore_transactions_failed';
/// Indicates store front is Google Play
const String kIAPSource = 'google_play';
/// An [InAppPurchasePlatform] that wraps Android BillingClient.
///
/// This translates various `BillingClient` calls and responses into the
/// generic plugin API.
class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform {
InAppPurchaseAndroidPlatform._() {
// Register [InAppPurchaseAndroidPlatformAddition].
InAppPurchasePlatformAddition.instance =
InAppPurchaseAndroidPlatformAddition(billingClientManager);
billingClientManager.purchasesUpdatedStream
.asyncMap(_getPurchaseDetailsFromResult)
.listen(_purchaseUpdatedController.add);
}
/// Registers this class as the default instance of [InAppPurchasePlatform].
static void registerPlatform() {
// Register the platform instance with the plugin platform
// interface.
InAppPurchasePlatform.instance = InAppPurchaseAndroidPlatform._();
}
final StreamController<List<PurchaseDetails>> _purchaseUpdatedController =
StreamController<List<PurchaseDetails>>.broadcast();
@override
late final Stream<List<PurchaseDetails>> purchaseStream =
_purchaseUpdatedController.stream;
/// The [BillingClient] that's abstracted by [GooglePlayConnection].
///
/// This field should not be used out of test code.
@visibleForTesting
final BillingClientManager billingClientManager = BillingClientManager();
static final Set<String> _productIdsToConsume = <String>{};
@override
Future<bool> isAvailable() async {
return billingClientManager
.runWithClientNonRetryable((BillingClient client) => client.isReady());
}
/// Performs a network query for the details of products available.
@override
Future<ProductDetailsResponse> queryProductDetails(
Set<String> identifiers,
) async {
List<ProductDetailsResponseWrapper>? productResponses;
PlatformException? exception;
try {
productResponses = await Future.wait(
<Future<ProductDetailsResponseWrapper>>[
billingClientManager.runWithClient(
(BillingClient client) => client.queryProductDetails(
productList: identifiers
.map((String productId) => ProductWrapper(
productId: productId, productType: ProductType.inapp))
.toList(),
),
),
billingClientManager.runWithClient(
(BillingClient client) => client.queryProductDetails(
productList: identifiers
.map((String productId) => ProductWrapper(
productId: productId, productType: ProductType.subs))
.toList(),
),
),
],
);
} on PlatformException catch (e) {
exception = e;
productResponses = <ProductDetailsResponseWrapper>[
ProductDetailsResponseWrapper(
billingResult: BillingResultWrapper(
responseCode: BillingResponse.error, debugMessage: e.code),
productDetailsList: const <ProductDetailsWrapper>[]),
ProductDetailsResponseWrapper(
billingResult: BillingResultWrapper(
responseCode: BillingResponse.error, debugMessage: e.code),
productDetailsList: const <ProductDetailsWrapper>[])
];
}
final List<ProductDetails> productDetailsList =
productResponses.expand((ProductDetailsResponseWrapper response) {
return response.productDetailsList;
}).expand((ProductDetailsWrapper productDetailWrapper) {
return GooglePlayProductDetails.fromProductDetails(productDetailWrapper);
}).toList();
final Set<String> successIDS = productDetailsList
.map((ProductDetails productDetails) => productDetails.id)
.toSet();
final List<String> notFoundIDS =
identifiers.difference(successIDS).toList();
return ProductDetailsResponse(
productDetails: productDetailsList,
notFoundIDs: notFoundIDS,
error: exception == null
? null
: IAPError(
source: kIAPSource,
code: exception.code,
message: exception.message ?? '',
details: exception.details));
}
@override
Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) async {
ChangeSubscriptionParam? changeSubscriptionParam;
if (purchaseParam is GooglePlayPurchaseParam) {
changeSubscriptionParam = purchaseParam.changeSubscriptionParam;
}
String? offerToken;
if (purchaseParam.productDetails is GooglePlayProductDetails) {
offerToken =
(purchaseParam.productDetails as GooglePlayProductDetails).offerToken;
}
final BillingResultWrapper billingResultWrapper =
await billingClientManager.runWithClient(
(BillingClient client) => client.launchBillingFlow(
product: purchaseParam.productDetails.id,
offerToken: offerToken,
accountId: purchaseParam.applicationUserName,
oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID,
purchaseToken: changeSubscriptionParam
?.oldPurchaseDetails.verificationData.serverVerificationData,
prorationMode: changeSubscriptionParam?.prorationMode),
);
return billingResultWrapper.responseCode == BillingResponse.ok;
}
@override
Future<bool> buyConsumable(
{required PurchaseParam purchaseParam, bool autoConsume = true}) {
if (autoConsume) {
_productIdsToConsume.add(purchaseParam.productDetails.id);
}
return buyNonConsumable(purchaseParam: purchaseParam);
}
@override
Future<BillingResultWrapper> completePurchase(
PurchaseDetails purchase) async {
assert(
purchase is GooglePlayPurchaseDetails,
'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.',
);
final GooglePlayPurchaseDetails googlePurchase =
purchase as GooglePlayPurchaseDetails;
if (googlePurchase.billingClientPurchase.isAcknowledged) {
return const BillingResultWrapper(responseCode: BillingResponse.ok);
}
return billingClientManager.runWithClient(
(BillingClient client) => client.acknowledgePurchase(
purchase.verificationData.serverVerificationData),
);
}
@override
Future<void> restorePurchases({
String? applicationUserName,
}) async {
List<PurchasesResultWrapper> responses;
responses = await Future.wait(<Future<PurchasesResultWrapper>>[
billingClientManager.runWithClient(
(BillingClient client) => client.queryPurchases(ProductType.inapp),
),
billingClientManager.runWithClient(
(BillingClient client) => client.queryPurchases(ProductType.subs),
),
]);
final Set<String> errorCodeSet = responses
.where((PurchasesResultWrapper response) =>
response.responseCode != BillingResponse.ok)
.map((PurchasesResultWrapper response) =>
response.responseCode.toString())
.toSet();
final String errorMessage =
errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : '';
final List<PurchaseDetails> pastPurchases = responses
.expand((PurchasesResultWrapper response) => response.purchasesList)
.expand((PurchaseWrapper purchaseWrapper) =>
GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper))
.map((GooglePlayPurchaseDetails details) =>
details..status = PurchaseStatus.restored)
.toList();
if (errorMessage.isNotEmpty) {
throw InAppPurchaseException(
source: kIAPSource,
code: kRestoredPurchaseErrorCode,
message: errorMessage,
);
}
_purchaseUpdatedController.add(pastPurchases);
}
Future<PurchaseDetails> _maybeAutoConsumePurchase(
PurchaseDetails purchaseDetails) async {
if (!(purchaseDetails.status == PurchaseStatus.purchased &&
_productIdsToConsume.contains(purchaseDetails.productID))) {
return purchaseDetails;
}
final BillingResultWrapper billingResult =
await (InAppPurchasePlatformAddition.instance!
as InAppPurchaseAndroidPlatformAddition)
.consumePurchase(purchaseDetails);
final BillingResponse consumedResponse = billingResult.responseCode;
if (consumedResponse != BillingResponse.ok) {
purchaseDetails.status = PurchaseStatus.error;
purchaseDetails.error = IAPError(
source: kIAPSource,
code: kConsumptionFailedErrorCode,
message: consumedResponse.toString(),
details: billingResult.debugMessage,
);
}
_productIdsToConsume.remove(purchaseDetails.productID);
return purchaseDetails;
}
Future<List<PurchaseDetails>> _getPurchaseDetailsFromResult(
PurchasesResultWrapper resultWrapper) async {
IAPError? error;
if (resultWrapper.responseCode != BillingResponse.ok) {
error = IAPError(
source: kIAPSource,
code: kPurchaseErrorCode,
message: resultWrapper.responseCode.toString(),
details: resultWrapper.billingResult.debugMessage,
);
}
final List<Future<PurchaseDetails>> purchases = resultWrapper.purchasesList
.expand((PurchaseWrapper purchase) =>
GooglePlayPurchaseDetails.fromPurchase(purchase))
.map((GooglePlayPurchaseDetails purchaseDetails) {
purchaseDetails.error = error;
if (resultWrapper.responseCode == BillingResponse.userCanceled) {
purchaseDetails.status = PurchaseStatus.canceled;
}
return _maybeAutoConsumePurchase(purchaseDetails);
}).toList();
if (purchases.isNotEmpty) {
return Future.wait(purchases);
} else {
PurchaseStatus status = PurchaseStatus.error;
if (resultWrapper.responseCode == BillingResponse.userCanceled) {
status = PurchaseStatus.canceled;
} else if (resultWrapper.responseCode == BillingResponse.ok) {
status = PurchaseStatus.purchased;
}
return <PurchaseDetails>[
PurchaseDetails(
purchaseID: '',
productID: '',
status: status,
transactionDate: null,
verificationData: PurchaseVerificationData(
localVerificationData: '',
serverVerificationData: '',
source: kIAPSource,
),
)..error = error
];
}
}
}