[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. * Updates minimum supported SDK version to Flutter 3.13/Dart 3.1.
## 0.3.1 ## 0.3.1

View File

@ -6,7 +6,9 @@ package io.flutter.plugins.inapppurchase;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
/** Responsible for creating a {@link BillingClient} object. */ /** Responsible for creating a {@link BillingClient} object. */
@ -22,5 +24,8 @@ interface BillingClientFactory {
* @return The {@link BillingClient} object that is created. * @return The {@link BillingClient} object that is created.
*/ */
BillingClient createBillingClient( 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 android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient; 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.plugin.common.MethodChannel;
import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode; import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode;
@ -15,11 +18,34 @@ final class BillingClientFactoryImpl implements BillingClientFactory {
@Override @Override
public BillingClient createBillingClient( 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(); BillingClient.Builder builder = BillingClient.newBuilder(context).enablePendingPurchases();
if (billingChoiceMode == BillingChoiceMode.ALTERNATIVE_BILLING_ONLY) { switch (billingChoiceMode) {
// https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app case BillingChoiceMode.ALTERNATIVE_BILLING_ONLY:
builder.enableAlternativeBillingOnly(); // 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(); 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.fromProductDetailsList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; 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 static io.flutter.plugins.inapppurchase.Translator.toProductList;
import android.app.Activity; 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.QueryProductDetailsParams.Product;
import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams; import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
import java.util.ArrayList; import java.util.ArrayList;
@ -72,6 +74,8 @@ class MethodCallHandlerImpl
"BillingClient#createAlternativeBillingOnlyReportingDetails()"; "BillingClient#createAlternativeBillingOnlyReportingDetails()";
static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG = static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG =
"BillingClient#showAlternativeBillingOnlyInformationDialog()"; "BillingClient#showAlternativeBillingOnlyInformationDialog()";
static final String USER_SELECTED_ALTERNATIVE_BILLING =
"UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)";
private MethodNames() {} private MethodNames() {}
} }
@ -94,6 +98,7 @@ class MethodCallHandlerImpl
static final class BillingChoiceMode { static final class BillingChoiceMode {
static final int PLAY_BILLING_ONLY = 0; static final int PLAY_BILLING_ONLY = 0;
static final int ALTERNATIVE_BILLING_ONLY = 1; 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 // TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new
@ -507,9 +512,10 @@ class MethodCallHandlerImpl
private void startConnection( private void startConnection(
final int handle, final MethodChannel.Result result, int billingChoiceMode) { final int handle, final MethodChannel.Result result, int billingChoiceMode) {
if (billingClient == null) { if (billingClient == null) {
UserChoiceBillingListener listener = getUserChoiceBillingListener(billingChoiceMode);
billingClient = billingClient =
billingClientFactory.createBillingClient( billingClientFactory.createBillingClient(
applicationContext, methodChannel, billingChoiceMode); applicationContext, methodChannel, billingChoiceMode, listener);
} }
billingClient.startConnection( 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) { private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) {
if (billingClientError(result)) { if (billingClientError(result)) {
return; return;

View File

@ -14,6 +14,8 @@ import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.QueryProductDetailsParams; 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.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Currency; import java.util.Currency;
@ -233,6 +235,34 @@ import java.util.Map;
return info; 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. */ /** Converter from {@link BillingResult} and {@link BillingConfig} to map. */
static HashMap<String, Object> fromBillingConfig( static HashMap<String, Object> fromBillingConfig(
BillingResult result, BillingConfig billingConfig) { 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.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.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG;
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.START_CONNECTION; 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.PluginPurchaseListener.ON_PURCHASES_UPDATED;
import static io.flutter.plugins.inapppurchase.Translator.fromAlternativeBillingOnlyReportingDetails; import static io.flutter.plugins.inapppurchase.Translator.fromAlternativeBillingOnlyReportingDetails;
import static io.flutter.plugins.inapppurchase.Translator.fromBillingConfig; 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.fromProductDetailsList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; 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.Arrays.asList;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static java.util.Collections.unmodifiableList; 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.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams; 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.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; 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.Constructor;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -92,6 +97,7 @@ import org.junit.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Captor; import org.mockito.Captor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import org.mockito.Spy; import org.mockito.Spy;
import org.mockito.stubbing.Answer; import org.mockito.stubbing.Answer;
@ -107,15 +113,23 @@ public class MethodCallHandlerTest {
@Mock ActivityPluginBinding mockActivityPluginBinding; @Mock ActivityPluginBinding mockActivityPluginBinding;
@Captor ArgumentCaptor<HashMap<String, Object>> resultCaptor; @Captor ArgumentCaptor<HashMap<String, Object>> resultCaptor;
private final int DEFAULT_HANDLE = 1;
@Before @Before
public void setUp() { public void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
// Use the same client no matter if alternative billing is enabled or not. // Use the same client no matter if alternative billing is enabled or not.
when(factory.createBillingClient( when(factory.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY)) context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null))
.thenReturn(mockBillingClient); .thenReturn(mockBillingClient);
when(factory.createBillingClient( 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); .thenReturn(mockBillingClient);
methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory); methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
when(mockActivityPluginBinding.getActivity()).thenReturn(activity); when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
@ -164,7 +178,7 @@ public class MethodCallHandlerTest {
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY); mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
verify(result, never()).success(any()); verify(result, never()).success(any());
verify(factory, times(1)) verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY); .createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
BillingResult billingResult = BillingResult billingResult =
BillingResult.newBuilder() BillingResult.newBuilder()
@ -183,7 +197,7 @@ public class MethodCallHandlerTest {
verify(result, never()).success(any()); verify(result, never()).success(any());
verify(factory, times(1)) verify(factory, times(1))
.createBillingClient( .createBillingClient(
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY); context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null);
BillingResult billingResult = BillingResult billingResult =
BillingResult.newBuilder() BillingResult.newBuilder()
@ -209,7 +223,7 @@ public class MethodCallHandlerTest {
methodChannelHandler.onMethodCall(call, result); methodChannelHandler.onMethodCall(call, result);
verify(result, never()).success(any()); verify(result, never()).success(any());
verify(factory, times(1)) verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY); .createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
BillingResult billingResult = BillingResult billingResult =
BillingResult.newBuilder() BillingResult.newBuilder()
@ -221,6 +235,106 @@ public class MethodCallHandlerTest {
verify(result, times(1)).success(fromBillingResult(billingResult)); 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 @Test
public void startConnection_multipleCalls() { public void startConnection_multipleCalls() {
Map<String, Object> arguments = new HashMap<>(); Map<String, Object> arguments = new HashMap<>();
@ -1071,7 +1185,7 @@ public class MethodCallHandlerTest {
*/ */
private ArgumentCaptor<BillingClientStateListener> mockStartConnection(int billingChoiceMode) { private ArgumentCaptor<BillingClientStateListener> mockStartConnection(int billingChoiceMode) {
Map<String, Object> arguments = new HashMap<>(); Map<String, Object> arguments = new HashMap<>();
arguments.put(MethodArgs.HANDLE, 1); arguments.put(MethodArgs.HANDLE, DEFAULT_HANDLE);
arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode); arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode);
MethodCall call = new MethodCall(START_CONNECTION, arguments); MethodCall call = new MethodCall(START_CONNECTION, arguments);
ArgumentCaptor<BillingClientStateListener> captor = ArgumentCaptor<BillingClientStateListener> captor =

View File

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

View File

@ -44,6 +44,7 @@ class _MyAppState extends State<_MyApp> {
final InAppPurchasePlatform _inAppPurchasePlatform = final InAppPurchasePlatform _inAppPurchasePlatform =
InAppPurchasePlatform.instance; InAppPurchasePlatform.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription; late StreamSubscription<List<PurchaseDetails>> _subscription;
late StreamSubscription<GooglePlayUserChoiceDetails> _userChoiceDetailsStream;
List<String> _notFoundIds = <String>[]; List<String> _notFoundIds = <String>[];
List<ProductDetails> _products = <ProductDetails>[]; List<ProductDetails> _products = <ProductDetails>[];
List<PurchaseDetails> _purchases = <PurchaseDetails>[]; List<PurchaseDetails> _purchases = <PurchaseDetails>[];
@ -56,6 +57,7 @@ class _MyAppState extends State<_MyApp> {
bool _purchasePending = false; bool _purchasePending = false;
bool _loading = true; bool _loading = true;
String? _queryProductError; String? _queryProductError;
final List<String> _userChoiceDetailsList = <String>[];
@override @override
void initState() { void initState() {
@ -70,6 +72,19 @@ class _MyAppState extends State<_MyApp> {
// handle error here. // handle error here.
}); });
initStoreInfo(); 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(); super.initState();
} }
@ -134,6 +149,8 @@ class _MyAppState extends State<_MyApp> {
@override @override
void dispose() { void dispose() {
_subscription.cancel(); _subscription.cancel();
_userChoiceDetailsStream.cancel();
_userChoiceDetailsList.clear();
super.dispose(); super.dispose();
} }
@ -149,6 +166,7 @@ class _MyAppState extends State<_MyApp> {
_buildConsumableBox(), _buildConsumableBox(),
const _FeatureCard(), const _FeatureCard(),
_buildFetchButtons(), _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() { Card _buildProductList() {
if (_loading) { if (_loading) {
return const Card( return const Card(
@ -537,6 +575,15 @@ class _MyAppState extends State<_MyApp> {
// handle invalid purchase here if _verifyPurchase` failed. // 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( Future<void> _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async { List<PurchaseDetails> purchaseDetailsList) async {
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) { 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/product_wrapper.dart';
export 'src/billing_client_wrappers/purchase_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/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 'billing_client_wrapper.dart';
import 'purchase_wrapper.dart'; import 'purchase_wrapper.dart';
import 'user_choice_details_wrapper.dart';
/// Abstraction of result of [BillingClient] operation that includes /// Abstraction of result of [BillingClient] operation that includes
/// a [BillingResponse]. /// a [BillingResponse].
@ -37,6 +38,13 @@ class BillingClientManager {
_connect(); _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]. /// Stream of `onPurchasesUpdated` events from the [BillingClient].
/// ///
/// This is a broadcast stream, so it can be listened to multiple times. /// 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] /// In order to access the [BillingClient], use [runWithClient]
/// and [runWithClientNonRetryable] methods. /// and [runWithClientNonRetryable] methods.
@visibleForTesting @visibleForTesting
late final BillingClient client = BillingClient(_onPurchasesUpdated); late final BillingClient client =
BillingClient(_onPurchasesUpdated, onUserChoiceAlternativeBilling);
final StreamController<PurchasesResultWrapper> _purchasesUpdatedController = final StreamController<PurchasesResultWrapper> _purchasesUpdatedController =
StreamController<PurchasesResultWrapper>.broadcast(); StreamController<PurchasesResultWrapper>.broadcast();
final StreamController<UserChoiceDetailsWrapper>
_userChoiceAlternativeBillingController =
StreamController<UserChoiceDetailsWrapper>.broadcast();
BillingChoiceMode _billingChoiceMode; BillingChoiceMode _billingChoiceMode;
bool _isConnecting = false; bool _isConnecting = false;
@ -113,12 +125,14 @@ class BillingClientManager {
/// After calling [dispose]: /// After calling [dispose]:
/// - Further connection attempts will not be made. /// - Further connection attempts will not be made.
/// - [purchasesUpdatedStream] will be closed. /// - [purchasesUpdatedStream] will be closed.
/// - [userChoiceDetailsStream] will be closed.
/// - Calls to [runWithClient] and [runWithClientNonRetryable] will throw. /// - Calls to [runWithClient] and [runWithClientNonRetryable] will throw.
void dispose() { void dispose() {
_debugAssertNotDisposed(); _debugAssertNotDisposed();
_isDisposed = true; _isDisposed = true;
client.endConnection(); client.endConnection();
_purchasesUpdatedController.close(); _purchasesUpdatedController.close();
_userChoiceAlternativeBillingController.close();
} }
/// Ends connection to [BillingClient] and reconnects with [billingChoiceMode]. /// 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.', '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 @visibleForTesting
const String kOnPurchasesUpdated = const String kOnPurchasesUpdated =
'PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List<Purchase>)'; 'PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List<Purchase>)';
/// Method identifier for the userSelectedAlternativeBilling method channel method.
@visibleForTesting
const String kUserSelectedAlternativeBilling =
'UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)';
const String _kOnBillingServiceDisconnected = const String _kOnBillingServiceDisconnected =
'BillingClientStateListener#onBillingServiceDisconnected()'; 'BillingClientStateListener#onBillingServiceDisconnected()';
@ -40,6 +46,10 @@ const String _kOnBillingServiceDisconnected =
typedef PurchasesUpdatedListener = void Function( typedef PurchasesUpdatedListener = void Function(
PurchasesResultWrapper purchasesResult); 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 /// This class can be used directly instead of [InAppPurchaseConnection] to call
/// Play-specific billing APIs. /// Play-specific billing APIs.
/// ///
@ -60,11 +70,16 @@ typedef PurchasesUpdatedListener = void Function(
/// transparently. /// transparently.
class BillingClient { class BillingClient {
/// Creates a billing client. /// Creates a billing client.
BillingClient(PurchasesUpdatedListener onPurchasesUpdated) { BillingClient(PurchasesUpdatedListener onPurchasesUpdated,
UserSelectedAlternativeBillingListener? alternativeBillingListener) {
channel.setMethodCallHandler(callHandler); channel.setMethodCallHandler(callHandler);
_callbacks[kOnPurchasesUpdated] = <PurchasesUpdatedListener>[ _callbacks[kOnPurchasesUpdated] = <PurchasesUpdatedListener>[
onPurchasesUpdated onPurchasesUpdated
]; ];
_callbacks[kUserSelectedAlternativeBilling] = alternativeBillingListener ==
null
? <UserSelectedAlternativeBillingListener>[]
: <UserSelectedAlternativeBillingListener>[alternativeBillingListener];
} }
// Occasionally methods in the native layer require a Dart callback to be // Occasionally methods in the native layer require a Dart callback to be
@ -114,7 +129,8 @@ class BillingClient {
BillingChoiceMode.playBillingOnly}) async { BillingChoiceMode.playBillingOnly}) async {
final List<Function> disconnectCallbacks = final List<Function> disconnectCallbacks =
_callbacks[_kOnBillingServiceDisconnected] ??= <Function>[]; _callbacks[_kOnBillingServiceDisconnected] ??= <Function>[];
disconnectCallbacks.add(onBillingServiceDisconnected); _callbacks[_kOnBillingServiceDisconnected]
?.add(onBillingServiceDisconnected);
return BillingResultWrapper.fromJson((await channel return BillingResultWrapper.fromJson((await channel
.invokeMapMethod<String, dynamic>( .invokeMapMethod<String, dynamic>(
'BillingClient#startConnection(BillingClientStateListener)', 'BillingClient#startConnection(BillingClientStateListener)',
@ -412,6 +428,15 @@ class BillingClient {
_callbacks[_kOnBillingServiceDisconnected]! _callbacks[_kOnBillingServiceDisconnected]!
.cast<OnBillingServiceDisconnected>(); .cast<OnBillingServiceDisconnected>();
onDisconnected[handle](); 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) @JsonValue(-2)
featureNotSupported, 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) @JsonValue(-1)
serviceDisconnected, serviceDisconnected,
@ -490,8 +515,8 @@ enum BillingResponse {
/// Plugin concept to cover billing modes. /// Plugin concept to cover billing modes.
/// ///
/// [playBillingOnly] (google play billing only). /// [playBillingOnly] (google Play billing only).
/// [alternativeBillingOnly] (app provided billing with reporting to play). /// [alternativeBillingOnly] (app provided billing with reporting to Play).
@JsonEnum(alwaysCreate: true) @JsonEnum(alwaysCreate: true)
enum BillingChoiceMode { enum BillingChoiceMode {
// WARNING: Changes to this class need to be reflected in our generated code. // 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 // Values must match what is used in
// in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java // 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) @JsonValue(0)
playBillingOnly, playBillingOnly,
/// Billing through app provided flow. /// Billing through app provided flow.
@JsonValue(1) @JsonValue(1)
alternativeBillingOnly, alternativeBillingOnly,
/// Users can choose Play billing or alternative billing.
@JsonValue(2)
userChoiceBilling,
} }
/// Serializer for [BillingChoiceMode]. /// Serializer for [BillingChoiceMode].

View File

@ -25,6 +25,7 @@ const _$BillingResponseEnumMap = {
const _$BillingChoiceModeEnumMap = { const _$BillingChoiceModeEnumMap = {
BillingChoiceMode.playBillingOnly: 0, BillingChoiceMode.playBillingOnly: 0,
BillingChoiceMode.alternativeBillingOnly: 1, BillingChoiceMode.alternativeBillingOnly: 1,
BillingChoiceMode.userChoiceBilling: 2,
}; };
const _$ProductTypeEnumMap = { 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 // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';
import '../billing_client_wrappers.dart'; import '../billing_client_wrappers.dart';
import '../in_app_purchase_android.dart'; import '../in_app_purchase_android.dart';
import 'billing_client_wrappers/billing_config_wrapper.dart'; import 'billing_client_wrappers/billing_config_wrapper.dart';
import 'types/translator.dart';
/// Contains InApp Purchase features that are only available on PlayStore. /// Contains InApp Purchase features that are only available on PlayStore.
class InAppPurchaseAndroidPlatformAddition class InAppPurchaseAndroidPlatformAddition
extends InAppPurchasePlatformAddition { extends InAppPurchasePlatformAddition {
/// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied
/// `BillingClientManager` to provide Android specific features. /// `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. /// 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_product_details.dart';
export 'google_play_purchase_details.dart'; export 'google_play_purchase_details.dart';
export 'google_play_purchase_param.dart'; export 'google_play_purchase_param.dart';
export 'google_play_user_choice_details.dart';
export 'query_purchase_details_response.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. 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 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 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: environment:
sdk: ^3.1.0 sdk: ^3.1.0

View File

@ -138,5 +138,32 @@ void main() {
expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1));
expect(stubPlatform.countPreviousCalls(endConnectionCall), 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 // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart';
@ -37,7 +39,8 @@ void main() {
.setMockMethodCallHandler(channel, stubPlatform.fakeMethodCallHandler)); .setMockMethodCallHandler(channel, stubPlatform.fakeMethodCallHandler));
setUp(() { setUp(() {
billingClient = BillingClient((PurchasesResultWrapper _) {}); billingClient = BillingClient(
(PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {});
stubPlatform.reset(); 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 String debugMessage = 'dummy message';
const BillingResponse responseCode = BillingResponse.developerError; const BillingResponse responseCode = BillingResponse.developerError;
stubPlatform.addResponse( 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 { test('handles method channel returning null', () async {
stubPlatform.addResponse( stubPlatform.addResponse(
name: methodName, 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/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/billing_client_wrappers/billing_config_wrapper.dart';
import 'package:in_app_purchase_android/src/channel.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/billing_client_wrapper_test.dart';
import 'billing_client_wrappers/purchase_wrapper_test.dart'; import 'billing_client_wrappers/purchase_wrapper_test.dart';
@ -281,4 +282,28 @@ void main() {
expect(arguments['feature'], equals('subscriptions')); 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);
});
});
}