From d489d84c359b5ad6849ece9b97c75e1925ed762d Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Fri, 8 Mar 2024 17:48:39 -0500 Subject: [PATCH] [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. --- .../in_app_purchase_android/CHANGELOG.md | 3 +- .../inapppurchase/BillingClientFactory.java | 7 +- .../BillingClientFactoryImpl.java | 34 ++++- .../inapppurchase/MethodCallHandlerImpl.java | 21 ++- .../plugins/inapppurchase/Translator.java | 30 ++++ .../inapppurchase/MethodCallHandlerTest.java | 126 ++++++++++++++++- .../in_app_purchase_test.dart | 3 +- .../example/lib/main.dart | 47 +++++++ .../lib/billing_client_wrappers.dart | 1 + .../billing_client_manager.dart | 26 +++- .../billing_client_wrapper.dart | 41 +++++- .../billing_client_wrapper.g.dart | 1 + .../user_choice_details_wrapper.dart | 131 ++++++++++++++++++ .../user_choice_details_wrapper.g.dart | 45 ++++++ ...pp_purchase_android_platform_addition.dart | 17 ++- .../google_play_user_choice_details.dart | 101 ++++++++++++++ .../lib/src/types/translator.dart | 47 +++++++ .../lib/src/types/types.dart | 1 + .../in_app_purchase_android/pubspec.yaml | 2 +- .../billing_client_manager_test.dart | 27 ++++ .../billing_client_wrapper_test.dart | 92 +++++++++++- ...rchase_android_platform_addition_test.dart | 25 ++++ .../test/types/translator_test.dart | 71 ++++++++++ 23 files changed, 874 insertions(+), 25 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_user_choice_details.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/translator.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/types/translator_test.dart 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 896e9f958995b..223d0441a7889 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -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 diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java index 9324c9367ee87..10ce08d63fc8f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java @@ -6,7 +6,9 @@ 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); } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java index c6911f8314a32..4c53951a495a3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java @@ -6,7 +6,10 @@ 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) { - // https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app - builder.enableAlternativeBillingOnly(); + 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(); } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index ecf88747779ac..0343a8a9d40bf 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -10,6 +10,7 @@ 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.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 @@ static final class MethodNames { "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 @@ private MethodArgs() {} 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 @@ private void getConnectionState(final MethodChannel.Result result) { 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 @@ public void onBillingServiceDisconnected() { }); } + @Nullable + private UserChoiceBillingListener getUserChoiceBillingListener(int billingChoiceMode) { + UserChoiceBillingListener listener = null; + if (billingChoiceMode == BillingChoiceMode.USER_CHOICE_BILLING) { + listener = + userChoiceDetails -> { + final Map 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; diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 8c80b1797eda2..f9e91659bc230 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -14,6 +14,8 @@ 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 @@ static HashMap fromBillingResult(BillingResult billingResult) { return info; } + static HashMap fromUserChoiceDetails(UserChoiceDetails userChoiceDetails) { + HashMap info = new HashMap<>(); + info.put("externalTransactionToken", userChoiceDetails.getExternalTransactionToken()); + info.put("originalExternalTransactionId", userChoiceDetails.getOriginalExternalTransactionId()); + info.put("products", fromProductsList(userChoiceDetails.getProducts())); + return info; + } + + static List> fromProductsList(List productsList) { + if (productsList.isEmpty()) { + return Collections.emptyList(); + } + ArrayList> output = new ArrayList<>(); + for (Product product : productsList) { + output.add(fromProduct(product)); + } + return output; + } + + static HashMap fromProduct(Product product) { + HashMap 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 fromBillingConfig( BillingResult result, BillingConfig billingConfig) { diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index bfdf928ca5114..42acbf5f86422 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -20,6 +20,7 @@ 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.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.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 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.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> 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, null)) .thenReturn(mockBillingClient); when(factory.createBillingClient( - context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY)) + 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 void startConnection() { 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 void startConnectionAlternativeBillingOnly() { 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 void startConnectionAlternativeBillingUnset() { 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 void startConnectionAlternativeBillingUnset() { verify(result, times(1)).success(fromBillingResult(billingResult)); } + @Test + public void startConnectionUserChoiceBilling() { + ArgumentCaptor captor = + mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING); + ArgumentCaptor 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 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 expectedInvocation = new HashMap<>(); + expectedInvocation.put("handle", DEFAULT_HANDLE); + verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation); + Mockito.reset(result, mockMethodChannel, mockBillingClient); + + // Second connection. + ArgumentCaptor captor2 = + mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING); + ArgumentCaptor 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 arguments = new HashMap<>(); @@ -1071,7 +1185,7 @@ private ArgumentCaptor mockStartConnection() { */ private ArgumentCaptor mockStartConnection(int billingChoiceMode) { Map 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 captor = diff --git a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart index 568becd7188ce..8ed5de2ce4b64 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart @@ -27,7 +27,8 @@ void main() { late final BillingClient billingClient; setUpAll(() { - billingClient = BillingClient((PurchasesResultWrapper _) {}); + billingClient = BillingClient( + (PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {}); }); testWidgets('BillingClient.acknowledgePurchase', diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index fbae24a5dcfd6..89883a1564ad2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -44,6 +44,7 @@ class _MyAppState extends State<_MyApp> { final InAppPurchasePlatform _inAppPurchasePlatform = InAppPurchasePlatform.instance; late StreamSubscription> _subscription; + late StreamSubscription _userChoiceDetailsStream; List _notFoundIds = []; List _products = []; List _purchases = []; @@ -56,6 +57,7 @@ class _MyAppState extends State<_MyApp> { bool _purchasePending = false; bool _loading = true; String? _queryProductError; + final List _userChoiceDetailsList = []; @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 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 entries = []; + 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: [ + 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 deliverUserChoiceDetails( + GooglePlayUserChoiceDetails details) async { + final String detailDescription = + '${details.externalTransactionToken}, ${details.originalExternalTransactionId}, ${details.products.length}'; + setState(() { + _userChoiceDetailsList.add(detailDescription); + }); + } + Future _listenToPurchaseUpdated( List purchaseDetailsList) async { for (final PurchaseDetails purchaseDetails in purchaseDetailsList) { 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 8a53b95e9a7e2..508b80e05f7d3 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 @@ -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'; 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 789ba5e01cc35..e9a3b50ce5cb4 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,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 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 _purchasesUpdatedController = StreamController.broadcast(); + final StreamController + _userChoiceAlternativeBillingController = + StreamController.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); + } } 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 15dc4217fe699..b3684d8177f4a 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 @@ -18,6 +18,12 @@ part 'billing_client_wrapper.g.dart'; @visibleForTesting const String kOnPurchasesUpdated = 'PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List)'; + +/// 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] = [ onPurchasesUpdated ]; + _callbacks[kUserSelectedAlternativeBilling] = alternativeBillingListener == + null + ? [] + : [alternativeBillingListener]; } // Occasionally methods in the native layer require a Dart callback to be @@ -114,7 +129,8 @@ class BillingClient { BillingChoiceMode.playBillingOnly}) async { final List disconnectCallbacks = _callbacks[_kOnBillingServiceDisconnected] ??= []; - disconnectCallbacks.add(onBillingServiceDisconnected); + _callbacks[_kOnBillingServiceDisconnected] + ?.add(onBillingServiceDisconnected); return BillingResultWrapper.fromJson((await channel .invokeMapMethod( 'BillingClient#startConnection(BillingClientStateListener)', @@ -412,6 +428,15 @@ class BillingClient { _callbacks[_kOnBillingServiceDisconnected]! .cast(); onDisconnected[handle](); + case kUserSelectedAlternativeBilling: + if (_callbacks[kUserSelectedAlternativeBilling]!.isNotEmpty) { + final UserSelectedAlternativeBillingListener listener = + _callbacks[kUserSelectedAlternativeBilling]!.first + as UserSelectedAlternativeBillingListener; + listener(UserChoiceDetailsWrapper.fromJson( + (call.arguments as Map) + .cast())); + } } } } @@ -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]. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart index eb7b41afce142..0768c35deec88 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart @@ -25,6 +25,7 @@ const _$BillingResponseEnumMap = { const _$BillingChoiceModeEnumMap = { BillingChoiceMode.playBillingOnly: 0, BillingChoiceMode.alternativeBillingOnly: 1, + BillingChoiceMode.userChoiceBilling: 2, }; const _$ProductTypeEnumMap = { diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.dart new file mode 100644 index 0000000000000..abdc31a178b09 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.dart @@ -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 map) => + _$UserChoiceDetailsWrapperFromJson(map); + + /// Creates a JSON representation of this product. + Map 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: []) + final List 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 map) => + _$UserChoiceDetailsProductWrapperFromJson(map); + + /// Creates a JSON representation of this product. + Map 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, + ); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.g.dart new file mode 100644 index 0000000000000..669d263588d6c --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/user_choice_details_wrapper.g.dart @@ -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?) + ?.map((e) => UserChoiceDetailsProductWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); + +Map _$UserChoiceDetailsWrapperToJson( + UserChoiceDetailsWrapper instance) => + { + '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 _$UserChoiceDetailsProductWrapperToJson( + UserChoiceDetailsProductWrapper instance) => + { + 'id': instance.id, + 'offerToken': instance.offerToken, + 'productType': const ProductTypeConverter().toJson(instance.productType), + }; 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 732840802d28a..e8c6f07a0bc72 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 @@ -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 + _userChoiceDetailsStreamController = + StreamController.broadcast(); + + /// [GooglePlayUserChoiceDetails] emits each time user selects alternative billing. + late final Stream userChoiceDetailsStream = + _userChoiceDetailsStreamController.stream; /// Whether pending purchase is enabled. /// diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_user_choice_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_user_choice_details.dart new file mode 100644 index 0000000000000..97553b3457597 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_user_choice_details.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 '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 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 +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/translator.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/translator.dart new file mode 100644 index 0000000000000..f6461afc2c27f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/translator.dart @@ -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; + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart index 0a43425f6e940..5116ac54e0614 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart @@ -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'; 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 7ce2e0080ce2f..5f57552f2336d 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/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 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 b53bb5c96c3fe..81874d75d8f98 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 @@ -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( + id: 'id1', + offerToken: 'offerToken1', + productType: ProductType.inapp), + UserChoiceDetailsProductWrapper( + id: 'id2', + offerToken: 'offerToken2', + productType: ProductType.inapp), + ], + ); + final Future detailsFuture = + manager.userChoiceDetailsStream.first; + manager.onUserChoiceAlternativeBilling(expected); + expect(await detailsFuture, expected); + }); }); } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 3ffcd8d5d08ed..92f14e2958ac8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -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: { + 'responseCode': const BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + final Completer completer = + Completer(); + + 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({ + 'handle': 0, + 'billingChoiceMode': 2, + })); + const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper( + originalExternalTransactionId: 'TransactionId', + externalTransactionToken: 'TransactionToken', + products: [ + 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: [ + expectedProduct1, + expectedProduct2, + ], + ); + final Map detailsJson = expected.toJson(); + expect(detailsJson.keys, contains(transactionIdKey)); + expect(detailsJson.keys, contains(transactionTokenKey)); + expect(detailsJson.keys, contains(productsKey)); + + final Map 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, 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 718bd3cdde1c4..bf6612542bd5d 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 @@ -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 futureDetails = + iapAndroidPlatformAddition.userChoiceDetailsStream.first; + const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper( + originalExternalTransactionId: 'TransactionId', + externalTransactionToken: 'TransactionToken', + products: [ + 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)); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/types/translator_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/types/translator_test.dart new file mode 100644 index 0000000000000..7e19c4b415728 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/types/translator_test.dart @@ -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: [ + expectedProduct1, + expectedProduct2 + ]); + + expect( + Translator.convertToUserChoiceDetails(UserChoiceDetailsWrapper( + originalExternalTransactionId: + expected.originalExternalTransactionId, + externalTransactionToken: expected.externalTransactionToken, + products: [ + UserChoiceDetailsProductWrapper( + id: expectedProduct1.id, + offerToken: expectedProduct1.offerToken, + productType: ProductType.inapp), + UserChoiceDetailsProductWrapper( + id: expectedProduct2.id, + offerToken: expectedProduct2.offerToken, + productType: ProductType.subs), + ])), + expected); + }); + }); +}