From 4a89c1397e78765e291932b11ad32dce882cef30 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Mon, 22 Aug 2022 13:37:51 +0200 Subject: [PATCH 1/6] [in_app_purchases_android_platform] Add BillingClient reconnecting logic --- .../src/in_app_purchase_android_platform.dart | 136 +++++++++--------- 1 file changed, 64 insertions(+), 72 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 14dd69364497..31ab72e80b67 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -30,17 +30,14 @@ const String kIAPSource = 'google_play'; class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { InAppPurchaseAndroidPlatform._() { billingClient = BillingClient((PurchasesResultWrapper resultWrapper) async { - _purchaseUpdatedController - .add(await _getPurchaseDetailsFromResult(resultWrapper)); + _purchaseUpdatedController.add(await _getPurchaseDetailsFromResult(resultWrapper)); }); // Register [InAppPurchaseAndroidPlatformAddition]. - InAppPurchasePlatformAddition.instance = - InAppPurchaseAndroidPlatformAddition(billingClient); + InAppPurchasePlatformAddition.instance = InAppPurchaseAndroidPlatformAddition(billingClient); - _readyFuture = _connect(); - _purchaseUpdatedController = - StreamController>.broadcast(); + _connect(); + _purchaseUpdatedController = StreamController>.broadcast(); } /// Registers this class as the default instance of [InAppPurchasePlatform]. @@ -50,12 +47,10 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { InAppPurchasePlatform.instance = InAppPurchaseAndroidPlatform._(); } - static late StreamController> - _purchaseUpdatedController; + static late StreamController> _purchaseUpdatedController; @override - Stream> get purchaseStream => - _purchaseUpdatedController.stream; + Stream> get purchaseStream => _purchaseUpdatedController.stream; /// The [BillingClient] that's abstracted by [GooglePlayConnection]. /// @@ -63,6 +58,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { @visibleForTesting late final BillingClient billingClient; + bool _isInitializing = true; late Future _readyFuture; static final Set _productIdsToConsume = {}; @@ -73,44 +69,35 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } @override - Future queryProductDetails( - Set identifiers) async { + Future queryProductDetails(Set identifiers) async { List responses; PlatformException? exception; try { responses = await Future.wait(>[ - billingClient.querySkuDetails( - skuType: SkuType.inapp, skusList: identifiers.toList()), - billingClient.querySkuDetails( - skuType: SkuType.subs, skusList: identifiers.toList()) + 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), + billingResult: BillingResultWrapper(responseCode: BillingResponse.error, debugMessage: e.code), skuDetailsList: const []), // ignore: invalid_use_of_visible_for_testing_member SkuDetailsResponseWrapper( - billingResult: BillingResultWrapper( - responseCode: BillingResponse.error, debugMessage: e.code), + billingResult: BillingResultWrapper(responseCode: BillingResponse.error, debugMessage: e.code), skuDetailsList: const []) ]; } - final List productDetailsList = - responses.expand((SkuDetailsResponseWrapper response) { + final List productDetailsList = responses.expand((SkuDetailsResponseWrapper response) { return response.skuDetailsList; }).map((SkuDetailsWrapper skuDetailWrapper) { return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); }).toList(); - final Set successIDS = productDetailsList - .map((ProductDetails productDetails) => productDetails.id) - .toSet(); - final List notFoundIDS = - identifiers.difference(successIDS).toList(); + final Set successIDS = productDetailsList.map((ProductDetails productDetails) => productDetails.id).toSet(); + final List notFoundIDS = identifiers.difference(successIDS).toList(); return ProductDetailsResponse( productDetails: productDetailsList, notFoundIDs: notFoundIDS, @@ -131,20 +118,19 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { changeSubscriptionParam = purchaseParam.changeSubscriptionParam; } - final BillingResultWrapper billingResultWrapper = - await billingClient.launchBillingFlow( - sku: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName, - oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: changeSubscriptionParam - ?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: changeSubscriptionParam?.prorationMode); + final BillingResultWrapper billingResultWrapper = await _wrap( + () => billingClient.launchBillingFlow( + sku: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName, + oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode), + ); return billingResultWrapper.responseCode == BillingResponse.ok; } @override - Future buyConsumable( - {required PurchaseParam purchaseParam, bool autoConsume = true}) { + Future buyConsumable({required PurchaseParam purchaseParam, bool autoConsume = true}) { if (autoConsume) { _productIdsToConsume.add(purchaseParam.productDetails.id); } @@ -152,27 +138,23 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } @override - Future completePurchase( - PurchaseDetails purchase) async { + Future completePurchase(PurchaseDetails purchase) async { assert( purchase is GooglePlayPurchaseDetails, 'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.', ); - final GooglePlayPurchaseDetails googlePurchase = - purchase as GooglePlayPurchaseDetails; + final GooglePlayPurchaseDetails googlePurchase = purchase as GooglePlayPurchaseDetails; if (googlePurchase.billingClientPurchase.isAcknowledged) { return const BillingResultWrapper(responseCode: BillingResponse.ok); } if (googlePurchase.verificationData == null) { - throw ArgumentError( - 'completePurchase unsuccessful. The `purchase.verificationData` is not valid'); + throw ArgumentError('completePurchase unsuccessful. The `purchase.verificationData` is not valid'); } - return await billingClient - .acknowledgePurchase(purchase.verificationData.serverVerificationData); + return await _wrap(() => billingClient.acknowledgePurchase(purchase.verificationData.serverVerificationData)); } @override @@ -187,21 +169,16 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { ]); final Set errorCodeSet = responses - .where((PurchasesResultWrapper response) => - response.responseCode != BillingResponse.ok) - .map((PurchasesResultWrapper response) => - response.responseCode.toString()) + .where((PurchasesResultWrapper response) => response.responseCode != BillingResponse.ok) + .map((PurchasesResultWrapper response) => response.responseCode.toString()) .toSet(); - final String errorMessage = - errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; + final String errorMessage = errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; - final List pastPurchases = - responses.expand((PurchasesResultWrapper response) { + final List pastPurchases = responses.expand((PurchasesResultWrapper response) { return response.purchasesList; }).map((PurchaseWrapper purchaseWrapper) { - final GooglePlayPurchaseDetails purchaseDetails = - GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); purchaseDetails.status = PurchaseStatus.restored; @@ -219,19 +196,27 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { _purchaseUpdatedController.add(pastPurchases); } - Future _connect() => - billingClient.startConnection(onBillingServiceDisconnected: () {}); + void _connect() { + _isInitializing = true; + _readyFuture = Future.sync(() async { + await billingClient.endConnection(); + await billingClient.startConnection(onBillingServiceDisconnected: () { + if (!_isInitializing) { + _connect(); + } + }); + _isInitializing = false; + }); + } - Future _maybeAutoConsumePurchase( - PurchaseDetails purchaseDetails) async { + Future _maybeAutoConsumePurchase(PurchaseDetails purchaseDetails) async { if (!(purchaseDetails.status == PurchaseStatus.purchased && _productIdsToConsume.contains(purchaseDetails.productID))) { return purchaseDetails; } final BillingResultWrapper billingResult = - await (InAppPurchasePlatformAddition.instance! - as InAppPurchaseAndroidPlatformAddition) + await (InAppPurchasePlatformAddition.instance! as InAppPurchaseAndroidPlatformAddition) .consumePurchase(purchaseDetails); final BillingResponse consumedResponse = billingResult.responseCode; if (consumedResponse != BillingResponse.ok) { @@ -248,8 +233,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { return purchaseDetails; } - Future> _getPurchaseDetailsFromResult( - PurchasesResultWrapper resultWrapper) async { + Future> _getPurchaseDetailsFromResult(PurchasesResultWrapper resultWrapper) async { IAPError? error; if (resultWrapper.responseCode != BillingResponse.ok) { error = IAPError( @@ -259,10 +243,9 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { details: resultWrapper.billingResult.debugMessage, ); } - final List> purchases = - resultWrapper.purchasesList.map((PurchaseWrapper purchase) { - final GooglePlayPurchaseDetails googlePlayPurchaseDetails = - GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error; + final List> purchases = resultWrapper.purchasesList.map((PurchaseWrapper purchase) { + final GooglePlayPurchaseDetails googlePlayPurchaseDetails = GooglePlayPurchaseDetails.fromPurchase(purchase) + ..error = error; if (resultWrapper.responseCode == BillingResponse.userCanceled) { googlePlayPurchaseDetails.status = PurchaseStatus.canceled; } @@ -283,12 +266,21 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { productID: '', status: status, transactionDate: null, - verificationData: PurchaseVerificationData( - localVerificationData: '', - serverVerificationData: '', - source: kIAPSource)) + verificationData: + PurchaseVerificationData(localVerificationData: '', serverVerificationData: '', source: kIAPSource)) ..error = error ]; } } + + Future _wrap(Future Function() block) async { + final BillingResultWrapper result = await block(); + if (result.responseCode == BillingResponse.serviceDisconnected) { + await Future.delayed(const Duration(seconds: 3)); + await _readyFuture; + return await _wrap(block); + } else { + return result; + } + } } From c12ca18b8bc043e3364c44767c2299df1ec711a1 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Wed, 24 Aug 2022 18:03:16 +0200 Subject: [PATCH 2/6] [in_app_purchases_android_platform] Extract BillingClientManager --- .../lib/billing_client_wrappers.dart | 1 + .../billing_client_manager.dart | 147 +++++++++++++ .../purchase_wrapper.dart | 8 +- .../sku_details_wrapper.dart | 16 +- .../src/in_app_purchase_android_platform.dart | 195 ++++++++++-------- ...pp_purchase_android_platform_addition.dart | 33 ++- .../billing_client_manager_test.dart | 101 +++++++++ ...rchase_android_platform_addition_test.dart | 2 +- ...in_app_purchase_android_platform_test.dart | 55 ++++- .../test/stub_in_app_purchase_platform.dart | 6 +- 10 files changed, 449 insertions(+), 115 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart index 1dac19f825b8..b49be8fe0fe1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/billing_client_wrappers/billing_client_manager.dart'; export 'src/billing_client_wrappers/billing_client_wrapper.dart'; export 'src/billing_client_wrappers/purchase_wrapper.dart'; export 'src/billing_client_wrappers/sku_details_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart new file mode 100644 index 000000000000..645be67eb0ef --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart @@ -0,0 +1,147 @@ +// 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/widgets.dart'; + +import 'billing_client_wrapper.dart'; +import 'purchase_wrapper.dart'; +import 'sku_details_wrapper.dart'; + +/// Utility class that manages a [BillingClient] connection. +/// +/// Connection is initialized on creation of [BillingClientManager]. +/// If [BillingClient] sends `onBillingServiceDisconnected` event or any +/// operation returns [BillingResponse.serviceDisconnected], connection is +/// re-initialized. +/// +/// [BillingClient] instance is not exposed directly. It can be accessed via +/// [run] and [runRaw] methods that handle the connection management. +/// +/// Consider calling [dispose] after the [BillingClient] is no longer needed. +class BillingClientManager { + /// Creates the [BillingClientManager]. + /// + /// Immediately initializes connection to the underlying [BillingClient]. + BillingClientManager() { + _connect(); + } + + /// Stream of `onPurchasesUpdated` events from the [BillingClient]. + /// + /// This is a broadcast stream, so it can be listened to multiple times. + /// A "done" event will be sent after [dispose] is called. + late final Stream purchasesUpdatedStream = + _purchasesUpdatedController.stream; + + /// [BillingClient] instance managed by this [BillingClientManager]. + /// + /// This field should not be accessed outside of test code. + /// In order to access the [BillingClient], consider using [run] and [runRaw] + /// methods. + @visibleForTesting + late final BillingClient client = BillingClient(_onPurchasesUpdated); + + final StreamController _purchasesUpdatedController = + StreamController.broadcast(); + + bool _isConnecting = false; + bool _isDisposed = false; + + // Initialized immediately in the constructor, so it's always safe to access. + late Future _readyFuture; + + /// Executes the given [block] with access to the underlying [BillingClient]. + /// + /// If necessary, waits for the underlying [BillingClient] to connect. + /// If given [block] returns [BillingResponse.serviceDisconnected], it will + /// be transparently retried after the connection is restored. Because + /// of this, [block] may be called multiple times. + /// A response with [BillingResponse.serviceDisconnected] may be returned + /// in case of [dispose] being called during the operation. + /// + /// See [runRaw] for operations that do not return a subclass + /// of [HasBillingResponse]. + Future run( + Future Function(BillingClient client) block, + ) async { + assert(_debugAssertNotDisposed()); + await _readyFuture; + final R result = await block(client); + if (result.responseCode == BillingResponse.serviceDisconnected && + !_isDisposed) { + await _connect(); + return await run(block); + } else { + return result; + } + } + + /// Executes the given [block] with access to the underlying [BillingClient]. + /// + /// If necessary, waits for the underlying [BillingClient] to connect. + /// Designed only for operations that do not return a subclass + /// of [HasBillingResponse] (e.g. [BillingClient.isReady], + /// [BillingClient.isFeatureSupported]). + /// + /// See [runRaw] for operations that return a subclass + /// of [HasBillingResponse]. + Future runRaw(Future Function(BillingClient client) block) async { + assert(_debugAssertNotDisposed()); + await _readyFuture; + return await block(client); + } + + /// Ends connection to the [BillingClient]. + /// + /// After calling [dispose] : + /// - Further connection attempts will not be made; + /// - [purchasesUpdatedStream] will be closed; + /// - Calls to [run] and [runRaw] will throw. + void dispose() { + assert(_debugAssertNotDisposed()); + _isDisposed = true; + client.endConnection(); + _purchasesUpdatedController.close(); + } + + // If disposed, does nothing. + // If currently connecting, waits for it to complete. + // Otherwise, starts a new connection. + Future _connect() { + if (_isDisposed) { + return Future.value(); + } + if (_isConnecting) { + return _readyFuture; + } + _isConnecting = true; + _readyFuture = Future.sync(() async { + await client.startConnection(onBillingServiceDisconnected: _connect); + _isConnecting = false; + }); + return _readyFuture; + } + + void _onPurchasesUpdated(PurchasesResultWrapper event) { + if (_isDisposed) { + return; + } + _purchasesUpdatedController.add(event); + } + + bool _debugAssertNotDisposed() { + assert(() { + if (_isDisposed) { + throw FlutterError( + 'A BillingClientManager was used after being disposed.\n' + 'Once you have called dispose() on a BillingClientManager, it can no longer be used.', + ); + } + return true; + }()); + return true; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart index 4e6b953096e2..a231e4f1bb54 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -265,7 +265,7 @@ class PurchaseHistoryRecordWrapper { @JsonSerializable() @BillingResponseConverter() @immutable -class PurchasesResultWrapper { +class PurchasesResultWrapper implements HasBillingResponse { /// Creates a [PurchasesResultWrapper] with the given purchase result details. const PurchasesResultWrapper( {required this.responseCode, @@ -300,6 +300,7 @@ class PurchasesResultWrapper { /// /// This can represent either the status of the "query purchase history" half /// of the operation and the "user made purchases" transaction itself. + @override final BillingResponse responseCode; /// The list of successful purchases made in this transaction. @@ -316,7 +317,7 @@ class PurchasesResultWrapper { @JsonSerializable() @BillingResponseConverter() @immutable -class PurchasesHistoryResult { +class PurchasesHistoryResult implements HasBillingResponse { /// Creates a [PurchasesHistoryResult] with the provided history. const PurchasesHistoryResult( {required this.billingResult, required this.purchaseHistoryRecordList}); @@ -325,6 +326,9 @@ class PurchasesHistoryResult { factory PurchasesHistoryResult.fromJson(Map map) => _$PurchasesHistoryResultFromJson(map); + @override + BillingResponse get responseCode => billingResult.responseCode; + @override bool operator ==(Object other) { if (identical(other, this)) { diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 1c5c2d1fcee9..129a63df67d6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:json_annotation/json_annotation.dart'; import 'billing_client_wrapper.dart'; @@ -19,6 +20,13 @@ part 'sku_details_wrapper.g.dart'; const String kInvalidBillingResultErrorMessage = 'Invalid billing result map from method channel.'; +/// Abstraction of result of [BillingClient] operation that includes +/// a [BillingResponse]. +abstract class HasBillingResponse { + /// The status of the operation. + abstract final BillingResponse responseCode; +} + /// Dart wrapper around [`com.android.billingclient.api.SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails). /// /// Contains the details of an available product in Google Play Billing. @@ -182,7 +190,7 @@ class SkuDetailsWrapper { /// Returned by [BillingClient.querySkuDetails]. @JsonSerializable() @immutable -class SkuDetailsResponseWrapper { +class SkuDetailsResponseWrapper implements HasBillingResponse { /// Creates a [SkuDetailsResponseWrapper] with the given purchase details. @visibleForTesting const SkuDetailsResponseWrapper( @@ -202,6 +210,9 @@ class SkuDetailsResponseWrapper { @JsonKey(defaultValue: []) final List skuDetailsList; + @override + BillingResponse get responseCode => billingResult.responseCode; + @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { @@ -221,7 +232,7 @@ class SkuDetailsResponseWrapper { @JsonSerializable() @BillingResponseConverter() @immutable -class BillingResultWrapper { +class BillingResultWrapper implements HasBillingResponse { /// Constructs the object with [responseCode] and [debugMessage]. const BillingResultWrapper({required this.responseCode, this.debugMessage}); @@ -239,6 +250,7 @@ class BillingResultWrapper { } /// Response code returned in the Play Billing API calls. + @override final BillingResponse responseCode; /// Debug message returned in the Play Billing API calls. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 31ab72e80b67..94639c70967d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -29,15 +29,13 @@ const String kIAPSource = 'google_play'; /// generic plugin API. class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { InAppPurchaseAndroidPlatform._() { - billingClient = BillingClient((PurchasesResultWrapper resultWrapper) async { - _purchaseUpdatedController.add(await _getPurchaseDetailsFromResult(resultWrapper)); - }); - // Register [InAppPurchaseAndroidPlatformAddition]. - InAppPurchasePlatformAddition.instance = InAppPurchaseAndroidPlatformAddition(billingClient); + InAppPurchasePlatformAddition.instance = + InAppPurchaseAndroidPlatformAddition(billingClientManager); - _connect(); - _purchaseUpdatedController = StreamController>.broadcast(); + billingClientManager.purchasesUpdatedStream + .asyncMap(_getPurchaseDetailsFromResult) + .listen(_purchaseUpdatedController.add); } /// Registers this class as the default instance of [InAppPurchasePlatform]. @@ -47,57 +45,72 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { InAppPurchasePlatform.instance = InAppPurchaseAndroidPlatform._(); } - static late StreamController> _purchaseUpdatedController; + final StreamController> _purchaseUpdatedController = + StreamController>.broadcast(); @override - Stream> get purchaseStream => _purchaseUpdatedController.stream; + late final Stream> purchaseStream = + _purchaseUpdatedController.stream; /// The [BillingClient] that's abstracted by [GooglePlayConnection]. /// /// This field should not be used out of test code. @visibleForTesting - late final BillingClient billingClient; + final BillingClientManager billingClientManager = BillingClientManager(); - bool _isInitializing = true; - late Future _readyFuture; static final Set _productIdsToConsume = {}; @override Future isAvailable() async { - await _readyFuture; - return billingClient.isReady(); + return await billingClientManager + .runRaw((BillingClient client) => client.isReady()); } @override - Future queryProductDetails(Set identifiers) async { + Future queryProductDetails( + Set identifiers, + ) async { List responses; PlatformException? exception; + + Future querySkuDetails(SkuType type) { + return billingClientManager.run( + (BillingClient client) => client.querySkuDetails( + skuType: type, + skusList: identifiers.toList(), + ), + ); + } + try { responses = await Future.wait(>[ - billingClient.querySkuDetails(skuType: SkuType.inapp, skusList: identifiers.toList()), - billingClient.querySkuDetails(skuType: SkuType.subs, skusList: identifiers.toList()) + querySkuDetails(SkuType.inapp), + querySkuDetails(SkuType.subs), ]); } 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: const []), - // ignore: invalid_use_of_visible_for_testing_member - SkuDetailsResponseWrapper( - billingResult: BillingResultWrapper(responseCode: BillingResponse.error, debugMessage: e.code), - skuDetailsList: const []) - ]; + // ignore: invalid_use_of_visible_for_testing_member + final SkuDetailsResponseWrapper response = SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: e.code, + ), + skuDetailsList: const [], + ); + responses = [response, response]; } - final List productDetailsList = responses.expand((SkuDetailsResponseWrapper response) { + final List productDetailsList = + responses.expand((SkuDetailsResponseWrapper response) { return response.skuDetailsList; }).map((SkuDetailsWrapper skuDetailWrapper) { return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); }).toList(); - final Set successIDS = productDetailsList.map((ProductDetails productDetails) => productDetails.id).toSet(); - final List notFoundIDS = identifiers.difference(successIDS).toList(); + final Set successIDS = productDetailsList + .map((ProductDetails productDetails) => productDetails.id) + .toSet(); + final List notFoundIDS = + identifiers.difference(successIDS).toList(); return ProductDetailsResponse( productDetails: productDetailsList, notFoundIDs: notFoundIDS, @@ -118,19 +131,23 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { changeSubscriptionParam = purchaseParam.changeSubscriptionParam; } - final BillingResultWrapper billingResultWrapper = await _wrap( - () => billingClient.launchBillingFlow( - sku: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName, - oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: changeSubscriptionParam?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: changeSubscriptionParam?.prorationMode), + final BillingResultWrapper billingResultWrapper = + await billingClientManager.run( + (BillingClient client) => client.launchBillingFlow( + sku: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName, + oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode, + ), ); return billingResultWrapper.responseCode == BillingResponse.ok; } @override - Future buyConsumable({required PurchaseParam purchaseParam, bool autoConsume = true}) { + Future buyConsumable( + {required PurchaseParam purchaseParam, bool autoConsume = true}) { if (autoConsume) { _productIdsToConsume.add(purchaseParam.productDetails.id); } @@ -138,47 +155,59 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } @override - Future completePurchase(PurchaseDetails purchase) async { + Future completePurchase( + PurchaseDetails purchase, + ) async { assert( purchase is GooglePlayPurchaseDetails, 'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.', ); - final GooglePlayPurchaseDetails googlePurchase = purchase as GooglePlayPurchaseDetails; + final GooglePlayPurchaseDetails googlePurchase = + purchase as GooglePlayPurchaseDetails; if (googlePurchase.billingClientPurchase.isAcknowledged) { return const BillingResultWrapper(responseCode: BillingResponse.ok); } if (googlePurchase.verificationData == null) { - throw ArgumentError('completePurchase unsuccessful. The `purchase.verificationData` is not valid'); + throw ArgumentError( + 'completePurchase unsuccessful. The `purchase.verificationData` is not valid'); } - return await _wrap(() => billingClient.acknowledgePurchase(purchase.verificationData.serverVerificationData)); + return await billingClientManager.run( + (BillingClient client) => client.acknowledgePurchase( + purchase.verificationData.serverVerificationData), + ); } @override - Future restorePurchases({ - String? applicationUserName, - }) async { + Future restorePurchases({String? applicationUserName}) async { List responses; responses = await Future.wait(>[ - billingClient.queryPurchases(SkuType.inapp), - billingClient.queryPurchases(SkuType.subs) + billingClientManager + .run((BillingClient client) => client.queryPurchases(SkuType.inapp)), + billingClientManager + .run((BillingClient client) => client.queryPurchases(SkuType.subs)), ]); final Set errorCodeSet = responses - .where((PurchasesResultWrapper response) => response.responseCode != BillingResponse.ok) - .map((PurchasesResultWrapper response) => response.responseCode.toString()) + .where((PurchasesResultWrapper response) => + response.responseCode != BillingResponse.ok) + .map((PurchasesResultWrapper response) => + response.responseCode.toString()) .toSet(); - final String errorMessage = errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; + final String errorMessage = + errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; - final List pastPurchases = responses.expand((PurchasesResultWrapper response) { + final List pastPurchases = + responses.expand((PurchasesResultWrapper response) { return response.purchasesList; }).map((PurchaseWrapper purchaseWrapper) { - final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + final GooglePlayPurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); purchaseDetails.status = PurchaseStatus.restored; @@ -196,28 +225,19 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { _purchaseUpdatedController.add(pastPurchases); } - void _connect() { - _isInitializing = true; - _readyFuture = Future.sync(() async { - await billingClient.endConnection(); - await billingClient.startConnection(onBillingServiceDisconnected: () { - if (!_isInitializing) { - _connect(); - } - }); - _isInitializing = false; - }); - } - - Future _maybeAutoConsumePurchase(PurchaseDetails purchaseDetails) async { + Future _maybeAutoConsumePurchase( + PurchaseDetails purchaseDetails, + ) async { if (!(purchaseDetails.status == PurchaseStatus.purchased && _productIdsToConsume.contains(purchaseDetails.productID))) { return purchaseDetails; } + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; final BillingResultWrapper billingResult = - await (InAppPurchasePlatformAddition.instance! as InAppPurchaseAndroidPlatformAddition) - .consumePurchase(purchaseDetails); + await addition.consumePurchase(purchaseDetails); final BillingResponse consumedResponse = billingResult.responseCode; if (consumedResponse != BillingResponse.ok) { purchaseDetails.status = PurchaseStatus.error; @@ -233,7 +253,9 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { return purchaseDetails; } - Future> _getPurchaseDetailsFromResult(PurchasesResultWrapper resultWrapper) async { + Future> _getPurchaseDetailsFromResult( + PurchasesResultWrapper resultWrapper, + ) async { IAPError? error; if (resultWrapper.responseCode != BillingResponse.ok) { error = IAPError( @@ -243,9 +265,10 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { details: resultWrapper.billingResult.debugMessage, ); } - final List> purchases = resultWrapper.purchasesList.map((PurchaseWrapper purchase) { - final GooglePlayPurchaseDetails googlePlayPurchaseDetails = GooglePlayPurchaseDetails.fromPurchase(purchase) - ..error = error; + final List> purchases = + resultWrapper.purchasesList.map((PurchaseWrapper purchase) { + final GooglePlayPurchaseDetails googlePlayPurchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error; if (resultWrapper.responseCode == BillingResponse.userCanceled) { googlePlayPurchaseDetails.status = PurchaseStatus.canceled; } @@ -262,25 +285,17 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } return [ PurchaseDetails( - purchaseID: '', - productID: '', - status: status, - transactionDate: null, - verificationData: - PurchaseVerificationData(localVerificationData: '', serverVerificationData: '', source: kIAPSource)) - ..error = error + purchaseID: '', + productID: '', + status: status, + transactionDate: null, + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: kIAPSource, + ), + )..error = error ]; } } - - Future _wrap(Future Function() block) async { - final BillingResultWrapper result = await block(); - if (result.responseCode == BillingResponse.serviceDisconnected) { - await Future.delayed(const Duration(seconds: 3)); - await _readyFuture; - return await _wrap(block); - } else { - return result; - } - } } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index db53ff4077d2..bf865d72d328 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -12,8 +12,8 @@ import '../billing_client_wrappers.dart'; class InAppPurchaseAndroidPlatformAddition extends InAppPurchasePlatformAddition { /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied - /// `BillingClient` to provide Android specific features. - InAppPurchaseAndroidPlatformAddition(this._billingClient); + /// `BillingClientManager` to provide Android specific features. + InAppPurchaseAndroidPlatformAddition(this._billingClientManager); /// Whether pending purchase is enabled. /// @@ -42,7 +42,7 @@ class InAppPurchaseAndroidPlatformAddition // No-op, until it is time to completely remove this method from the API. } - final BillingClient _billingClient; + final BillingClientManager _billingClientManager; /// Mark that the user has consumed a product. /// @@ -54,8 +54,10 @@ class InAppPurchaseAndroidPlatformAddition throw ArgumentError( 'consumePurchase unsuccessful. The `purchase.verificationData` is not valid'); } - return _billingClient - .consumeAsync(purchase.verificationData.serverVerificationData); + return _billingClientManager.run( + (BillingClient client) => + client.consumeAsync(purchase.verificationData.serverVerificationData), + ); } /// Query all previous purchases. @@ -78,8 +80,12 @@ class InAppPurchaseAndroidPlatformAddition PlatformException? exception; try { responses = await Future.wait(>[ - _billingClient.queryPurchases(SkuType.inapp), - _billingClient.queryPurchases(SkuType.subs) + _billingClientManager.run( + (BillingClient client) => client.queryPurchases(SkuType.inapp), + ), + _billingClientManager.run( + (BillingClient client) => client.queryPurchases(SkuType.subs), + ), ]); } on PlatformException catch (e) { exception = e; @@ -141,7 +147,8 @@ class InAppPurchaseAndroidPlatformAddition /// Checks if the specified feature or capability is supported by the Play Store. /// Call this to check if a [BillingClientFeature] is supported by the device. Future isFeatureSupported(BillingClientFeature feature) async { - return _billingClient.isFeatureSupported(feature); + return _billingClientManager + .runRaw((BillingClient client) => client.isFeatureSupported(feature)); } /// Initiates a flow to confirm the change of price for an item subscribed by the user. @@ -151,8 +158,12 @@ class InAppPurchaseAndroidPlatformAddition /// /// The skuDetails needs to have already been fetched in a /// [InAppPurchaseAndroidPlatform.queryProductDetails] call. - Future launchPriceChangeConfirmationFlow( - {required String sku}) { - return _billingClient.launchPriceChangeConfirmationFlow(sku: sku); + Future launchPriceChangeConfirmationFlow({ + required String sku, + }) { + return _billingClientManager.run( + (BillingClient client) => + client.launchPriceChangeConfirmationFlow(sku: sku), + ); } } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart new file mode 100644 index 000000000000..b329dd8d51f4 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart @@ -0,0 +1,101 @@ +// 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:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; + +import '../stub_in_app_purchase_platform.dart'; +import 'purchase_wrapper_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late BillingClientManager manager; + late Completer connectedCompleter; + + const String startConnectionCall = + 'BillingClient#startConnection(BillingClientStateListener)'; + const String endConnectionCall = 'BillingClient#endConnection()'; + const String onBillingServiceDisconnectedCallback = + 'BillingClientStateListener#onBillingServiceDisconnected()'; + + setUpAll(() => + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler)); + + setUp(() { + WidgetsFlutterBinding.ensureInitialized(); + connectedCompleter = Completer.sync(); + stubPlatform.addResponse( + name: startConnectionCall, + value: buildBillingResultMap( + const BillingResultWrapper(responseCode: BillingResponse.ok), + ), + additionalStepBeforeReturn: (dynamic _) => connectedCompleter.future, + ); + stubPlatform.addResponse(name: endConnectionCall); + manager = BillingClientManager(); + }); + + tearDown(() => stubPlatform.reset()); + + group('BillingClientWrapper', () { + test('connects on initialization', () { + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); + }); + test('waits for connection before executing the operations', () { + bool runCalled = false; + bool runRawCalled = false; + manager.run((BillingClient _) async { + runCalled = true; + return const BillingResultWrapper(responseCode: BillingResponse.ok); + }); + manager.runRaw((BillingClient _) async => runRawCalled = true); + expect(runCalled, equals(false)); + expect(runRawCalled, equals(false)); + connectedCompleter.complete(); + expect(runCalled, equals(true)); + expect(runRawCalled, equals(true)); + }); + test('re-connects when client sends onBillingServiceDisconnected', () { + connectedCompleter.complete(); + manager.client.callHandler( + const MethodCall(onBillingServiceDisconnectedCallback, + {'handle': 0}), + ); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + }); + test( + 're-connects when operation returns BillingResponse.serviceDisconnected', + () async { + connectedCompleter.complete(); + int timesCalled = 0; + final BillingResultWrapper result = await manager.run( + (BillingClient _) async { + timesCalled++; + return BillingResultWrapper( + responseCode: timesCalled == 1 + ? BillingResponse.serviceDisconnected + : BillingResponse.ok, + ); + }, + ); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + expect(timesCalled, equals(2)); + expect(result.responseCode, equals(BillingResponse.ok)); + }, + ); + test('does not re-connect when disposed', () { + connectedCompleter.complete(); + manager.dispose(); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); + expect(stubPlatform.countPreviousCalls(endConnectionCall), equals(1)); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 9737282e27b7..9f2feb3c14a8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -37,7 +37,7 @@ void main() { value: buildBillingResultMap(expectedBillingResult)); stubPlatform.addResponse(name: endConnectionCall); iapAndroidPlatformAddition = - InAppPurchaseAndroidPlatformAddition(BillingClient((_) {})); + InAppPurchaseAndroidPlatformAddition(BillingClientManager()); }); group('consume purchases', () { diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index b6055cc9a8bb..397e7e134ce2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -24,6 +24,10 @@ void main() { const String startConnectionCall = 'BillingClient#startConnection(BillingClientStateListener)'; const String endConnectionCall = 'BillingClient#endConnection()'; + const String acknowledgePurchaseCall = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + const String onBillingServiceDisconnectedCallback = + 'BillingClientStateListener#onBillingServiceDisconnected()'; setUpAll(() { channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler); @@ -55,6 +59,43 @@ void main() { //await iapAndroidPlatform.isAvailable(); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); }); + test('re-connects when client sends onBillingServiceDisconnected', () { + iapAndroidPlatform.billingClientManager.client.callHandler( + const MethodCall(onBillingServiceDisconnectedCallback, + {'handle': 0}), + ); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + }); + test( + 're-connects when operation returns BillingResponse.clientDisconnected', + () async { + final Map okValue = buildBillingResultMap( + const BillingResultWrapper(responseCode: BillingResponse.ok)); + stubPlatform.addResponse( + name: acknowledgePurchaseCall, + value: buildBillingResultMap( + const BillingResultWrapper( + responseCode: BillingResponse.serviceDisconnected, + ), + ), + ); + stubPlatform.addResponse( + name: startConnectionCall, + value: okValue, + additionalStepBeforeReturn: (dynamic _) => stubPlatform.addResponse( + name: acknowledgePurchaseCall, value: okValue), + ); + final PurchaseDetails purchase = + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + final BillingResultWrapper result = + await iapAndroidPlatform.completePurchase(purchase); + expect( + stubPlatform.countPreviousCalls(acknowledgePurchaseCall), + equals(2), + ); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + expect(result.responseCode, equals(BillingResponse.ok)); + }); }); group('isAvailable', () { @@ -312,7 +353,7 @@ void main() { } ] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer completer = Completer(); PurchaseDetails purchaseDetails; @@ -356,7 +397,7 @@ void main() { 'responseCode': const BillingResponseConverter().toJson(sentCode), 'purchasesList': const [] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer completer = Completer(); PurchaseDetails purchaseDetails; @@ -414,7 +455,7 @@ void main() { } ] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer consumeCompleter = Completer(); // adding call back for consume purchase @@ -528,7 +569,7 @@ void main() { } ] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer consumeCompleter = Completer(); // adding call back for consume purchase @@ -605,7 +646,7 @@ void main() { } ] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer consumeCompleter = Completer(); // adding call back for consume purchase @@ -670,7 +711,7 @@ void main() { } ] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer consumeCompleter = Completer(); // adding call back for consume purchase @@ -727,7 +768,7 @@ void main() { 'responseCode': const BillingResponseConverter().toJson(sentCode), 'purchasesList': const [] }); - iapAndroidPlatform.billingClient.callHandler(call); + iapAndroidPlatform.billingClientManager.client.callHandler(call); }); final Completer completer = Completer(); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart index 75972e644faa..35e2807bc3b1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart @@ -5,7 +5,9 @@ import 'dart:async'; import 'package:flutter/services.dart'; -typedef AdditionalSteps = void Function(dynamic args); +// `FutureOr` instead of `FutureOr` to avoid +// "don't assign to void" warnings. +typedef AdditionalSteps = FutureOr Function(dynamic args); class StubInAppPurchasePlatform { final Map _expectedCalls = {}; @@ -36,7 +38,7 @@ class StubInAppPurchasePlatform { _previousCalls.add(call); if (_expectedCalls.containsKey(call.method)) { if (_additionalSteps[call.method] != null) { - _additionalSteps[call.method]!(call.arguments); + await _additionalSteps[call.method]!(call.arguments); } return Future.sync(() => _expectedCalls[call.method]); } else { From 9f921dfdf305c8ac96ca2ad3c6f8b487fe9175a1 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Wed, 31 Aug 2022 13:50:39 +0200 Subject: [PATCH 3/6] [in_app_purchases_android] Update changelog --- packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 910c5cdc1fc0..0d6fd852b824 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,6 +1,8 @@ -## NEXT +## 0.2.4 * Updates minimum Flutter version to 2.10. +* Fixes the management of `BillingClient` connection. +* Introduces `BillingClientManager`. ## 0.2.3+3 From 732cda143a4aedac200a7a0bee6f82cb8643ac39 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Wed, 31 Aug 2022 17:09:45 +0200 Subject: [PATCH 4/6] [in_app_purchases_android] Update pubspec.yaml --- packages/in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 320e4b818e83..aa34582000cc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/main/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.2.3+3 +version: 0.2.4 environment: sdk: ">=2.14.0 <3.0.0" From 213ec8aab82cebd9f79bed66aa81ee97e66d1325 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Tue, 4 Oct 2022 11:32:22 +0200 Subject: [PATCH 5/6] [in_app_purchases_android_platform] Enhance documentation, move HasBillingResponse --- .../billing_client_manager.dart | 13 +++++++++++-- .../billing_client_wrapper.dart | 6 ++++++ .../billing_client_wrappers/purchase_wrapper.dart | 1 + .../sku_details_wrapper.dart | 10 +--------- .../lib/src/in_app_purchase_android_platform.dart | 7 +++---- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart index 645be67eb0ef..4427032ef910 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart @@ -8,7 +8,13 @@ import 'package:flutter/widgets.dart'; import 'billing_client_wrapper.dart'; import 'purchase_wrapper.dart'; -import 'sku_details_wrapper.dart'; + +/// Abstraction of result of [BillingClient] operation that includes +/// a [BillingResponse]. +abstract class HasBillingResponse { + /// The status of the operation. + abstract final BillingResponse responseCode; +} /// Utility class that manages a [BillingClient] connection. /// @@ -38,7 +44,6 @@ class BillingClientManager { /// [BillingClient] instance managed by this [BillingClientManager]. /// - /// This field should not be accessed outside of test code. /// In order to access the [BillingClient], consider using [run] and [runRaw] /// methods. @visibleForTesting @@ -59,6 +64,7 @@ class BillingClientManager { /// If given [block] returns [BillingResponse.serviceDisconnected], it will /// be transparently retried after the connection is restored. Because /// of this, [block] may be called multiple times. + /// /// A response with [BillingResponse.serviceDisconnected] may be returned /// in case of [dispose] being called during the operation. /// @@ -96,6 +102,9 @@ class BillingClientManager { /// Ends connection to the [BillingClient]. /// + /// Consider calling [dispose] after you no longer need the [BillingClient] + /// API to free up the resources. + /// /// After calling [dispose] : /// - Further connection attempts will not be made; /// - [purchasesUpdatedStream] will be closed; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index b64eaab49a9d..042ff9026cf0 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -51,6 +51,12 @@ typedef PurchasesUpdatedListener = void Function( /// `com.android.billingclient.api.BillingClient` API as much as possible, with /// some minor changes to account for language differences. Callbacks have been /// converted to futures where appropriate. +/// +/// Connection to [BillingClient] may be lost at any time (see +/// `onBillingServiceDisconnected` param of [startConnection] and +/// [BillingResponse.serviceDisconnected]). +/// Consider using [BillingClientManager] that handles these disconnections +/// transparently. class BillingClient { /// Creates a billing client. BillingClient(PurchasesUpdatedListener onPurchasesUpdated) { diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart index a231e4f1bb54..633aa732165b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'billing_client_manager.dart'; import 'billing_client_wrapper.dart'; import 'sku_details_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 129a63df67d6..5ef13c2e2de2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -3,10 +3,9 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; -import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'billing_client_wrapper.dart'; +import '../../billing_client_wrappers.dart'; // WARNING: Changes to `@JsonSerializable` classes need to be reflected in the // below generated file. Run `flutter packages pub run build_runner watch` to @@ -20,13 +19,6 @@ part 'sku_details_wrapper.g.dart'; const String kInvalidBillingResultErrorMessage = 'Invalid billing result map from method channel.'; -/// Abstraction of result of [BillingClient] operation that includes -/// a [BillingResponse]. -abstract class HasBillingResponse { - /// The status of the operation. - abstract final BillingResponse responseCode; -} - /// Dart wrapper around [`com.android.billingclient.api.SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails). /// /// Contains the details of an available product in Google Play Billing. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 91245574381f..6349842d5f66 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -233,11 +233,10 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { return purchaseDetails; } - final InAppPurchaseAndroidPlatformAddition addition = - InAppPurchasePlatformAddition.instance! - as InAppPurchaseAndroidPlatformAddition; final BillingResultWrapper billingResult = - await addition.consumePurchase(purchaseDetails); + await (InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition) + .consumePurchase(purchaseDetails); final BillingResponse consumedResponse = billingResult.responseCode; if (consumedResponse != BillingResponse.ok) { purchaseDetails.status = PurchaseStatus.error; From 48ddee756e90357c5c67b0db019f910a34b83603 Mon Sep 17 00:00:00 2001 From: Jakub Walusiak Date: Fri, 3 Feb 2023 12:30:14 +0100 Subject: [PATCH 6/6] [image_picker_android] Fix analyzer warnings, remove unnecessary changes. --- .../billing_client_manager.dart | 4 ++-- .../sku_details_wrapper.dart | 3 ++- .../src/in_app_purchase_android_platform.dart | 17 ++++++++--------- ..._app_purchase_android_platform_addition.dart | 3 +-- .../billing_client_manager_test.dart | 4 ++++ .../in_app_purchase_android_platform_test.dart | 2 ++ 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart index 4427032ef910..31598621da05 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart @@ -79,7 +79,7 @@ class BillingClientManager { if (result.responseCode == BillingResponse.serviceDisconnected && !_isDisposed) { await _connect(); - return await run(block); + return run(block); } else { return result; } @@ -97,7 +97,7 @@ class BillingClientManager { Future runRaw(Future Function(BillingClient client) block) async { assert(_debugAssertNotDisposed()); await _readyFuture; - return await block(client); + return block(client); } /// Ends connection to the [BillingClient]. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 5ef13c2e2de2..2689cf37eac4 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -5,7 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../billing_client_wrappers.dart'; +import 'billing_client_manager.dart'; +import 'billing_client_wrapper.dart'; // WARNING: Changes to `@JsonSerializable` classes need to be reflected in the // below generated file. Run `flutter packages pub run build_runner watch` to diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 6349842d5f66..0dea21a579d6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -62,14 +62,13 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { @override Future isAvailable() async { - return await billingClientManager + return billingClientManager .runRaw((BillingClient client) => client.isReady()); } @override Future queryProductDetails( - Set identifiers, - ) async { + Set identifiers) async { List responses; PlatformException? exception; @@ -156,8 +155,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { @override Future completePurchase( - PurchaseDetails purchase, - ) async { + PurchaseDetails purchase) async { assert( purchase is GooglePlayPurchaseDetails, 'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.', @@ -175,14 +173,16 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { 'completePurchase unsuccessful. The `purchase.verificationData` is not valid'); } - return await billingClientManager.run( + return billingClientManager.run( (BillingClient client) => client.acknowledgePurchase( purchase.verificationData.serverVerificationData), ); } @override - Future restorePurchases({String? applicationUserName}) async { + Future restorePurchases({ + String? applicationUserName, + }) async { List responses; responses = await Future.wait(>[ @@ -253,8 +253,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } Future> _getPurchaseDetailsFromResult( - PurchasesResultWrapper resultWrapper, - ) async { + PurchasesResultWrapper resultWrapper) async { IAPError? error; if (resultWrapper.responseCode != BillingResponse.ok) { error = IAPError( diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index 00deae440cf4..2e117680f3df 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -159,8 +159,7 @@ class InAppPurchaseAndroidPlatformAddition /// The skuDetails needs to have already been fetched in a /// [InAppPurchaseAndroidPlatform.queryProductDetails] call. Future launchPriceChangeConfirmationFlow({ - required String sku, - }) { + required String sku}) { return _billingClientManager.run( (BillingClient client) => client.launchPriceChangeConfirmationFlow(sku: sku), diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart index b329dd8d51f4..8351395cb46b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart @@ -49,6 +49,7 @@ void main() { test('connects on initialization', () { expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); }); + test('waits for connection before executing the operations', () { bool runCalled = false; bool runRawCalled = false; @@ -63,6 +64,7 @@ void main() { expect(runCalled, equals(true)); expect(runRawCalled, equals(true)); }); + test('re-connects when client sends onBillingServiceDisconnected', () { connectedCompleter.complete(); manager.client.callHandler( @@ -71,6 +73,7 @@ void main() { ); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); }); + test( 're-connects when operation returns BillingResponse.serviceDisconnected', () async { @@ -91,6 +94,7 @@ void main() { expect(result.responseCode, equals(BillingResponse.ok)); }, ); + test('does not re-connect when disposed', () { connectedCompleter.complete(); manager.dispose(); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 397e7e134ce2..351b3bb1f575 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -59,6 +59,7 @@ void main() { //await iapAndroidPlatform.isAvailable(); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); }); + test('re-connects when client sends onBillingServiceDisconnected', () { iapAndroidPlatform.billingClientManager.client.callHandler( const MethodCall(onBillingServiceDisconnectedCallback, @@ -66,6 +67,7 @@ void main() { ); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); }); + test( 're-connects when operation returns BillingResponse.clientDisconnected', () async {