mirror of
https://github.com/flutter/packages.git
synced 2025-06-30 23:03:11 +08:00
[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:
@ -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
|
||||
|
@ -6,7 +6,9 @@ package io.flutter.plugins.inapppurchase;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.android.billingclient.api.BillingClient;
|
||||
import com.android.billingclient.api.UserChoiceBillingListener;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
/** Responsible for creating a {@link BillingClient} object. */
|
||||
@ -22,5 +24,8 @@ interface BillingClientFactory {
|
||||
* @return The {@link BillingClient} object that is created.
|
||||
*/
|
||||
BillingClient createBillingClient(
|
||||
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode);
|
||||
@NonNull Context context,
|
||||
@NonNull MethodChannel channel,
|
||||
int billingChoiceMode,
|
||||
@Nullable UserChoiceBillingListener userChoiceBillingListener);
|
||||
}
|
||||
|
@ -6,7 +6,10 @@ package io.flutter.plugins.inapppurchase;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.android.billingclient.api.BillingClient;
|
||||
import com.android.billingclient.api.UserChoiceBillingListener;
|
||||
import io.flutter.Log;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode;
|
||||
|
||||
@ -15,11 +18,34 @@ final class BillingClientFactoryImpl implements BillingClientFactory {
|
||||
|
||||
@Override
|
||||
public BillingClient createBillingClient(
|
||||
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode) {
|
||||
@NonNull Context context,
|
||||
@NonNull MethodChannel channel,
|
||||
int billingChoiceMode,
|
||||
@Nullable UserChoiceBillingListener userChoiceBillingListener) {
|
||||
BillingClient.Builder builder = BillingClient.newBuilder(context).enablePendingPurchases();
|
||||
if (billingChoiceMode == BillingChoiceMode.ALTERNATIVE_BILLING_ONLY) {
|
||||
// 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();
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.toProductList;
|
||||
|
||||
import android.app.Activity;
|
||||
@ -33,6 +34,7 @@ import com.android.billingclient.api.QueryProductDetailsParams;
|
||||
import com.android.billingclient.api.QueryProductDetailsParams.Product;
|
||||
import com.android.billingclient.api.QueryPurchaseHistoryParams;
|
||||
import com.android.billingclient.api.QueryPurchasesParams;
|
||||
import com.android.billingclient.api.UserChoiceBillingListener;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import java.util.ArrayList;
|
||||
@ -72,6 +74,8 @@ class MethodCallHandlerImpl
|
||||
"BillingClient#createAlternativeBillingOnlyReportingDetails()";
|
||||
static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG =
|
||||
"BillingClient#showAlternativeBillingOnlyInformationDialog()";
|
||||
static final String USER_SELECTED_ALTERNATIVE_BILLING =
|
||||
"UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)";
|
||||
|
||||
private MethodNames() {}
|
||||
}
|
||||
@ -94,6 +98,7 @@ class MethodCallHandlerImpl
|
||||
static final class BillingChoiceMode {
|
||||
static final int PLAY_BILLING_ONLY = 0;
|
||||
static final int ALTERNATIVE_BILLING_ONLY = 1;
|
||||
static final int USER_CHOICE_BILLING = 2;
|
||||
}
|
||||
|
||||
// TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new
|
||||
@ -507,9 +512,10 @@ class MethodCallHandlerImpl
|
||||
private void startConnection(
|
||||
final int handle, final MethodChannel.Result result, int billingChoiceMode) {
|
||||
if (billingClient == null) {
|
||||
UserChoiceBillingListener listener = getUserChoiceBillingListener(billingChoiceMode);
|
||||
billingClient =
|
||||
billingClientFactory.createBillingClient(
|
||||
applicationContext, methodChannel, billingChoiceMode);
|
||||
applicationContext, methodChannel, billingChoiceMode, listener);
|
||||
}
|
||||
|
||||
billingClient.startConnection(
|
||||
@ -537,6 +543,19 @@ class MethodCallHandlerImpl
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private UserChoiceBillingListener getUserChoiceBillingListener(int billingChoiceMode) {
|
||||
UserChoiceBillingListener listener = null;
|
||||
if (billingChoiceMode == BillingChoiceMode.USER_CHOICE_BILLING) {
|
||||
listener =
|
||||
userChoiceDetails -> {
|
||||
final Map<String, Object> arguments = fromUserChoiceDetails(userChoiceDetails);
|
||||
methodChannel.invokeMethod(MethodNames.USER_SELECTED_ALTERNATIVE_BILLING, arguments);
|
||||
};
|
||||
}
|
||||
return listener;
|
||||
}
|
||||
|
||||
private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) {
|
||||
if (billingClientError(result)) {
|
||||
return;
|
||||
|
@ -14,6 +14,8 @@ import com.android.billingclient.api.ProductDetails;
|
||||
import com.android.billingclient.api.Purchase;
|
||||
import com.android.billingclient.api.PurchaseHistoryRecord;
|
||||
import com.android.billingclient.api.QueryProductDetailsParams;
|
||||
import com.android.billingclient.api.UserChoiceDetails;
|
||||
import com.android.billingclient.api.UserChoiceDetails.Product;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Currency;
|
||||
@ -233,6 +235,34 @@ import java.util.Map;
|
||||
return info;
|
||||
}
|
||||
|
||||
static HashMap<String, Object> fromUserChoiceDetails(UserChoiceDetails userChoiceDetails) {
|
||||
HashMap<String, Object> info = new HashMap<>();
|
||||
info.put("externalTransactionToken", userChoiceDetails.getExternalTransactionToken());
|
||||
info.put("originalExternalTransactionId", userChoiceDetails.getOriginalExternalTransactionId());
|
||||
info.put("products", fromProductsList(userChoiceDetails.getProducts()));
|
||||
return info;
|
||||
}
|
||||
|
||||
static List<HashMap<String, Object>> fromProductsList(List<Product> productsList) {
|
||||
if (productsList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
ArrayList<HashMap<String, Object>> output = new ArrayList<>();
|
||||
for (Product product : productsList) {
|
||||
output.add(fromProduct(product));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static HashMap<String, Object> fromProduct(Product product) {
|
||||
HashMap<String, Object> info = new HashMap<>();
|
||||
info.put("id", product.getId());
|
||||
info.put("offerToken", product.getOfferToken());
|
||||
info.put("productType", product.getType());
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Converter from {@link BillingResult} and {@link BillingConfig} to map. */
|
||||
static HashMap<String, Object> fromBillingConfig(
|
||||
BillingResult result, BillingConfig billingConfig) {
|
||||
|
@ -20,6 +20,7 @@ import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames
|
||||
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC;
|
||||
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG;
|
||||
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.START_CONNECTION;
|
||||
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.USER_SELECTED_ALTERNATIVE_BILLING;
|
||||
import static io.flutter.plugins.inapppurchase.PluginPurchaseListener.ON_PURCHASES_UPDATED;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromAlternativeBillingOnlyReportingDetails;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromBillingConfig;
|
||||
@ -27,6 +28,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
|
||||
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
@ -73,6 +75,8 @@ import com.android.billingclient.api.PurchasesResponseListener;
|
||||
import com.android.billingclient.api.QueryProductDetailsParams;
|
||||
import com.android.billingclient.api.QueryPurchaseHistoryParams;
|
||||
import com.android.billingclient.api.QueryPurchasesParams;
|
||||
import com.android.billingclient.api.UserChoiceBillingListener;
|
||||
import com.android.billingclient.api.UserChoiceDetails;
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
@ -82,6 +86,7 @@ import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodArgs;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -92,6 +97,7 @@ import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.stubbing.Answer;
|
||||
@ -107,15 +113,23 @@ public class MethodCallHandlerTest {
|
||||
@Mock ActivityPluginBinding mockActivityPluginBinding;
|
||||
@Captor ArgumentCaptor<HashMap<String, Object>> resultCaptor;
|
||||
|
||||
private final int DEFAULT_HANDLE = 1;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
// Use the same client no matter if alternative billing is enabled or not.
|
||||
when(factory.createBillingClient(
|
||||
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY))
|
||||
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null))
|
||||
.thenReturn(mockBillingClient);
|
||||
when(factory.createBillingClient(
|
||||
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY))
|
||||
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null))
|
||||
.thenReturn(mockBillingClient);
|
||||
when(factory.createBillingClient(
|
||||
any(Context.class),
|
||||
any(MethodChannel.class),
|
||||
eq(BillingChoiceMode.USER_CHOICE_BILLING),
|
||||
any(UserChoiceBillingListener.class)))
|
||||
.thenReturn(mockBillingClient);
|
||||
methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
|
||||
when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
|
||||
@ -164,7 +178,7 @@ public class MethodCallHandlerTest {
|
||||
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
|
||||
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
|
||||
|
||||
BillingResult billingResult =
|
||||
BillingResult.newBuilder()
|
||||
@ -183,7 +197,7 @@ public class MethodCallHandlerTest {
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(
|
||||
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY);
|
||||
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null);
|
||||
|
||||
BillingResult billingResult =
|
||||
BillingResult.newBuilder()
|
||||
@ -209,7 +223,7 @@ public class MethodCallHandlerTest {
|
||||
methodChannelHandler.onMethodCall(call, result);
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
|
||||
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
|
||||
|
||||
BillingResult billingResult =
|
||||
BillingResult.newBuilder()
|
||||
@ -221,6 +235,106 @@ public class MethodCallHandlerTest {
|
||||
verify(result, times(1)).success(fromBillingResult(billingResult));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startConnectionUserChoiceBilling() {
|
||||
ArgumentCaptor<BillingClientStateListener> captor =
|
||||
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
|
||||
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
|
||||
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(
|
||||
any(Context.class),
|
||||
any(MethodChannel.class),
|
||||
eq(BillingChoiceMode.USER_CHOICE_BILLING),
|
||||
billingCaptor.capture());
|
||||
|
||||
BillingResult billingResult =
|
||||
BillingResult.newBuilder()
|
||||
.setResponseCode(100)
|
||||
.setDebugMessage("dummy debug message")
|
||||
.build();
|
||||
captor.getValue().onBillingSetupFinished(billingResult);
|
||||
|
||||
verify(result, times(1)).success(fromBillingResult(billingResult));
|
||||
UserChoiceDetails details = mock(UserChoiceDetails.class);
|
||||
final String externalTransactionToken = "someLongTokenId1234";
|
||||
final String originalTransactionId = "originalTransactionId123456";
|
||||
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
|
||||
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
|
||||
when(details.getProducts()).thenReturn(Collections.emptyList());
|
||||
billingCaptor.getValue().userSelectedAlternativeBilling(details);
|
||||
|
||||
verify(mockMethodChannel, times(1))
|
||||
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void userChoiceBillingOnSecondConnection() {
|
||||
// First connection.
|
||||
ArgumentCaptor<BillingClientStateListener> captor1 =
|
||||
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
|
||||
|
||||
BillingResult billingResult1 =
|
||||
BillingResult.newBuilder()
|
||||
.setResponseCode(100)
|
||||
.setDebugMessage("dummy debug message")
|
||||
.build();
|
||||
final BillingClientStateListener stateListener = captor1.getValue();
|
||||
stateListener.onBillingSetupFinished(billingResult1);
|
||||
verify(result, times(1)).success(fromBillingResult(billingResult1));
|
||||
Mockito.reset(result, mockMethodChannel, mockBillingClient);
|
||||
|
||||
// Disconnect
|
||||
MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
|
||||
methodChannelHandler.onMethodCall(disconnectCall, result);
|
||||
|
||||
// Verify that the client is disconnected and that the OnDisconnect callback has
|
||||
// been triggered
|
||||
verify(result, times(1)).success(any());
|
||||
verify(mockBillingClient, times(1)).endConnection();
|
||||
stateListener.onBillingServiceDisconnected();
|
||||
Map<String, Integer> expectedInvocation = new HashMap<>();
|
||||
expectedInvocation.put("handle", DEFAULT_HANDLE);
|
||||
verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation);
|
||||
Mockito.reset(result, mockMethodChannel, mockBillingClient);
|
||||
|
||||
// Second connection.
|
||||
ArgumentCaptor<BillingClientStateListener> captor2 =
|
||||
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
|
||||
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
|
||||
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
|
||||
verify(result, never()).success(any());
|
||||
verify(factory, times(1))
|
||||
.createBillingClient(
|
||||
any(Context.class),
|
||||
any(MethodChannel.class),
|
||||
eq(BillingChoiceMode.USER_CHOICE_BILLING),
|
||||
billingCaptor.capture());
|
||||
|
||||
BillingResult billingResult2 =
|
||||
BillingResult.newBuilder()
|
||||
.setResponseCode(100)
|
||||
.setDebugMessage("dummy debug message")
|
||||
.build();
|
||||
captor2.getValue().onBillingSetupFinished(billingResult2);
|
||||
|
||||
verify(result, times(1)).success(fromBillingResult(billingResult2));
|
||||
UserChoiceDetails details = mock(UserChoiceDetails.class);
|
||||
final String externalTransactionToken = "someLongTokenId1234";
|
||||
final String originalTransactionId = "originalTransactionId123456";
|
||||
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
|
||||
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
|
||||
when(details.getProducts()).thenReturn(Collections.emptyList());
|
||||
billingCaptor.getValue().userSelectedAlternativeBilling(details);
|
||||
|
||||
verify(mockMethodChannel, times(1))
|
||||
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startConnection_multipleCalls() {
|
||||
Map<String, Object> arguments = new HashMap<>();
|
||||
@ -1071,7 +1185,7 @@ public class MethodCallHandlerTest {
|
||||
*/
|
||||
private ArgumentCaptor<BillingClientStateListener> mockStartConnection(int billingChoiceMode) {
|
||||
Map<String, Object> arguments = new HashMap<>();
|
||||
arguments.put(MethodArgs.HANDLE, 1);
|
||||
arguments.put(MethodArgs.HANDLE, DEFAULT_HANDLE);
|
||||
arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode);
|
||||
MethodCall call = new MethodCall(START_CONNECTION, arguments);
|
||||
ArgumentCaptor<BillingClientStateListener> captor =
|
||||
|
@ -27,7 +27,8 @@ void main() {
|
||||
late final BillingClient billingClient;
|
||||
|
||||
setUpAll(() {
|
||||
billingClient = BillingClient((PurchasesResultWrapper _) {});
|
||||
billingClient = BillingClient(
|
||||
(PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {});
|
||||
});
|
||||
|
||||
testWidgets('BillingClient.acknowledgePurchase',
|
||||
|
@ -44,6 +44,7 @@ class _MyAppState extends State<_MyApp> {
|
||||
final InAppPurchasePlatform _inAppPurchasePlatform =
|
||||
InAppPurchasePlatform.instance;
|
||||
late StreamSubscription<List<PurchaseDetails>> _subscription;
|
||||
late StreamSubscription<GooglePlayUserChoiceDetails> _userChoiceDetailsStream;
|
||||
List<String> _notFoundIds = <String>[];
|
||||
List<ProductDetails> _products = <ProductDetails>[];
|
||||
List<PurchaseDetails> _purchases = <PurchaseDetails>[];
|
||||
@ -56,6 +57,7 @@ class _MyAppState extends State<_MyApp> {
|
||||
bool _purchasePending = false;
|
||||
bool _loading = true;
|
||||
String? _queryProductError;
|
||||
final List<String> _userChoiceDetailsList = <String>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -70,6 +72,19 @@ class _MyAppState extends State<_MyApp> {
|
||||
// handle error here.
|
||||
});
|
||||
initStoreInfo();
|
||||
final InAppPurchaseAndroidPlatformAddition addition =
|
||||
InAppPurchasePlatformAddition.instance!
|
||||
as InAppPurchaseAndroidPlatformAddition;
|
||||
final Stream<GooglePlayUserChoiceDetails> userChoiceDetailsUpdated =
|
||||
addition.userChoiceDetailsStream;
|
||||
_userChoiceDetailsStream =
|
||||
userChoiceDetailsUpdated.listen((GooglePlayUserChoiceDetails details) {
|
||||
deliverUserChoiceDetails(details);
|
||||
}, onDone: () {
|
||||
_userChoiceDetailsStream.cancel();
|
||||
}, onError: (Object error) {
|
||||
// handle error here.
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@ -134,6 +149,8 @@ class _MyAppState extends State<_MyApp> {
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
_userChoiceDetailsStream.cancel();
|
||||
_userChoiceDetailsList.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -149,6 +166,7 @@ class _MyAppState extends State<_MyApp> {
|
||||
_buildConsumableBox(),
|
||||
const _FeatureCard(),
|
||||
_buildFetchButtons(),
|
||||
_buildUserChoiceDetailsDisplay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -326,6 +344,26 @@ class _MyAppState extends State<_MyApp> {
|
||||
);
|
||||
}
|
||||
|
||||
Card _buildUserChoiceDetailsDisplay() {
|
||||
const ListTile header = ListTile(title: Text('UserChoiceDetails'));
|
||||
final List<Widget> entries = <ListTile>[];
|
||||
for (final String item in _userChoiceDetailsList) {
|
||||
entries.add(ListTile(
|
||||
title: Text(item,
|
||||
style: TextStyle(color: ThemeData.light().colorScheme.primary)),
|
||||
subtitle: Text(_countryCode)));
|
||||
}
|
||||
return Card(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
header,
|
||||
const Divider(),
|
||||
...entries,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Card _buildProductList() {
|
||||
if (_loading) {
|
||||
return const Card(
|
||||
@ -537,6 +575,15 @@ class _MyAppState extends State<_MyApp> {
|
||||
// handle invalid purchase here if _verifyPurchase` failed.
|
||||
}
|
||||
|
||||
Future<void> deliverUserChoiceDetails(
|
||||
GooglePlayUserChoiceDetails details) async {
|
||||
final String detailDescription =
|
||||
'${details.externalTransactionToken}, ${details.originalExternalTransactionId}, ${details.products.length}';
|
||||
setState(() {
|
||||
_userChoiceDetailsList.add(detailDescription);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _listenToPurchaseUpdated(
|
||||
List<PurchaseDetails> purchaseDetailsList) async {
|
||||
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
|
||||
|
@ -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';
|
||||
|
@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'billing_client_wrapper.dart';
|
||||
import 'purchase_wrapper.dart';
|
||||
import 'user_choice_details_wrapper.dart';
|
||||
|
||||
/// Abstraction of result of [BillingClient] operation that includes
|
||||
/// a [BillingResponse].
|
||||
@ -37,6 +38,13 @@ class BillingClientManager {
|
||||
_connect();
|
||||
}
|
||||
|
||||
/// Stream of `userSelectedAlternativeBilling` events from the [BillingClient].
|
||||
///
|
||||
/// This is a broadcast stream, so it can be listened to multiple times.
|
||||
/// A "done" event will be sent after [dispose] is called.
|
||||
late final Stream<UserChoiceDetailsWrapper> userChoiceDetailsStream =
|
||||
_userChoiceAlternativeBillingController.stream;
|
||||
|
||||
/// Stream of `onPurchasesUpdated` events from the [BillingClient].
|
||||
///
|
||||
/// This is a broadcast stream, so it can be listened to multiple times.
|
||||
@ -49,10 +57,14 @@ class BillingClientManager {
|
||||
/// In order to access the [BillingClient], use [runWithClient]
|
||||
/// and [runWithClientNonRetryable] methods.
|
||||
@visibleForTesting
|
||||
late final BillingClient client = BillingClient(_onPurchasesUpdated);
|
||||
late final BillingClient client =
|
||||
BillingClient(_onPurchasesUpdated, onUserChoiceAlternativeBilling);
|
||||
|
||||
final StreamController<PurchasesResultWrapper> _purchasesUpdatedController =
|
||||
StreamController<PurchasesResultWrapper>.broadcast();
|
||||
final StreamController<UserChoiceDetailsWrapper>
|
||||
_userChoiceAlternativeBillingController =
|
||||
StreamController<UserChoiceDetailsWrapper>.broadcast();
|
||||
|
||||
BillingChoiceMode _billingChoiceMode;
|
||||
bool _isConnecting = false;
|
||||
@ -113,12 +125,14 @@ class BillingClientManager {
|
||||
/// After calling [dispose]:
|
||||
/// - Further connection attempts will not be made.
|
||||
/// - [purchasesUpdatedStream] will be closed.
|
||||
/// - [userChoiceDetailsStream] will be closed.
|
||||
/// - Calls to [runWithClient] and [runWithClientNonRetryable] will throw.
|
||||
void dispose() {
|
||||
_debugAssertNotDisposed();
|
||||
_isDisposed = true;
|
||||
client.endConnection();
|
||||
_purchasesUpdatedController.close();
|
||||
_userChoiceAlternativeBillingController.close();
|
||||
}
|
||||
|
||||
/// Ends connection to [BillingClient] and reconnects with [billingChoiceMode].
|
||||
@ -168,4 +182,14 @@ class BillingClientManager {
|
||||
'called dispose() on a BillingClientManager, it can no longer be used.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Callback passed to [BillingClient] to use when customer chooses
|
||||
/// alternative billing.
|
||||
@visibleForTesting
|
||||
void onUserChoiceAlternativeBilling(UserChoiceDetailsWrapper event) {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
_userChoiceAlternativeBillingController.add(event);
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,12 @@ part 'billing_client_wrapper.g.dart';
|
||||
@visibleForTesting
|
||||
const String kOnPurchasesUpdated =
|
||||
'PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List<Purchase>)';
|
||||
|
||||
/// Method identifier for the userSelectedAlternativeBilling method channel method.
|
||||
@visibleForTesting
|
||||
const String kUserSelectedAlternativeBilling =
|
||||
'UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)';
|
||||
|
||||
const String _kOnBillingServiceDisconnected =
|
||||
'BillingClientStateListener#onBillingServiceDisconnected()';
|
||||
|
||||
@ -40,6 +46,10 @@ const String _kOnBillingServiceDisconnected =
|
||||
typedef PurchasesUpdatedListener = void Function(
|
||||
PurchasesResultWrapper purchasesResult);
|
||||
|
||||
/// Wraps a [UserChoiceBillingListener](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceBillingListener)
|
||||
typedef UserSelectedAlternativeBillingListener = void Function(
|
||||
UserChoiceDetailsWrapper userChoiceDetailsWrapper);
|
||||
|
||||
/// This class can be used directly instead of [InAppPurchaseConnection] to call
|
||||
/// Play-specific billing APIs.
|
||||
///
|
||||
@ -60,11 +70,16 @@ typedef PurchasesUpdatedListener = void Function(
|
||||
/// transparently.
|
||||
class BillingClient {
|
||||
/// Creates a billing client.
|
||||
BillingClient(PurchasesUpdatedListener onPurchasesUpdated) {
|
||||
BillingClient(PurchasesUpdatedListener onPurchasesUpdated,
|
||||
UserSelectedAlternativeBillingListener? alternativeBillingListener) {
|
||||
channel.setMethodCallHandler(callHandler);
|
||||
_callbacks[kOnPurchasesUpdated] = <PurchasesUpdatedListener>[
|
||||
onPurchasesUpdated
|
||||
];
|
||||
_callbacks[kUserSelectedAlternativeBilling] = alternativeBillingListener ==
|
||||
null
|
||||
? <UserSelectedAlternativeBillingListener>[]
|
||||
: <UserSelectedAlternativeBillingListener>[alternativeBillingListener];
|
||||
}
|
||||
|
||||
// Occasionally methods in the native layer require a Dart callback to be
|
||||
@ -114,7 +129,8 @@ class BillingClient {
|
||||
BillingChoiceMode.playBillingOnly}) async {
|
||||
final List<Function> disconnectCallbacks =
|
||||
_callbacks[_kOnBillingServiceDisconnected] ??= <Function>[];
|
||||
disconnectCallbacks.add(onBillingServiceDisconnected);
|
||||
_callbacks[_kOnBillingServiceDisconnected]
|
||||
?.add(onBillingServiceDisconnected);
|
||||
return BillingResultWrapper.fromJson((await channel
|
||||
.invokeMapMethod<String, dynamic>(
|
||||
'BillingClient#startConnection(BillingClientStateListener)',
|
||||
@ -412,6 +428,15 @@ class BillingClient {
|
||||
_callbacks[_kOnBillingServiceDisconnected]!
|
||||
.cast<OnBillingServiceDisconnected>();
|
||||
onDisconnected[handle]();
|
||||
case kUserSelectedAlternativeBilling:
|
||||
if (_callbacks[kUserSelectedAlternativeBilling]!.isNotEmpty) {
|
||||
final UserSelectedAlternativeBillingListener listener =
|
||||
_callbacks[kUserSelectedAlternativeBilling]!.first
|
||||
as UserSelectedAlternativeBillingListener;
|
||||
listener(UserChoiceDetailsWrapper.fromJson(
|
||||
(call.arguments as Map<dynamic, dynamic>)
|
||||
.cast<String, dynamic>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -443,7 +468,7 @@ enum BillingResponse {
|
||||
@JsonValue(-2)
|
||||
featureNotSupported,
|
||||
|
||||
/// The play Store service is not connected now - potentially transient state.
|
||||
/// The Play Store service is not connected now - potentially transient state.
|
||||
@JsonValue(-1)
|
||||
serviceDisconnected,
|
||||
|
||||
@ -490,8 +515,8 @@ enum BillingResponse {
|
||||
|
||||
/// Plugin concept to cover billing modes.
|
||||
///
|
||||
/// [playBillingOnly] (google play billing only).
|
||||
/// [alternativeBillingOnly] (app provided billing with reporting to play).
|
||||
/// [playBillingOnly] (google Play billing only).
|
||||
/// [alternativeBillingOnly] (app provided billing with reporting to Play).
|
||||
@JsonEnum(alwaysCreate: true)
|
||||
enum BillingChoiceMode {
|
||||
// WARNING: Changes to this class need to be reflected in our generated code.
|
||||
@ -500,13 +525,17 @@ enum BillingChoiceMode {
|
||||
// Values must match what is used in
|
||||
// in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java
|
||||
|
||||
/// Billing through google play. Default state.
|
||||
/// Billing through google Play. Default state.
|
||||
@JsonValue(0)
|
||||
playBillingOnly,
|
||||
|
||||
/// Billing through app provided flow.
|
||||
@JsonValue(1)
|
||||
alternativeBillingOnly,
|
||||
|
||||
/// Users can choose Play billing or alternative billing.
|
||||
@JsonValue(2)
|
||||
userChoiceBilling,
|
||||
}
|
||||
|
||||
/// Serializer for [BillingChoiceMode].
|
||||
|
@ -25,6 +25,7 @@ const _$BillingResponseEnumMap = {
|
||||
const _$BillingChoiceModeEnumMap = {
|
||||
BillingChoiceMode.playBillingOnly: 0,
|
||||
BillingChoiceMode.alternativeBillingOnly: 1,
|
||||
BillingChoiceMode.userChoiceBilling: 2,
|
||||
};
|
||||
|
||||
const _$ProductTypeEnumMap = {
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
@ -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),
|
||||
};
|
@ -2,19 +2,34 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart';
|
||||
|
||||
import '../billing_client_wrappers.dart';
|
||||
import '../in_app_purchase_android.dart';
|
||||
import 'billing_client_wrappers/billing_config_wrapper.dart';
|
||||
import 'types/translator.dart';
|
||||
|
||||
/// Contains InApp Purchase features that are only available on PlayStore.
|
||||
class InAppPurchaseAndroidPlatformAddition
|
||||
extends InAppPurchasePlatformAddition {
|
||||
/// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied
|
||||
/// `BillingClientManager` to provide Android specific features.
|
||||
InAppPurchaseAndroidPlatformAddition(this._billingClientManager);
|
||||
InAppPurchaseAndroidPlatformAddition(this._billingClientManager) {
|
||||
_billingClientManager.userChoiceDetailsStream
|
||||
.map(Translator.convertToUserChoiceDetails)
|
||||
.listen(_userChoiceDetailsStreamController.add);
|
||||
}
|
||||
|
||||
final StreamController<GooglePlayUserChoiceDetails>
|
||||
_userChoiceDetailsStreamController =
|
||||
StreamController<GooglePlayUserChoiceDetails>.broadcast();
|
||||
|
||||
/// [GooglePlayUserChoiceDetails] emits each time user selects alternative billing.
|
||||
late final Stream<GooglePlayUserChoiceDetails> userChoiceDetailsStream =
|
||||
_userChoiceDetailsStreamController.stream;
|
||||
|
||||
/// Whether pending purchase is enabled.
|
||||
///
|
||||
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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
|
||||
|
@ -138,5 +138,32 @@ void main() {
|
||||
expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1));
|
||||
expect(stubPlatform.countPreviousCalls(endConnectionCall), equals(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'Emits UserChoiceDetailsWrapper when onUserChoiceAlternativeBilling is called',
|
||||
() async {
|
||||
connectedCompleter.complete();
|
||||
// Ensures all asynchronous connected code finishes.
|
||||
await manager.runWithClientNonRetryable((_) async {});
|
||||
|
||||
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
|
||||
originalExternalTransactionId: 'TransactionId',
|
||||
externalTransactionToken: 'TransactionToken',
|
||||
products: <UserChoiceDetailsProductWrapper>[
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id1',
|
||||
offerToken: 'offerToken1',
|
||||
productType: ProductType.inapp),
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id2',
|
||||
offerToken: 'offerToken2',
|
||||
productType: ProductType.inapp),
|
||||
],
|
||||
);
|
||||
final Future<UserChoiceDetailsWrapper> detailsFuture =
|
||||
manager.userChoiceDetailsStream.first;
|
||||
manager.onUserChoiceAlternativeBilling(expected);
|
||||
expect(await detailsFuture, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
||||
@ -37,7 +39,8 @@ void main() {
|
||||
.setMockMethodCallHandler(channel, stubPlatform.fakeMethodCallHandler));
|
||||
|
||||
setUp(() {
|
||||
billingClient = BillingClient((PurchasesResultWrapper _) {});
|
||||
billingClient = BillingClient(
|
||||
(PurchasesResultWrapper _) {}, (UserChoiceDetailsWrapper _) {});
|
||||
stubPlatform.reset();
|
||||
});
|
||||
|
||||
@ -114,7 +117,7 @@ void main() {
|
||||
}));
|
||||
});
|
||||
|
||||
test('passes billingChoiceMode when set', () async {
|
||||
test('passes billingChoiceMode alternativeBillingOnly when set', () async {
|
||||
const String debugMessage = 'dummy message';
|
||||
const BillingResponse responseCode = BillingResponse.developerError;
|
||||
stubPlatform.addResponse(
|
||||
@ -136,6 +139,91 @@ void main() {
|
||||
}));
|
||||
});
|
||||
|
||||
test('passes billingChoiceMode userChoiceBilling when set', () async {
|
||||
const String debugMessage = 'dummy message';
|
||||
const BillingResponse responseCode = BillingResponse.ok;
|
||||
stubPlatform.addResponse(
|
||||
name: methodName,
|
||||
value: <String, dynamic>{
|
||||
'responseCode': const BillingResponseConverter().toJson(responseCode),
|
||||
'debugMessage': debugMessage,
|
||||
},
|
||||
);
|
||||
final Completer<UserChoiceDetailsWrapper> completer =
|
||||
Completer<UserChoiceDetailsWrapper>();
|
||||
|
||||
billingClient = BillingClient((PurchasesResultWrapper _) {},
|
||||
(UserChoiceDetailsWrapper details) => completer.complete(details));
|
||||
stubPlatform.reset();
|
||||
await billingClient.startConnection(
|
||||
onBillingServiceDisconnected: () {},
|
||||
billingChoiceMode: BillingChoiceMode.userChoiceBilling);
|
||||
final MethodCall call = stubPlatform.previousCallMatching(methodName);
|
||||
expect(
|
||||
call.arguments,
|
||||
equals(<dynamic, dynamic>{
|
||||
'handle': 0,
|
||||
'billingChoiceMode': 2,
|
||||
}));
|
||||
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
|
||||
originalExternalTransactionId: 'TransactionId',
|
||||
externalTransactionToken: 'TransactionToken',
|
||||
products: <UserChoiceDetailsProductWrapper>[
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id1',
|
||||
offerToken: 'offerToken1',
|
||||
productType: ProductType.inapp),
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id2',
|
||||
offerToken: 'offerToken2',
|
||||
productType: ProductType.inapp),
|
||||
],
|
||||
);
|
||||
await billingClient.callHandler(
|
||||
MethodCall(kUserSelectedAlternativeBilling, expected.toJson()));
|
||||
expect(completer.isCompleted, isTrue);
|
||||
expect(await completer.future, expected);
|
||||
});
|
||||
|
||||
test('UserChoiceDetailsWrapper searilization check', () async {
|
||||
// Test ensures that changes to UserChoiceDetailsWrapper#toJson are
|
||||
// compatible with code in Translator.java.
|
||||
const String transactionIdKey = 'originalExternalTransactionId';
|
||||
const String transactionTokenKey = 'externalTransactionToken';
|
||||
const String productsKey = 'products';
|
||||
const String productIdKey = 'id';
|
||||
const String productOfferTokenKey = 'offerToken';
|
||||
const String productTypeKey = 'productType';
|
||||
|
||||
const UserChoiceDetailsProductWrapper expectedProduct1 =
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id1',
|
||||
offerToken: 'offerToken1',
|
||||
productType: ProductType.inapp);
|
||||
const UserChoiceDetailsProductWrapper expectedProduct2 =
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id2',
|
||||
offerToken: 'offerToken2',
|
||||
productType: ProductType.inapp);
|
||||
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
|
||||
originalExternalTransactionId: 'TransactionId',
|
||||
externalTransactionToken: 'TransactionToken',
|
||||
products: <UserChoiceDetailsProductWrapper>[
|
||||
expectedProduct1,
|
||||
expectedProduct2,
|
||||
],
|
||||
);
|
||||
final Map<String, dynamic> detailsJson = expected.toJson();
|
||||
expect(detailsJson.keys, contains(transactionIdKey));
|
||||
expect(detailsJson.keys, contains(transactionTokenKey));
|
||||
expect(detailsJson.keys, contains(productsKey));
|
||||
|
||||
final Map<String, dynamic> productJson = expectedProduct1.toJson();
|
||||
expect(productJson, contains(productIdKey));
|
||||
expect(productJson, contains(productOfferTokenKey));
|
||||
expect(productJson, contains(productTypeKey));
|
||||
});
|
||||
|
||||
test('handles method channel returning null', () async {
|
||||
stubPlatform.addResponse(
|
||||
name: methodName,
|
||||
|
@ -9,6 +9,7 @@ import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
import 'package:in_app_purchase_android/src/billing_client_wrappers/billing_config_wrapper.dart';
|
||||
import 'package:in_app_purchase_android/src/channel.dart';
|
||||
import 'package:in_app_purchase_android/src/types/translator.dart';
|
||||
|
||||
import 'billing_client_wrappers/billing_client_wrapper_test.dart';
|
||||
import 'billing_client_wrappers/purchase_wrapper_test.dart';
|
||||
@ -281,4 +282,28 @@ void main() {
|
||||
expect(arguments['feature'], equals('subscriptions'));
|
||||
});
|
||||
});
|
||||
|
||||
group('userChoiceDetails', () {
|
||||
test('called', () async {
|
||||
final Future<GooglePlayUserChoiceDetails> futureDetails =
|
||||
iapAndroidPlatformAddition.userChoiceDetailsStream.first;
|
||||
const UserChoiceDetailsWrapper expected = UserChoiceDetailsWrapper(
|
||||
originalExternalTransactionId: 'TransactionId',
|
||||
externalTransactionToken: 'TransactionToken',
|
||||
products: <UserChoiceDetailsProductWrapper>[
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id1',
|
||||
offerToken: 'offerToken1',
|
||||
productType: ProductType.inapp),
|
||||
UserChoiceDetailsProductWrapper(
|
||||
id: 'id2',
|
||||
offerToken: 'offerToken2',
|
||||
productType: ProductType.inapp),
|
||||
],
|
||||
);
|
||||
manager.onUserChoiceAlternativeBilling(expected);
|
||||
expect(
|
||||
await futureDetails, Translator.convertToUserChoiceDetails(expected));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user