Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.
Merged
Prev Previous commit
Next Next commit
Android specific implementation
  • Loading branch information
mvanbeusekom committed May 11, 2021
commit e3e40c3b18ad0a5e0be1f3b7507a0e3107e681f2
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,326 @@
// 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_android/in_app_purchase_android.dart';
import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart';
import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';

import '../billing_client_wrappers.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 previouys 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
with WidgetsBindingObserver {
InAppPurchaseAndroidPlatform._()
: billingClient =
BillingClient((PurchasesResultWrapper resultWrapper) async {
_purchaseUpdatedController
.add(await _getPurchaseDetailsFromResult(resultWrapper));
}) {
if (InAppPurchaseAndroidPlatform.enablePendingPurchase) {
billingClient.enablePendingPurchases();
}

_readyFuture = _connect();
WidgetsBinding.instance!.addObserver(this);
_purchaseUpdatedController = StreamController.broadcast();
;
}

/// Returns the singleton instance of the [InAppPurchaseAndroidPlatform].
static InAppPurchaseAndroidPlatform get instance => _getOrCreateInstance();
static InAppPurchaseAndroidPlatform? _instance;

/// Whether pending purchase is enabled.
///
/// See also [enablePendingPurchases] for more on pending purchases.
static bool get enablePendingPurchase => _enablePendingPurchase;
static bool _enablePendingPurchase = false;

/// Enable the [InAppPurchaseConnection] to handle pending purchases.
///
/// This method is required to be called when initialize the application.
/// It is to acknowledge your application has been updated to support pending purchases.
/// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending)
/// for more details.
/// Failure to call this method before access [instance] will throw an exception.
static void enablePendingPurchases() {
_enablePendingPurchase = true;
}

Stream<List<PurchaseDetails>> get purchaseStream =>
_purchaseUpdatedController.stream;
static late StreamController<List<PurchaseDetails>>
_purchaseUpdatedController;

/// The [BillingClient] that's abstracted by [GooglePlayConnection].
///
/// This field should not be used out of test code.
@visibleForTesting
late final BillingClient billingClient;

late Future<void> _readyFuture;
static Set<String> _productIdsToConsume = Set<String>();

@override
Future<bool> isAvailable() async {
await _readyFuture;
return billingClient.isReady();
}

@override
Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) async {
if (!(purchaseParam is GooglePlayPurchaseParam)) {
throw ArgumentError(
'On Android, the `purchaseParam` should always be of type `GooglePlayPurchaseParam`.',
);
}

BillingResultWrapper billingResultWrapper =
await billingClient.launchBillingFlow(
sku: purchaseParam.productDetails.id,
accountId: purchaseParam.applicationUserName,
oldSku: purchaseParam
.changeSubscriptionParam?.oldPurchaseDetails.productID,
purchaseToken: purchaseParam.changeSubscriptionParam
?.oldPurchaseDetails.verificationData.serverVerificationData,
prorationMode:
purchaseParam.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`.',
);

GooglePlayPurchaseDetails googlePurchase =
purchase as GooglePlayPurchaseDetails;

if (googlePurchase.billingClientPurchase.isAcknowledged) {
return BillingResultWrapper(responseCode: BillingResponse.ok);
}

if (googlePurchase.verificationData == null) {
throw ArgumentError(
'completePurchase unsuccessful. The `purchase.verificationData` is not valid');
}

return await billingClient
.acknowledgePurchase(purchase.verificationData.serverVerificationData);
}

/// Mark that the user has consumed a product.
///
/// You are responsible for consuming all consumable purchases once they are
/// delivered. The user won't be able to buy the same product again until the
/// purchase of the product is consumed.
Future<BillingResultWrapper> consumePurchase(PurchaseDetails purchase) {
if (purchase.verificationData == null) {
throw ArgumentError(
'consumePurchase unsuccessful. The `purchase.verificationData` is not valid');
}
return billingClient
.consumeAsync(purchase.verificationData.serverVerificationData);
}

@override
Future<void> restorePurchases({
String? applicationUserName,
}) async {
List<PurchasesResultWrapper> responses;

responses = await Future.wait([
billingClient.queryPurchases(SkuType.inapp),
billingClient.queryPurchases(SkuType.subs)
]);

Set errorCodeSet = responses
.where((PurchasesResultWrapper response) =>
response.responseCode != BillingResponse.ok)
.map((PurchasesResultWrapper response) =>
response.responseCode.toString())
.toSet();

String errorMessage =
errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : '';

List<PurchaseDetails> pastPurchases =
responses.expand((PurchasesResultWrapper response) {
return response.purchasesList;
}).map((PurchaseWrapper purchaseWrapper) {
return GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper);
}).toList();

if (errorMessage.isNotEmpty) {
throw InAppPurchaseException(
source: kIAPSource,
code: kRestoredPurchaseErrorCode,
message: errorMessage,
);
}

_purchaseUpdatedController.add(pastPurchases);
}

/// Resets the connection instance.
///
/// The next call to [instance] will create a new instance. Should only be
/// used in tests.
@visibleForTesting
static void reset() => _instance = null;

static InAppPurchaseAndroidPlatform _getOrCreateInstance() {
if (_instance != null) {
return _instance!;
}

_instance = InAppPurchaseAndroidPlatform._();
return _instance!;
}

Future<void> _connect() =>
billingClient.startConnection(onBillingServiceDisconnected: () {});

/// Query the product detail list.
///
/// This method only returns [ProductDetailsResponse].
/// To get detailed Google Play sku list, use [BillingClient.querySkuDetails]
/// to get the [SkuDetailsResponseWrapper].
Future<ProductDetailsResponse> queryProductDetails(
Set<String> identifiers) async {
List<SkuDetailsResponseWrapper> responses;
PlatformException? exception;
try {
responses = await Future.wait([
billingClient.querySkuDetails(
skuType: SkuType.inapp, skusList: identifiers.toList()),
billingClient.querySkuDetails(
skuType: SkuType.subs, skusList: identifiers.toList())
]);
} on PlatformException catch (e) {
exception = e;
responses = [
// ignore: invalid_use_of_visible_for_testing_member
SkuDetailsResponseWrapper(
billingResult: BillingResultWrapper(
responseCode: BillingResponse.error, debugMessage: e.code),
skuDetailsList: []),
// ignore: invalid_use_of_visible_for_testing_member
SkuDetailsResponseWrapper(
billingResult: BillingResultWrapper(
responseCode: BillingResponse.error, debugMessage: e.code),
skuDetailsList: [])
];
}
List<ProductDetails> productDetailsList =
responses.expand((SkuDetailsResponseWrapper response) {
return response.skuDetailsList;
}).map((SkuDetailsWrapper skuDetailWrapper) {
return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper);
}).toList();

Set<String> successIDS = productDetailsList
.map((ProductDetails productDetails) => productDetails.id)
.toSet();
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));
}

static 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.map((PurchaseWrapper purchase) {
return _maybeAutoConsumePurchase(
GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error);
}).toList();
if (purchases.isNotEmpty) {
return Future.wait(purchases);
} else {
return [
PurchaseDetails(
purchaseID: '',
productID: '',
status: PurchaseStatus.error,
transactionDate: null,
verificationData: PurchaseVerificationData(
localVerificationData: '',
serverVerificationData: '',
source: kIAPSource))
..error = error
];
}
}

static Future<PurchaseDetails> _maybeAutoConsumePurchase(
PurchaseDetails purchaseDetails) async {
if (!(purchaseDetails.status == PurchaseStatus.purchased &&
_productIdsToConsume.contains(purchaseDetails.productID))) {
return purchaseDetails;
}

final BillingResultWrapper billingResult =
await instance.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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import '../../billing_client_wrappers.dart';
import 'types.dart';

/// This parameter object for upgrading or downgrading an existing subscription.
class ChangeSubscriptionParam {
/// Creates a new change subscription param object with given data
ChangeSubscriptionParam({
required this.oldPurchaseDetails,
this.prorationMode,
});

/// The purchase object of the existing subscription that the user needs to
/// upgrade/downgrade from.
final GooglePlayPurchaseDetails oldPurchaseDetails;

/// The proration mode.
///
/// This is an optional parameter that indicates how to handle the existing
/// subscription when the new subscription comes into effect.
final ProrationMode? prorationMode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';

import '../../in_app_purchase_android.dart';

/// Google Play specific parameter object for generating a purchase.
class GooglePlayPurchaseParam extends PurchaseParam {
/// Creates a new [GooglePlayPurchaseParam] object with the given data.
GooglePlayPurchaseParam({
required ProductDetails productDetails,
String? applicationUserName,
this.changeSubscriptionParam,
}) : super(
productDetails: productDetails,
applicationUserName: applicationUserName,
);

/// The 'changeSubscriptionParam' containing information for upgrading or
/// downgrading an existing subscription.
final ChangeSubscriptionParam? changeSubscriptionParam;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// Thrown to indicate that an action failed while interacting with the
/// in_app_purchase plugin.
class InAppPurchaseException implements Exception {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this android only? Does it make sense to be in the platform_interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment it is only used in the Android package. On iOS we don't need this specific exception. However I can imagine the exception to be useful on other platforms in the future.

So I wasn't sure to add it now to the platform_interface or later if we really need it on multiple platform (by that time we might have forgotten that there is a similar class in the Android implementation).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think it belongs to the platform_interface

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a separate PR (#3852) which adds the InAppPurchaseException to the platform_interface. Once that is merged I will make sure it gets removed from this PR.

/// Creates a [InAppPurchaseException] with the specified source and error
/// [code] and optional [message].
InAppPurchaseException({
required this.source,
required this.code,
this.message,
}) : assert(code != null);

/// An error code.
final String code;

/// A human-readable error message, possibly null.
final String? message;

/// Which source is the error on.
final String source;

@override
String toString() => 'InAppPurchaseException($code, $message, $source)';
}
Loading