Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.2.5

* Fixes the management of `BillingClient` connection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: This should include how the management is being fixed.

* Introduces `BillingClientManager`.

## 0.2.4+3

* Clarifies explanation of endorsement in README.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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';

/// 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.
///
/// 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
/// [runWithClient] and [runWithClientNonRetryable] 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<PurchasesResultWrapper> purchasesUpdatedStream =
_purchasesUpdatedController.stream;

/// [BillingClient] instance managed by this [BillingClientManager].
///
/// In order to access the [BillingClient], use [runWithClient]
/// and [runWithClientNonRetryable] methods.
@visibleForTesting
late final BillingClient client = BillingClient(_onPurchasesUpdated);

final StreamController<PurchasesResultWrapper> _purchasesUpdatedController =
StreamController<PurchasesResultWrapper>.broadcast();

bool _isConnecting = false;
bool _isDisposed = false;

// Initialized immediately in the constructor, so it's always safe to access.
late Future<void> _readyFuture;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: This is optional, but a Completer<void> could be used instead of a Future<void>. This would make it so you would not have to depend on a late initializer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In this case this wouldn't make much difference - new Completer would always have to be created in _connect function anyway.


/// Executes the given [action] with access to the underlying [BillingClient].
///
/// If necessary, waits for the underlying [BillingClient] to connect.
/// If given [action] returns [BillingResponse.serviceDisconnected], it will
/// be transparently retried after the connection is restored. Because
/// of this, [action] may be called multiple times.
///
/// A response with [BillingResponse.serviceDisconnected] may be returned
/// in case of [dispose] being called during the operation.
///
/// See [runWithClientNonRetryable] for operations that do not return
/// a subclass of [HasBillingResponse].
Future<R> runWithClient<R extends HasBillingResponse>(
Future<R> Function(BillingClient client) action,
) async {
_debugAssertNotDisposed();
await _readyFuture;
final R result = await action(client);
if (result.responseCode == BillingResponse.serviceDisconnected &&
!_isDisposed) {
await _connect();
return runWithClient(action);
} else {
return result;
}
}

/// Executes the given [action] 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 [runWithClient] for operations that return a subclass
/// of [HasBillingResponse].
Future<R> runWithClientNonRetryable<R>(
Future<R> Function(BillingClient client) action,
) async {
_debugAssertNotDisposed();
await _readyFuture;
return action(client);
}

/// 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.
/// - Calls to [runWithClient] and [runWithClientNonRetryable] will throw.
void dispose() {
_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<void> _connect() {
if (_isDisposed) {
return Future<void>.value();
}
if (_isConnecting) {
return _readyFuture;
}
_isConnecting = true;
_readyFuture = Future<void>.sync(() async {
await client.startConnection(onBillingServiceDisconnected: _connect);
_isConnecting = false;
});
return _readyFuture;
}

void _onPurchasesUpdated(PurchasesResultWrapper event) {
if (_isDisposed) {
return;
}
_purchasesUpdatedController.add(event);
}

void _debugAssertNotDisposed() {
assert(
!_isDisposed,
'A BillingClientManager was used after being disposed. Once you have '
'called dispose() on a BillingClientManager, it can no longer be used.',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -265,7 +266,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,
Expand Down Expand Up @@ -300,6 +301,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.
Expand All @@ -316,7 +318,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});
Expand All @@ -325,6 +327,9 @@ class PurchasesHistoryResult {
factory PurchasesHistoryResult.fromJson(Map<String, dynamic> map) =>
_$PurchasesHistoryResultFromJson(map);

@override
BillingResponse get responseCode => billingResult.responseCode;

@override
bool operator ==(Object other) {
if (identical(other, this)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';

import 'billing_client_manager.dart';
import 'billing_client_wrapper.dart';

// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the
Expand Down Expand Up @@ -182,7 +183,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(
Expand All @@ -202,6 +203,9 @@ class SkuDetailsResponseWrapper {
@JsonKey(defaultValue: <SkuDetailsWrapper>[])
final List<SkuDetailsWrapper> skuDetailsList;

@override
BillingResponse get responseCode => billingResult.responseCode;

@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
Expand All @@ -221,7 +225,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});

Expand All @@ -239,6 +243,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.
Expand Down
Loading