[in_app_purchase_android] Add UserChoiceBilling mode. (#6162)

Add UserChoiceBilling billing mode option. 

Fixes flutter/flutter/issues/143004 

Left in draft until: 

This does not have an End to end working example with play integration. I am currently stuck at the server side play integration part.
This commit is contained in:
Reid Baker
2024-03-08 17:48:39 -05:00
committed by GitHub
parent a10b360a21
commit d489d84c35
23 changed files with 874 additions and 25 deletions

View File

@ -1,5 +1,6 @@
## NEXT
## 0.3.2
* Adds UserChoiceBilling APIs to platform addition.
* Updates minimum supported SDK version to Flutter 3.13/Dart 3.1.
## 0.3.1

View File

@ -6,7 +6,9 @@ package io.flutter.plugins.inapppurchase;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodChannel;
/** Responsible for creating a {@link BillingClient} object. */
@ -22,5 +24,8 @@ interface BillingClientFactory {
* @return The {@link BillingClient} object that is created.
*/
BillingClient createBillingClient(
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode);
@NonNull Context context,
@NonNull MethodChannel channel,
int billingChoiceMode,
@Nullable UserChoiceBillingListener userChoiceBillingListener);
}

View File

@ -6,7 +6,10 @@ package io.flutter.plugins.inapppurchase;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.Log;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode;
@ -15,11 +18,34 @@ final class BillingClientFactoryImpl implements BillingClientFactory {
@Override
public BillingClient createBillingClient(
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode) {
@NonNull Context context,
@NonNull MethodChannel channel,
int billingChoiceMode,
@Nullable UserChoiceBillingListener userChoiceBillingListener) {
BillingClient.Builder builder = BillingClient.newBuilder(context).enablePendingPurchases();
if (billingChoiceMode == BillingChoiceMode.ALTERNATIVE_BILLING_ONLY) {
switch (billingChoiceMode) {
case BillingChoiceMode.ALTERNATIVE_BILLING_ONLY:
// https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app
builder.enableAlternativeBillingOnly();
break;
case BillingChoiceMode.USER_CHOICE_BILLING:
if (userChoiceBillingListener != null) {
// https://developer.android.com/google/play/billing/alternative/alternative-billing-with-user-choice-in-app
builder.enableUserChoiceBilling(userChoiceBillingListener);
} else {
Log.e(
"BillingClientFactoryImpl",
"userChoiceBillingListener null when USER_CHOICE_BILLING set. Defaulting to PLAY_BILLING_ONLY");
}
break;
case BillingChoiceMode.PLAY_BILLING_ONLY:
// Do nothing.
break;
default:
Log.e(
"BillingClientFactoryImpl",
"Unknown BillingChoiceMode " + billingChoiceMode + ", Defaulting to PLAY_BILLING_ONLY");
break;
}
return builder.setListener(new PluginPurchaseListener(channel)).build();
}

View File

@ -10,6 +10,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
import static io.flutter.plugins.inapppurchase.Translator.toProductList;
import android.app.Activity;
@ -33,6 +34,7 @@ import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryProductDetailsParams.Product;
import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import java.util.ArrayList;
@ -72,6 +74,8 @@ class MethodCallHandlerImpl
"BillingClient#createAlternativeBillingOnlyReportingDetails()";
static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG =
"BillingClient#showAlternativeBillingOnlyInformationDialog()";
static final String USER_SELECTED_ALTERNATIVE_BILLING =
"UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)";
private MethodNames() {}
}
@ -94,6 +98,7 @@ class MethodCallHandlerImpl
static final class BillingChoiceMode {
static final int PLAY_BILLING_ONLY = 0;
static final int ALTERNATIVE_BILLING_ONLY = 1;
static final int USER_CHOICE_BILLING = 2;
}
// TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new
@ -507,9 +512,10 @@ class MethodCallHandlerImpl
private void startConnection(
final int handle, final MethodChannel.Result result, int billingChoiceMode) {
if (billingClient == null) {
UserChoiceBillingListener listener = getUserChoiceBillingListener(billingChoiceMode);
billingClient =
billingClientFactory.createBillingClient(
applicationContext, methodChannel, billingChoiceMode);
applicationContext, methodChannel, billingChoiceMode, listener);
}
billingClient.startConnection(
@ -537,6 +543,19 @@ class MethodCallHandlerImpl
});
}
@Nullable
private UserChoiceBillingListener getUserChoiceBillingListener(int billingChoiceMode) {
UserChoiceBillingListener listener = null;
if (billingChoiceMode == BillingChoiceMode.USER_CHOICE_BILLING) {
listener =
userChoiceDetails -> {
final Map<String, Object> arguments = fromUserChoiceDetails(userChoiceDetails);
methodChannel.invokeMethod(MethodNames.USER_SELECTED_ALTERNATIVE_BILLING, arguments);
};
}
return listener;
}
private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) {
if (billingClientError(result)) {
return;

View File

@ -14,6 +14,8 @@ import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.UserChoiceDetails;
import com.android.billingclient.api.UserChoiceDetails.Product;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Currency;
@ -233,6 +235,34 @@ import java.util.Map;
return info;
}
static HashMap<String, Object> fromUserChoiceDetails(UserChoiceDetails userChoiceDetails) {
HashMap<String, Object> info = new HashMap<>();
info.put("externalTransactionToken", userChoiceDetails.getExternalTransactionToken());
info.put("originalExternalTransactionId", userChoiceDetails.getOriginalExternalTransactionId());
info.put("products", fromProductsList(userChoiceDetails.getProducts()));
return info;
}
static List<HashMap<String, Object>> fromProductsList(List<Product> productsList) {
if (productsList.isEmpty()) {
return Collections.emptyList();
}
ArrayList<HashMap<String, Object>> output = new ArrayList<>();
for (Product product : productsList) {
output.add(fromProduct(product));
}
return output;
}
static HashMap<String, Object> fromProduct(Product product) {
HashMap<String, Object> info = new HashMap<>();
info.put("id", product.getId());
info.put("offerToken", product.getOfferToken());
info.put("productType", product.getType());
return info;
}
/** Converter from {@link BillingResult} and {@link BillingConfig} to map. */
static HashMap<String, Object> fromBillingConfig(
BillingResult result, BillingConfig billingConfig) {

View File

@ -20,6 +20,7 @@ import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC;
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG;
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.START_CONNECTION;
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.USER_SELECTED_ALTERNATIVE_BILLING;
import static io.flutter.plugins.inapppurchase.PluginPurchaseListener.ON_PURCHASES_UPDATED;
import static io.flutter.plugins.inapppurchase.Translator.fromAlternativeBillingOnlyReportingDetails;
import static io.flutter.plugins.inapppurchase.Translator.fromBillingConfig;
@ -27,6 +28,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static java.util.Collections.unmodifiableList;
@ -73,6 +75,8 @@ import com.android.billingclient.api.PurchasesResponseListener;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.UserChoiceBillingListener;
import com.android.billingclient.api.UserChoiceDetails;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@ -82,6 +86,7 @@ import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodArgs;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -92,6 +97,7 @@ import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.mockito.stubbing.Answer;
@ -107,15 +113,23 @@ public class MethodCallHandlerTest {
@Mock ActivityPluginBinding mockActivityPluginBinding;
@Captor ArgumentCaptor<HashMap<String, Object>> resultCaptor;
private final int DEFAULT_HANDLE = 1;
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
// Use the same client no matter if alternative billing is enabled or not.
when(factory.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY))
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null))
.thenReturn(mockBillingClient);
when(factory.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY))
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null))
.thenReturn(mockBillingClient);
when(factory.createBillingClient(
any(Context.class),
any(MethodChannel.class),
eq(BillingChoiceMode.USER_CHOICE_BILLING),
any(UserChoiceBillingListener.class)))
.thenReturn(mockBillingClient);
methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
@ -164,7 +178,7 @@ public class MethodCallHandlerTest {
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
BillingResult billingResult =
BillingResult.newBuilder()
@ -183,7 +197,7 @@ public class MethodCallHandlerTest {
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY);
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null);
BillingResult billingResult =
BillingResult.newBuilder()
@ -209,7 +223,7 @@ public class MethodCallHandlerTest {
methodChannelHandler.onMethodCall(call, result);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
BillingResult billingResult =
BillingResult.newBuilder()
@ -221,6 +235,106 @@ public class MethodCallHandlerTest {
verify(result, times(1)).success(fromBillingResult(billingResult));
}
@Test
public void startConnectionUserChoiceBilling() {
ArgumentCaptor<BillingClientStateListener> captor =
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(
any(Context.class),
any(MethodChannel.class),
eq(BillingChoiceMode.USER_CHOICE_BILLING),
billingCaptor.capture());
BillingResult billingResult =
BillingResult.newBuilder()
.setResponseCode(100)
.setDebugMessage("dummy debug message")
.build();
captor.getValue().onBillingSetupFinished(billingResult);
verify(result, times(1)).success(fromBillingResult(billingResult));
UserChoiceDetails details = mock(UserChoiceDetails.class);
final String externalTransactionToken = "someLongTokenId1234";
final String originalTransactionId = "originalTransactionId123456";
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
when(details.getProducts()).thenReturn(Collections.emptyList());
billingCaptor.getValue().userSelectedAlternativeBilling(details);
verify(mockMethodChannel, times(1))
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
}
@Test
public void userChoiceBillingOnSecondConnection() {
// First connection.
ArgumentCaptor<BillingClientStateListener> captor1 =
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
BillingResult billingResult1 =
BillingResult.newBuilder()
.setResponseCode(100)
.setDebugMessage("dummy debug message")
.build();
final BillingClientStateListener stateListener = captor1.getValue();
stateListener.onBillingSetupFinished(billingResult1);
verify(result, times(1)).success(fromBillingResult(billingResult1));
Mockito.reset(result, mockMethodChannel, mockBillingClient);
// Disconnect
MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
methodChannelHandler.onMethodCall(disconnectCall, result);
// Verify that the client is disconnected and that the OnDisconnect callback has
// been triggered
verify(result, times(1)).success(any());
verify(mockBillingClient, times(1)).endConnection();
stateListener.onBillingServiceDisconnected();
Map<String, Integer> expectedInvocation = new HashMap<>();
expectedInvocation.put("handle", DEFAULT_HANDLE);
verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation);
Mockito.reset(result, mockMethodChannel, mockBillingClient);
// Second connection.
ArgumentCaptor<BillingClientStateListener> captor2 =
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(
any(Context.class),
any(MethodChannel.class),
eq(BillingChoiceMode.USER_CHOICE_BILLING),
billingCaptor.capture());
BillingResult billingResult2 =
BillingResult.newBuilder()
.setResponseCode(100)
.setDebugMessage("dummy debug message")
.build();
captor2.getValue().onBillingSetupFinished(billingResult2);
verify(result, times(1)).success(fromBillingResult(billingResult2));
UserChoiceDetails details = mock(UserChoiceDetails.class);
final String externalTransactionToken = "someLongTokenId1234";
final String originalTransactionId = "originalTransactionId123456";
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
when(details.getProducts()).thenReturn(Collections.emptyList());
billingCaptor.getValue().userSelectedAlternativeBilling(details);
verify(mockMethodChannel, times(1))
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
}
@Test
public void startConnection_multipleCalls() {
Map<String, Object> arguments = new HashMap<>();
@ -1071,7 +1185,7 @@ public class MethodCallHandlerTest {
*/
private ArgumentCaptor<BillingClientStateListener> mockStartConnection(int billingChoiceMode) {
Map<String, Object> arguments = new HashMap<>();
arguments.put(MethodArgs.HANDLE, 1);
arguments.put(MethodArgs.HANDLE, DEFAULT_HANDLE);
arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode);
MethodCall call = new MethodCall(START_CONNECTION, arguments);
ArgumentCaptor<BillingClientStateListener> captor =

View File

@ -27,7 +27,8 @@ void main() {
late final BillingClient billingClient;
setUpAll(() {
billingClient = BillingClient((PurchasesResultWrapper _) {});
billingClient = BillingClient(
(PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {});
});
testWidgets('BillingClient.acknowledgePurchase',

View File

@ -44,6 +44,7 @@ class _MyAppState extends State<_MyApp> {
final InAppPurchasePlatform _inAppPurchasePlatform =
InAppPurchasePlatform.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;
late StreamSubscription<GooglePlayUserChoiceDetails> _userChoiceDetailsStream;
List<String> _notFoundIds = <String>[];
List<ProductDetails> _products = <ProductDetails>[];
List<PurchaseDetails> _purchases = <PurchaseDetails>[];
@ -56,6 +57,7 @@ class _MyAppState extends State<_MyApp> {
bool _purchasePending = false;
bool _loading = true;
String? _queryProductError;
final List<String> _userChoiceDetailsList = <String>[];
@override
void initState() {
@ -70,6 +72,19 @@ class _MyAppState extends State<_MyApp> {
// handle error here.
});
initStoreInfo();
final InAppPurchaseAndroidPlatformAddition addition =
InAppPurchasePlatformAddition.instance!
as InAppPurchaseAndroidPlatformAddition;
final Stream<GooglePlayUserChoiceDetails> userChoiceDetailsUpdated =
addition.userChoiceDetailsStream;
_userChoiceDetailsStream =
userChoiceDetailsUpdated.listen((GooglePlayUserChoiceDetails details) {
deliverUserChoiceDetails(details);
}, onDone: () {
_userChoiceDetailsStream.cancel();
}, onError: (Object error) {
// handle error here.
});
super.initState();
}
@ -134,6 +149,8 @@ class _MyAppState extends State<_MyApp> {
@override
void dispose() {
_subscription.cancel();
_userChoiceDetailsStream.cancel();
_userChoiceDetailsList.clear();
super.dispose();
}
@ -149,6 +166,7 @@ class _MyAppState extends State<_MyApp> {
_buildConsumableBox(),
const _FeatureCard(),
_buildFetchButtons(),
_buildUserChoiceDetailsDisplay(),
],
),
);
@ -326,6 +344,26 @@ class _MyAppState extends State<_MyApp> {
);
}
Card _buildUserChoiceDetailsDisplay() {
const ListTile header = ListTile(title: Text('UserChoiceDetails'));
final List<Widget> entries = <ListTile>[];
for (final String item in _userChoiceDetailsList) {
entries.add(ListTile(
title: Text(item,
style: TextStyle(color: ThemeData.light().colorScheme.primary)),
subtitle: Text(_countryCode)));
}
return Card(
child: Column(
children: <Widget>[
header,
const Divider(),
...entries,
],
),
);
}
Card _buildProductList() {
if (_loading) {
return const Card(
@ -537,6 +575,15 @@ class _MyAppState extends State<_MyApp> {
// handle invalid purchase here if _verifyPurchase` failed.
}
Future<void> deliverUserChoiceDetails(
GooglePlayUserChoiceDetails details) async {
final String detailDescription =
'${details.externalTransactionToken}, ${details.originalExternalTransactionId}, ${details.products.length}';
setState(() {
_userChoiceDetailsList.add(detailDescription);
});
}
Future<void> _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {

View File

@ -11,3 +11,4 @@ export 'src/billing_client_wrappers/product_details_wrapper.dart';
export 'src/billing_client_wrappers/product_wrapper.dart';
export 'src/billing_client_wrappers/purchase_wrapper.dart';
export 'src/billing_client_wrappers/subscription_offer_details_wrapper.dart';
export 'src/billing_client_wrappers/user_choice_details_wrapper.dart';

View File

@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart';
import 'billing_client_wrapper.dart';
import 'purchase_wrapper.dart';
import 'user_choice_details_wrapper.dart';
/// Abstraction of result of [BillingClient] operation that includes
/// a [BillingResponse].
@ -37,6 +38,13 @@ class BillingClientManager {
_connect();
}
/// Stream of `userSelectedAlternativeBilling` 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<UserChoiceDetailsWrapper> userChoiceDetailsStream =
_userChoiceAlternativeBillingController.stream;
/// Stream of `onPurchasesUpdated` events from the [BillingClient].
///
/// This is a broadcast stream, so it can be listened to multiple times.
@ -49,10 +57,14 @@ class BillingClientManager {
/// In order to access the [BillingClient], use [runWithClient]
/// and [runWithClientNonRetryable] methods.
@visibleForTesting
late final BillingClient client = BillingClient(_onPurchasesUpdated);
late final BillingClient client =
BillingClient(_onPurchasesUpdated, onUserChoiceAlternativeBilling);
final StreamController<PurchasesResultWrapper> _purchasesUpdatedController =
StreamController<PurchasesResultWrapper>.broadcast();
final StreamController<UserChoiceDetailsWrapper>
_userChoiceAlternativeBillingController =
StreamController<UserChoiceDetailsWrapper>.broadcast();
BillingChoiceMode _billingChoiceMode;
bool _isConnecting = false;
@ -113,12 +125,14 @@ class BillingClientManager {
/// After calling [dispose]:
/// - Further connection attempts will not be made.
/// - [purchasesUpdatedStream] will be closed.
/// - [userChoiceDetailsStream] will be closed.
/// - Calls to [runWithClient] and [runWithClientNonRetryable] will throw.
void dispose() {
_debugAssertNotDisposed();
_isDisposed = true;
client.endConnection();
_purchasesUpdatedController.close();
_userChoiceAlternativeBillingController.close();
}
/// Ends connection to [BillingClient] and reconnects with [billingChoiceMode].
@ -168,4 +182,14 @@ class BillingClientManager {
'called dispose() on a BillingClientManager, it can no longer be used.',
);
}
/// Callback passed to [BillingClient] to use when customer chooses
/// alternative billing.
@visibleForTesting
void onUserChoiceAlternativeBilling(UserChoiceDetailsWrapper event) {
if (_isDisposed) {
return;
}
_userChoiceAlternativeBillingController.add(event);
}
}

View File

@ -18,6 +18,12 @@ part 'billing_client_wrapper.g.dart';
@visibleForTesting
const String kOnPurchasesUpdated =
'PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List<Purchase>)';
/// Method identifier for the userSelectedAlternativeBilling method channel method.
@visibleForTesting
const String kUserSelectedAlternativeBilling =
'UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)';
const String _kOnBillingServiceDisconnected =
'BillingClientStateListener#onBillingServiceDisconnected()';
@ -40,6 +46,10 @@ const String _kOnBillingServiceDisconnected =
typedef PurchasesUpdatedListener = void Function(
PurchasesResultWrapper purchasesResult);
/// Wraps a [UserChoiceBillingListener](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceBillingListener)
typedef UserSelectedAlternativeBillingListener = void Function(
UserChoiceDetailsWrapper userChoiceDetailsWrapper);
/// This class can be used directly instead of [InAppPurchaseConnection] to call
/// Play-specific billing APIs.
///
@ -60,11 +70,16 @@ typedef PurchasesUpdatedListener = void Function(
/// transparently.
class BillingClient {
/// Creates a billing client.
BillingClient(PurchasesUpdatedListener onPurchasesUpdated) {
BillingClient(PurchasesUpdatedListener onPurchasesUpdated,
UserSelectedAlternativeBillingListener? alternativeBillingListener) {
channel.setMethodCallHandler(callHandler);
_callbacks[kOnPurchasesUpdated] = <PurchasesUpdatedListener>[
onPurchasesUpdated
];
_callbacks[kUserSelectedAlternativeBilling] = alternativeBillingListener ==
null
? <UserSelectedAlternativeBillingListener>[]
: <UserSelectedAlternativeBillingListener>[alternativeBillingListener];
}
// Occasionally methods in the native layer require a Dart callback to be
@ -114,7 +129,8 @@ class BillingClient {
BillingChoiceMode.playBillingOnly}) async {
final List<Function> disconnectCallbacks =
_callbacks[_kOnBillingServiceDisconnected] ??= <Function>[];
disconnectCallbacks.add(onBillingServiceDisconnected);
_callbacks[_kOnBillingServiceDisconnected]
?.add(onBillingServiceDisconnected);
return BillingResultWrapper.fromJson((await channel
.invokeMapMethod<String, dynamic>(
'BillingClient#startConnection(BillingClientStateListener)',
@ -412,6 +428,15 @@ class BillingClient {
_callbacks[_kOnBillingServiceDisconnected]!
.cast<OnBillingServiceDisconnected>();
onDisconnected[handle]();
case kUserSelectedAlternativeBilling:
if (_callbacks[kUserSelectedAlternativeBilling]!.isNotEmpty) {
final UserSelectedAlternativeBillingListener listener =
_callbacks[kUserSelectedAlternativeBilling]!.first
as UserSelectedAlternativeBillingListener;
listener(UserChoiceDetailsWrapper.fromJson(
(call.arguments as Map<dynamic, dynamic>)
.cast<String, dynamic>()));
}
}
}
}
@ -443,7 +468,7 @@ enum BillingResponse {
@JsonValue(-2)
featureNotSupported,
/// The play Store service is not connected now - potentially transient state.
/// The Play Store service is not connected now - potentially transient state.
@JsonValue(-1)
serviceDisconnected,
@ -490,8 +515,8 @@ enum BillingResponse {
/// Plugin concept to cover billing modes.
///
/// [playBillingOnly] (google play billing only).
/// [alternativeBillingOnly] (app provided billing with reporting to play).
/// [playBillingOnly] (google Play billing only).
/// [alternativeBillingOnly] (app provided billing with reporting to Play).
@JsonEnum(alwaysCreate: true)
enum BillingChoiceMode {
// WARNING: Changes to this class need to be reflected in our generated code.
@ -500,13 +525,17 @@ enum BillingChoiceMode {
// Values must match what is used in
// in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java
/// Billing through google play. Default state.
/// Billing through google Play. Default state.
@JsonValue(0)
playBillingOnly,
/// Billing through app provided flow.
@JsonValue(1)
alternativeBillingOnly,
/// Users can choose Play billing or alternative billing.
@JsonValue(2)
userChoiceBilling,
}
/// Serializer for [BillingChoiceMode].

View File

@ -25,6 +25,7 @@ const _$BillingResponseEnumMap = {
const _$BillingChoiceModeEnumMap = {
BillingChoiceMode.playBillingOnly: 0,
BillingChoiceMode.alternativeBillingOnly: 1,
BillingChoiceMode.userChoiceBilling: 2,
};
const _$ProductTypeEnumMap = {

View File

@ -0,0 +1,131 @@
// 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:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.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
// rebuild and watch for further changes.
part 'user_choice_details_wrapper.g.dart';
/// This wraps [`com.android.billingclient.api.UserChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails)
// See https://docs.flutter.dev/data-and-backend/serialization/json#generating-code-for-nested-classes
// for explination for why this uses explicitToJson.
@JsonSerializable(createToJson: true, explicitToJson: true)
@immutable
class UserChoiceDetailsWrapper {
/// Creates a purchase wrapper with the given purchase details.
@visibleForTesting
const UserChoiceDetailsWrapper({
required this.originalExternalTransactionId,
required this.externalTransactionToken,
required this.products,
});
/// Factory for creating a [UserChoiceDetailsWrapper] from a [Map] with
/// the user choice details.
factory UserChoiceDetailsWrapper.fromJson(Map<String, dynamic> map) =>
_$UserChoiceDetailsWrapperFromJson(map);
/// Creates a JSON representation of this product.
Map<String, dynamic> toJson() => _$UserChoiceDetailsWrapperToJson(this);
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is UserChoiceDetailsWrapper &&
other.originalExternalTransactionId == originalExternalTransactionId &&
other.externalTransactionToken == externalTransactionToken &&
listEquals(other.products, products);
}
@override
int get hashCode => Object.hash(
originalExternalTransactionId,
externalTransactionToken,
products.hashCode,
);
/// Returns the external transaction Id of the originating subscription, if
/// the purchase is a subscription upgrade/downgrade.
@JsonKey(defaultValue: '')
final String originalExternalTransactionId;
/// Returns a token that represents the user's prospective purchase via
/// user choice alternative billing.
@JsonKey(defaultValue: '')
final String externalTransactionToken;
/// Returns a list of [UserChoiceDetailsProductWrapper] to be purchased in
/// the user choice alternative billing flow.
@JsonKey(defaultValue: <UserChoiceDetailsProductWrapper>[])
final List<UserChoiceDetailsProductWrapper> products;
}
/// Data structure representing a UserChoiceDetails product.
///
/// This wraps [`com.android.billingclient.api.UserChoiceDetails.Product`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails.Product)
//
// See https://docs.flutter.dev/data-and-backend/serialization/json#generating-code-for-nested-classes
// for explination for why this uses explicitToJson.
@JsonSerializable(createToJson: true, explicitToJson: true)
@ProductTypeConverter()
@immutable
class UserChoiceDetailsProductWrapper {
/// Creates a [UserChoiceDetailsProductWrapper] with the given record details.
@visibleForTesting
const UserChoiceDetailsProductWrapper({
required this.id,
required this.offerToken,
required this.productType,
});
/// Factory for creating a [UserChoiceDetailsProductWrapper] from a [Map] with the record details.
factory UserChoiceDetailsProductWrapper.fromJson(Map<String, dynamic> map) =>
_$UserChoiceDetailsProductWrapperFromJson(map);
/// Creates a JSON representation of this product.
Map<String, dynamic> toJson() =>
_$UserChoiceDetailsProductWrapperToJson(this);
/// Returns the id of the product being purchased.
@JsonKey(defaultValue: '')
final String id;
/// Returns the offer token that was passed in launchBillingFlow to purchase the product.
@JsonKey(defaultValue: '')
final String offerToken;
/// Returns the [ProductType] of the product being purchased.
final ProductType productType;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is UserChoiceDetailsProductWrapper &&
other.id == id &&
other.offerToken == offerToken &&
other.productType == productType;
}
@override
int get hashCode => Object.hash(
id,
offerToken,
productType,
);
}

View File

@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_choice_details_wrapper.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserChoiceDetailsWrapper _$UserChoiceDetailsWrapperFromJson(Map json) =>
UserChoiceDetailsWrapper(
originalExternalTransactionId:
json['originalExternalTransactionId'] as String? ?? '',
externalTransactionToken:
json['externalTransactionToken'] as String? ?? '',
products: (json['products'] as List<dynamic>?)
?.map((e) => UserChoiceDetailsProductWrapper.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
[],
);
Map<String, dynamic> _$UserChoiceDetailsWrapperToJson(
UserChoiceDetailsWrapper instance) =>
<String, dynamic>{
'originalExternalTransactionId': instance.originalExternalTransactionId,
'externalTransactionToken': instance.externalTransactionToken,
'products': instance.products.map((e) => e.toJson()).toList(),
};
UserChoiceDetailsProductWrapper _$UserChoiceDetailsProductWrapperFromJson(
Map json) =>
UserChoiceDetailsProductWrapper(
id: json['id'] as String? ?? '',
offerToken: json['offerToken'] as String? ?? '',
productType:
const ProductTypeConverter().fromJson(json['productType'] as String?),
);
Map<String, dynamic> _$UserChoiceDetailsProductWrapperToJson(
UserChoiceDetailsProductWrapper instance) =>
<String, dynamic>{
'id': instance.id,
'offerToken': instance.offerToken,
'productType': const ProductTypeConverter().toJson(instance.productType),
};

View File

@ -2,19 +2,34 @@
// 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:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';
import '../billing_client_wrappers.dart';
import '../in_app_purchase_android.dart';
import 'billing_client_wrappers/billing_config_wrapper.dart';
import 'types/translator.dart';
/// Contains InApp Purchase features that are only available on PlayStore.
class InAppPurchaseAndroidPlatformAddition
extends InAppPurchasePlatformAddition {
/// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied
/// `BillingClientManager` to provide Android specific features.
InAppPurchaseAndroidPlatformAddition(this._billingClientManager);
InAppPurchaseAndroidPlatformAddition(this._billingClientManager) {
_billingClientManager.userChoiceDetailsStream
.map(Translator.convertToUserChoiceDetails)
.listen(_userChoiceDetailsStreamController.add);
}
final StreamController<GooglePlayUserChoiceDetails>
_userChoiceDetailsStreamController =
StreamController<GooglePlayUserChoiceDetails>.broadcast();
/// [GooglePlayUserChoiceDetails] emits each time user selects alternative billing.
late final Stream<GooglePlayUserChoiceDetails> userChoiceDetailsStream =
_userChoiceDetailsStreamController.stream;
/// Whether pending purchase is enabled.
///

View File

@ -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 'package:flutter/foundation.dart';
/// Data structure representing a UserChoiceDetails.
///
/// This wraps [`com.android.billingclient.api.UserChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails)
@immutable
class GooglePlayUserChoiceDetails {
/// Creates a new Google Play specific user choice billing details object with
/// the provided details.
const GooglePlayUserChoiceDetails({
required this.originalExternalTransactionId,
required this.externalTransactionToken,
required this.products,
});
/// Returns the external transaction Id of the originating subscription, if
/// the purchase is a subscription upgrade/downgrade.
final String originalExternalTransactionId;
/// Returns a token that represents the user's prospective purchase via
/// user choice alternative billing.
final String externalTransactionToken;
/// Returns a list of [GooglePlayUserChoiceDetailsProduct] to be purchased in
/// the user choice alternative billing flow.
final List<GooglePlayUserChoiceDetailsProduct> products;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is GooglePlayUserChoiceDetails &&
other.originalExternalTransactionId == originalExternalTransactionId &&
other.externalTransactionToken == externalTransactionToken &&
listEquals(other.products, products);
}
@override
int get hashCode => Object.hash(
originalExternalTransactionId,
externalTransactionToken,
products.hashCode,
);
}
/// Data structure representing a UserChoiceDetails product.
///
/// This wraps [`com.android.billingclient.api.UserChoiceDetails.Product`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails.Product)
@immutable
class GooglePlayUserChoiceDetailsProduct {
/// Creates UserChoiceDetailsProduct.
const GooglePlayUserChoiceDetailsProduct(
{required this.id, required this.offerToken, required this.productType});
/// Returns the id of the product being purchased.
final String id;
/// Returns the offer token that was passed in launchBillingFlow to purchase the product.
final String offerToken;
/// Returns the [GooglePlayProductType] of the product being purchased.
final GooglePlayProductType productType;
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is GooglePlayUserChoiceDetailsProduct &&
other.id == id &&
other.offerToken == offerToken &&
other.productType == productType;
}
@override
int get hashCode => Object.hash(
id,
offerToken,
productType,
);
}
/// This wraps [`com.android.billingclient.api.BillingClient.ProductType`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.ProductType)
enum GooglePlayProductType {
/// A Product type for Android apps in-app products.
inapp,
/// A Product type for Android apps subscriptions.
subs
}

View File

@ -0,0 +1,47 @@
// 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:flutter/foundation.dart';
import '../../billing_client_wrappers.dart';
import 'google_play_user_choice_details.dart';
/// Class used to convert cross process object into api expose objects.
class Translator {
Translator._();
/// Converts from [UserChoiceDetailsWrapper] to [GooglePlayUserChoiceDetails].
static GooglePlayUserChoiceDetails convertToUserChoiceDetails(
UserChoiceDetailsWrapper detailsWrapper) {
return GooglePlayUserChoiceDetails(
originalExternalTransactionId:
detailsWrapper.originalExternalTransactionId,
externalTransactionToken: detailsWrapper.externalTransactionToken,
products: detailsWrapper.products
.map((UserChoiceDetailsProductWrapper e) =>
convertToUserChoiceDetailsProduct(e))
.toList());
}
/// Converts from [UserChoiceDetailsProductWrapper] to [GooglePlayUserChoiceDetailsProduct].
@visibleForTesting
static GooglePlayUserChoiceDetailsProduct convertToUserChoiceDetailsProduct(
UserChoiceDetailsProductWrapper productWrapper) {
return GooglePlayUserChoiceDetailsProduct(
id: productWrapper.id,
offerToken: productWrapper.offerToken,
productType: convertToPlayProductType(productWrapper.productType));
}
/// Coverts from [ProductType] to [GooglePlayProductType].
@visibleForTesting
static GooglePlayProductType convertToPlayProductType(ProductType type) {
switch (type) {
case ProductType.inapp:
return GooglePlayProductType.inapp;
case ProductType.subs:
return GooglePlayProductType.subs;
}
}
}

View File

@ -6,4 +6,5 @@ export 'change_subscription_param.dart';
export 'google_play_product_details.dart';
export 'google_play_purchase_details.dart';
export 'google_play_purchase_param.dart';
export 'google_play_user_choice_details.dart';
export 'query_purchase_details_response.dart';

View File

@ -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/packages/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.3.1
version: 0.3.2
environment:
sdk: ^3.1.0

View File

@ -138,5 +138,32 @@ void main() {
expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1));
expect(stubPlatform.countPreviousCalls(endConnectionCall), equals(1));
});
test(
'Emits UserChoiceDetailsWrapper when onUserChoiceAlternativeBilling is called',
() async {
connectedCompleter.complete();
// Ensures all asynchronous connected code finishes.
await manager.runWithClientNonRetryable((_) async {});
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
originalExternalTransactionId: 'TransactionId',
externalTransactionToken: 'TransactionToken',
products: <UserChoiceDetailsProductWrapper>[
UserChoiceDetailsProductWrapper(
id: 'id1',
offerToken: 'offerToken1',
productType: ProductType.inapp),
UserChoiceDetailsProductWrapper(
id: 'id2',
offerToken: 'offerToken2',
productType: ProductType.inapp),
],
);
final Future<UserChoiceDetailsWrapper> detailsFuture =
manager.userChoiceDetailsStream.first;
manager.onUserChoiceAlternativeBilling(expected);
expect(await detailsFuture, expected);
});
});
}

View File

@ -2,6 +2,8 @@
// 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_test/flutter_test.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
@ -37,7 +39,8 @@ void main() {
.setMockMethodCallHandler(channel, stubPlatform.fakeMethodCallHandler));
setUp(() {
billingClient = BillingClient((PurchasesResultWrapper _) {});
billingClient = BillingClient(
(PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {});
stubPlatform.reset();
});
@ -114,7 +117,7 @@ void main() {
}));
});
test('passes billingChoiceMode when set', () async {
test('passes billingChoiceMode alternativeBillingOnly when set', () async {
const String debugMessage = 'dummy message';
const BillingResponse responseCode = BillingResponse.developerError;
stubPlatform.addResponse(
@ -136,6 +139,91 @@ void main() {
}));
});
test('passes billingChoiceMode userChoiceBilling when set', () async {
const String debugMessage = 'dummy message';
const BillingResponse responseCode = BillingResponse.ok;
stubPlatform.addResponse(
name: methodName,
value: <String, dynamic>{
'responseCode': const BillingResponseConverter().toJson(responseCode),
'debugMessage': debugMessage,
},
);
final Completer<UserChoiceDetailsWrapper> completer =
Completer<UserChoiceDetailsWrapper>();
billingClient = BillingClient((PurchasesResultWrapper _) {},
(UserChoiceDetailsWrapper details) => completer.complete(details));
stubPlatform.reset();
await billingClient.startConnection(
onBillingServiceDisconnected: () {},
billingChoiceMode: BillingChoiceMode.userChoiceBilling);
final MethodCall call = stubPlatform.previousCallMatching(methodName);
expect(
call.arguments,
equals(<dynamic, dynamic>{
'handle': 0,
'billingChoiceMode': 2,
}));
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
originalExternalTransactionId: 'TransactionId',
externalTransactionToken: 'TransactionToken',
products: <UserChoiceDetailsProductWrapper>[
UserChoiceDetailsProductWrapper(
id: 'id1',
offerToken: 'offerToken1',
productType: ProductType.inapp),
UserChoiceDetailsProductWrapper(
id: 'id2',
offerToken: 'offerToken2',
productType: ProductType.inapp),
],
);
await billingClient.callHandler(
MethodCall(kUserSelectedAlternativeBilling, expected.toJson()));
expect(completer.isCompleted, isTrue);
expect(await completer.future, expected);
});
test('UserChoiceDetailsWrapper searilization check', () async {
// Test ensures that changes to UserChoiceDetailsWrapper#toJson are
// compatible with code in Translator.java.
const String transactionIdKey = 'originalExternalTransactionId';
const String transactionTokenKey = 'externalTransactionToken';
const String productsKey = 'products';
const String productIdKey = 'id';
const String productOfferTokenKey = 'offerToken';
const String productTypeKey = 'productType';
const UserChoiceDetailsProductWrapper expectedProduct1 =
UserChoiceDetailsProductWrapper(
id: 'id1',
offerToken: 'offerToken1',
productType: ProductType.inapp);
const UserChoiceDetailsProductWrapper expectedProduct2 =
UserChoiceDetailsProductWrapper(
id: 'id2',
offerToken: 'offerToken2',
productType: ProductType.inapp);
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
originalExternalTransactionId: 'TransactionId',
externalTransactionToken: 'TransactionToken',
products: <UserChoiceDetailsProductWrapper>[
expectedProduct1,
expectedProduct2,
],
);
final Map<String, dynamic> detailsJson = expected.toJson();
expect(detailsJson.keys, contains(transactionIdKey));
expect(detailsJson.keys, contains(transactionTokenKey));
expect(detailsJson.keys, contains(productsKey));
final Map<String, dynamic> productJson = expectedProduct1.toJson();
expect(productJson, contains(productIdKey));
expect(productJson, contains(productOfferTokenKey));
expect(productJson, contains(productTypeKey));
});
test('handles method channel returning null', () async {
stubPlatform.addResponse(
name: methodName,

View File

@ -9,6 +9,7 @@ import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:in_app_purchase_android/src/billing_client_wrappers/billing_config_wrapper.dart';
import 'package:in_app_purchase_android/src/channel.dart';
import 'package:in_app_purchase_android/src/types/translator.dart';
import 'billing_client_wrappers/billing_client_wrapper_test.dart';
import 'billing_client_wrappers/purchase_wrapper_test.dart';
@ -281,4 +282,28 @@ void main() {
expect(arguments['feature'], equals('subscriptions'));
});
});
group('userChoiceDetails', () {
test('called', () async {
final Future<GooglePlayUserChoiceDetails> futureDetails =
iapAndroidPlatformAddition.userChoiceDetailsStream.first;
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
originalExternalTransactionId: 'TransactionId',
externalTransactionToken: 'TransactionToken',
products: <UserChoiceDetailsProductWrapper>[
UserChoiceDetailsProductWrapper(
id: 'id1',
offerToken: 'offerToken1',
productType: ProductType.inapp),
UserChoiceDetailsProductWrapper(
id: 'id2',
offerToken: 'offerToken2',
productType: ProductType.inapp),
],
);
manager.onUserChoiceAlternativeBilling(expected);
expect(
await futureDetails, Translator.convertToUserChoiceDetails(expected));
});
});
}

View File

@ -0,0 +1,71 @@
// 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_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/src/types/google_play_user_choice_details.dart';
import 'package:in_app_purchase_android/src/types/translator.dart';
import 'package:test/test.dart';
void main() {
group('Translator ', () {
test('convertToPlayProductType', () {
expect(Translator.convertToPlayProductType(ProductType.inapp),
GooglePlayProductType.inapp);
expect(Translator.convertToPlayProductType(ProductType.subs),
GooglePlayProductType.subs);
expect(GooglePlayProductType.values.length, ProductType.values.length);
});
test('convertToUserChoiceDetailsProduct', () {
const GooglePlayUserChoiceDetailsProduct expected =
GooglePlayUserChoiceDetailsProduct(
id: 'id',
offerToken: 'offerToken',
productType: GooglePlayProductType.inapp);
expect(
Translator.convertToUserChoiceDetailsProduct(
UserChoiceDetailsProductWrapper(
id: expected.id,
offerToken: expected.offerToken,
productType: ProductType.inapp)),
expected);
});
test('convertToUserChoiceDetailsProduct', () {
const GooglePlayUserChoiceDetailsProduct expectedProduct1 =
GooglePlayUserChoiceDetailsProduct(
id: 'id1',
offerToken: 'offerToken1',
productType: GooglePlayProductType.inapp);
const GooglePlayUserChoiceDetailsProduct expectedProduct2 =
GooglePlayUserChoiceDetailsProduct(
id: 'id2',
offerToken: 'offerToken2',
productType: GooglePlayProductType.subs);
const GooglePlayUserChoiceDetails expected = GooglePlayUserChoiceDetails(
originalExternalTransactionId: 'originalExternalTransactionId',
externalTransactionToken: 'externalTransactionToken',
products: <GooglePlayUserChoiceDetailsProduct>[
expectedProduct1,
expectedProduct2
]);
expect(
Translator.convertToUserChoiceDetails(UserChoiceDetailsWrapper(
originalExternalTransactionId:
expected.originalExternalTransactionId,
externalTransactionToken: expected.externalTransactionToken,
products: <UserChoiceDetailsProductWrapper>[
UserChoiceDetailsProductWrapper(
id: expectedProduct1.id,
offerToken: expectedProduct1.offerToken,
productType: ProductType.inapp),
UserChoiceDetailsProductWrapper(
id: expectedProduct2.id,
offerToken: expectedProduct2.offerToken,
productType: ProductType.subs),
])),
expected);
});
});
}