Skip to content

Commit d489d84

Browse files
authored
[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.
1 parent a10b360 commit d489d84

23 files changed

+874
-25
lines changed

packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
## NEXT
1+
## 0.3.2
22

3+
* Adds UserChoiceBilling APIs to platform addition.
34
* Updates minimum supported SDK version to Flutter 3.13/Dart 3.1.
45

56
## 0.3.1

packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
import android.content.Context;
88
import androidx.annotation.NonNull;
9+
import androidx.annotation.Nullable;
910
import com.android.billingclient.api.BillingClient;
11+
import com.android.billingclient.api.UserChoiceBillingListener;
1012
import io.flutter.plugin.common.MethodChannel;
1113

1214
/** Responsible for creating a {@link BillingClient} object. */
@@ -22,5 +24,8 @@ interface BillingClientFactory {
2224
* @return The {@link BillingClient} object that is created.
2325
*/
2426
BillingClient createBillingClient(
25-
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode);
27+
@NonNull Context context,
28+
@NonNull MethodChannel channel,
29+
int billingChoiceMode,
30+
@Nullable UserChoiceBillingListener userChoiceBillingListener);
2631
}

packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
import android.content.Context;
88
import androidx.annotation.NonNull;
9+
import androidx.annotation.Nullable;
910
import com.android.billingclient.api.BillingClient;
11+
import com.android.billingclient.api.UserChoiceBillingListener;
12+
import io.flutter.Log;
1013
import io.flutter.plugin.common.MethodChannel;
1114
import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode;
1215

@@ -15,11 +18,34 @@ final class BillingClientFactoryImpl implements BillingClientFactory {
1518

1619
@Override
1720
public BillingClient createBillingClient(
18-
@NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode) {
21+
@NonNull Context context,
22+
@NonNull MethodChannel channel,
23+
int billingChoiceMode,
24+
@Nullable UserChoiceBillingListener userChoiceBillingListener) {
1925
BillingClient.Builder builder = BillingClient.newBuilder(context).enablePendingPurchases();
20-
if (billingChoiceMode == BillingChoiceMode.ALTERNATIVE_BILLING_ONLY) {
21-
// https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app
22-
builder.enableAlternativeBillingOnly();
26+
switch (billingChoiceMode) {
27+
case BillingChoiceMode.ALTERNATIVE_BILLING_ONLY:
28+
// https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app
29+
builder.enableAlternativeBillingOnly();
30+
break;
31+
case BillingChoiceMode.USER_CHOICE_BILLING:
32+
if (userChoiceBillingListener != null) {
33+
// https://developer.android.com/google/play/billing/alternative/alternative-billing-with-user-choice-in-app
34+
builder.enableUserChoiceBilling(userChoiceBillingListener);
35+
} else {
36+
Log.e(
37+
"BillingClientFactoryImpl",
38+
"userChoiceBillingListener null when USER_CHOICE_BILLING set. Defaulting to PLAY_BILLING_ONLY");
39+
}
40+
break;
41+
case BillingChoiceMode.PLAY_BILLING_ONLY:
42+
// Do nothing.
43+
break;
44+
default:
45+
Log.e(
46+
"BillingClientFactoryImpl",
47+
"Unknown BillingChoiceMode " + billingChoiceMode + ", Defaulting to PLAY_BILLING_ONLY");
48+
break;
2349
}
2450
return builder.setListener(new PluginPurchaseListener(channel)).build();
2551
}

packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
1111
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
1212
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
13+
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
1314
import static io.flutter.plugins.inapppurchase.Translator.toProductList;
1415

1516
import android.app.Activity;
@@ -33,6 +34,7 @@
3334
import com.android.billingclient.api.QueryProductDetailsParams.Product;
3435
import com.android.billingclient.api.QueryPurchaseHistoryParams;
3536
import com.android.billingclient.api.QueryPurchasesParams;
37+
import com.android.billingclient.api.UserChoiceBillingListener;
3638
import io.flutter.plugin.common.MethodCall;
3739
import io.flutter.plugin.common.MethodChannel;
3840
import java.util.ArrayList;
@@ -72,6 +74,8 @@ static final class MethodNames {
7274
"BillingClient#createAlternativeBillingOnlyReportingDetails()";
7375
static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG =
7476
"BillingClient#showAlternativeBillingOnlyInformationDialog()";
77+
static final String USER_SELECTED_ALTERNATIVE_BILLING =
78+
"UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)";
7579

7680
private MethodNames() {}
7781
}
@@ -94,6 +98,7 @@ private MethodArgs() {}
9498
static final class BillingChoiceMode {
9599
static final int PLAY_BILLING_ONLY = 0;
96100
static final int ALTERNATIVE_BILLING_ONLY = 1;
101+
static final int USER_CHOICE_BILLING = 2;
97102
}
98103

99104
// TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new
@@ -507,9 +512,10 @@ private void getConnectionState(final MethodChannel.Result result) {
507512
private void startConnection(
508513
final int handle, final MethodChannel.Result result, int billingChoiceMode) {
509514
if (billingClient == null) {
515+
UserChoiceBillingListener listener = getUserChoiceBillingListener(billingChoiceMode);
510516
billingClient =
511517
billingClientFactory.createBillingClient(
512-
applicationContext, methodChannel, billingChoiceMode);
518+
applicationContext, methodChannel, billingChoiceMode, listener);
513519
}
514520

515521
billingClient.startConnection(
@@ -537,6 +543,19 @@ public void onBillingServiceDisconnected() {
537543
});
538544
}
539545

546+
@Nullable
547+
private UserChoiceBillingListener getUserChoiceBillingListener(int billingChoiceMode) {
548+
UserChoiceBillingListener listener = null;
549+
if (billingChoiceMode == BillingChoiceMode.USER_CHOICE_BILLING) {
550+
listener =
551+
userChoiceDetails -> {
552+
final Map<String, Object> arguments = fromUserChoiceDetails(userChoiceDetails);
553+
methodChannel.invokeMethod(MethodNames.USER_SELECTED_ALTERNATIVE_BILLING, arguments);
554+
};
555+
}
556+
return listener;
557+
}
558+
540559
private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) {
541560
if (billingClientError(result)) {
542561
return;

packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import com.android.billingclient.api.Purchase;
1515
import com.android.billingclient.api.PurchaseHistoryRecord;
1616
import com.android.billingclient.api.QueryProductDetailsParams;
17+
import com.android.billingclient.api.UserChoiceDetails;
18+
import com.android.billingclient.api.UserChoiceDetails.Product;
1719
import java.util.ArrayList;
1820
import java.util.Collections;
1921
import java.util.Currency;
@@ -233,6 +235,34 @@ static HashMap<String, Object> fromBillingResult(BillingResult billingResult) {
233235
return info;
234236
}
235237

238+
static HashMap<String, Object> fromUserChoiceDetails(UserChoiceDetails userChoiceDetails) {
239+
HashMap<String, Object> info = new HashMap<>();
240+
info.put("externalTransactionToken", userChoiceDetails.getExternalTransactionToken());
241+
info.put("originalExternalTransactionId", userChoiceDetails.getOriginalExternalTransactionId());
242+
info.put("products", fromProductsList(userChoiceDetails.getProducts()));
243+
return info;
244+
}
245+
246+
static List<HashMap<String, Object>> fromProductsList(List<Product> productsList) {
247+
if (productsList.isEmpty()) {
248+
return Collections.emptyList();
249+
}
250+
ArrayList<HashMap<String, Object>> output = new ArrayList<>();
251+
for (Product product : productsList) {
252+
output.add(fromProduct(product));
253+
}
254+
return output;
255+
}
256+
257+
static HashMap<String, Object> fromProduct(Product product) {
258+
HashMap<String, Object> info = new HashMap<>();
259+
info.put("id", product.getId());
260+
info.put("offerToken", product.getOfferToken());
261+
info.put("productType", product.getType());
262+
263+
return info;
264+
}
265+
236266
/** Converter from {@link BillingResult} and {@link BillingConfig} to map. */
237267
static HashMap<String, Object> fromBillingConfig(
238268
BillingResult result, BillingConfig billingConfig) {

packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC;
2121
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG;
2222
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.START_CONNECTION;
23+
import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.USER_SELECTED_ALTERNATIVE_BILLING;
2324
import static io.flutter.plugins.inapppurchase.PluginPurchaseListener.ON_PURCHASES_UPDATED;
2425
import static io.flutter.plugins.inapppurchase.Translator.fromAlternativeBillingOnlyReportingDetails;
2526
import static io.flutter.plugins.inapppurchase.Translator.fromBillingConfig;
2627
import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
2728
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
2829
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
2930
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
31+
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
3032
import static java.util.Arrays.asList;
3133
import static java.util.Collections.singletonList;
3234
import static java.util.Collections.unmodifiableList;
@@ -73,6 +75,8 @@
7375
import com.android.billingclient.api.QueryProductDetailsParams;
7476
import com.android.billingclient.api.QueryPurchaseHistoryParams;
7577
import com.android.billingclient.api.QueryPurchasesParams;
78+
import com.android.billingclient.api.UserChoiceBillingListener;
79+
import com.android.billingclient.api.UserChoiceDetails;
7680
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
7781
import io.flutter.plugin.common.MethodCall;
7882
import io.flutter.plugin.common.MethodChannel;
@@ -82,6 +86,7 @@
8286
import java.lang.reflect.Constructor;
8387
import java.lang.reflect.InvocationTargetException;
8488
import java.util.ArrayList;
89+
import java.util.Collections;
8590
import java.util.HashMap;
8691
import java.util.List;
8792
import java.util.Map;
@@ -92,6 +97,7 @@
9297
import org.mockito.ArgumentCaptor;
9398
import org.mockito.Captor;
9499
import org.mockito.Mock;
100+
import org.mockito.Mockito;
95101
import org.mockito.MockitoAnnotations;
96102
import org.mockito.Spy;
97103
import org.mockito.stubbing.Answer;
@@ -107,15 +113,23 @@ public class MethodCallHandlerTest {
107113
@Mock ActivityPluginBinding mockActivityPluginBinding;
108114
@Captor ArgumentCaptor<HashMap<String, Object>> resultCaptor;
109115

116+
private final int DEFAULT_HANDLE = 1;
117+
110118
@Before
111119
public void setUp() {
112120
MockitoAnnotations.openMocks(this);
113121
// Use the same client no matter if alternative billing is enabled or not.
114122
when(factory.createBillingClient(
115-
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY))
123+
context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null))
124+
.thenReturn(mockBillingClient);
125+
when(factory.createBillingClient(
126+
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null))
116127
.thenReturn(mockBillingClient);
117128
when(factory.createBillingClient(
118-
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY))
129+
any(Context.class),
130+
any(MethodChannel.class),
131+
eq(BillingChoiceMode.USER_CHOICE_BILLING),
132+
any(UserChoiceBillingListener.class)))
119133
.thenReturn(mockBillingClient);
120134
methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
121135
when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
@@ -164,7 +178,7 @@ public void startConnection() {
164178
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
165179
verify(result, never()).success(any());
166180
verify(factory, times(1))
167-
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
181+
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
168182

169183
BillingResult billingResult =
170184
BillingResult.newBuilder()
@@ -183,7 +197,7 @@ public void startConnectionAlternativeBillingOnly() {
183197
verify(result, never()).success(any());
184198
verify(factory, times(1))
185199
.createBillingClient(
186-
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY);
200+
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null);
187201

188202
BillingResult billingResult =
189203
BillingResult.newBuilder()
@@ -209,7 +223,7 @@ public void startConnectionAlternativeBillingUnset() {
209223
methodChannelHandler.onMethodCall(call, result);
210224
verify(result, never()).success(any());
211225
verify(factory, times(1))
212-
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
226+
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
213227

214228
BillingResult billingResult =
215229
BillingResult.newBuilder()
@@ -221,6 +235,106 @@ public void startConnectionAlternativeBillingUnset() {
221235
verify(result, times(1)).success(fromBillingResult(billingResult));
222236
}
223237

238+
@Test
239+
public void startConnectionUserChoiceBilling() {
240+
ArgumentCaptor<BillingClientStateListener> captor =
241+
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
242+
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
243+
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
244+
verify(result, never()).success(any());
245+
verify(factory, times(1))
246+
.createBillingClient(
247+
any(Context.class),
248+
any(MethodChannel.class),
249+
eq(BillingChoiceMode.USER_CHOICE_BILLING),
250+
billingCaptor.capture());
251+
252+
BillingResult billingResult =
253+
BillingResult.newBuilder()
254+
.setResponseCode(100)
255+
.setDebugMessage("dummy debug message")
256+
.build();
257+
captor.getValue().onBillingSetupFinished(billingResult);
258+
259+
verify(result, times(1)).success(fromBillingResult(billingResult));
260+
UserChoiceDetails details = mock(UserChoiceDetails.class);
261+
final String externalTransactionToken = "someLongTokenId1234";
262+
final String originalTransactionId = "originalTransactionId123456";
263+
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
264+
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
265+
when(details.getProducts()).thenReturn(Collections.emptyList());
266+
billingCaptor.getValue().userSelectedAlternativeBilling(details);
267+
268+
verify(mockMethodChannel, times(1))
269+
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
270+
}
271+
272+
@Test
273+
public void userChoiceBillingOnSecondConnection() {
274+
// First connection.
275+
ArgumentCaptor<BillingClientStateListener> captor1 =
276+
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
277+
verify(result, never()).success(any());
278+
verify(factory, times(1))
279+
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);
280+
281+
BillingResult billingResult1 =
282+
BillingResult.newBuilder()
283+
.setResponseCode(100)
284+
.setDebugMessage("dummy debug message")
285+
.build();
286+
final BillingClientStateListener stateListener = captor1.getValue();
287+
stateListener.onBillingSetupFinished(billingResult1);
288+
verify(result, times(1)).success(fromBillingResult(billingResult1));
289+
Mockito.reset(result, mockMethodChannel, mockBillingClient);
290+
291+
// Disconnect
292+
MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
293+
methodChannelHandler.onMethodCall(disconnectCall, result);
294+
295+
// Verify that the client is disconnected and that the OnDisconnect callback has
296+
// been triggered
297+
verify(result, times(1)).success(any());
298+
verify(mockBillingClient, times(1)).endConnection();
299+
stateListener.onBillingServiceDisconnected();
300+
Map<String, Integer> expectedInvocation = new HashMap<>();
301+
expectedInvocation.put("handle", DEFAULT_HANDLE);
302+
verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation);
303+
Mockito.reset(result, mockMethodChannel, mockBillingClient);
304+
305+
// Second connection.
306+
ArgumentCaptor<BillingClientStateListener> captor2 =
307+
mockStartConnection(BillingChoiceMode.USER_CHOICE_BILLING);
308+
ArgumentCaptor<UserChoiceBillingListener> billingCaptor =
309+
ArgumentCaptor.forClass(UserChoiceBillingListener.class);
310+
verify(result, never()).success(any());
311+
verify(factory, times(1))
312+
.createBillingClient(
313+
any(Context.class),
314+
any(MethodChannel.class),
315+
eq(BillingChoiceMode.USER_CHOICE_BILLING),
316+
billingCaptor.capture());
317+
318+
BillingResult billingResult2 =
319+
BillingResult.newBuilder()
320+
.setResponseCode(100)
321+
.setDebugMessage("dummy debug message")
322+
.build();
323+
captor2.getValue().onBillingSetupFinished(billingResult2);
324+
325+
verify(result, times(1)).success(fromBillingResult(billingResult2));
326+
UserChoiceDetails details = mock(UserChoiceDetails.class);
327+
final String externalTransactionToken = "someLongTokenId1234";
328+
final String originalTransactionId = "originalTransactionId123456";
329+
when(details.getExternalTransactionToken()).thenReturn(externalTransactionToken);
330+
when(details.getOriginalExternalTransactionId()).thenReturn(originalTransactionId);
331+
when(details.getProducts()).thenReturn(Collections.emptyList());
332+
billingCaptor.getValue().userSelectedAlternativeBilling(details);
333+
334+
verify(mockMethodChannel, times(1))
335+
.invokeMethod(USER_SELECTED_ALTERNATIVE_BILLING, fromUserChoiceDetails(details));
336+
}
337+
224338
@Test
225339
public void startConnection_multipleCalls() {
226340
Map<String, Object> arguments = new HashMap<>();
@@ -1071,7 +1185,7 @@ private ArgumentCaptor<BillingClientStateListener> mockStartConnection() {
10711185
*/
10721186
private ArgumentCaptor<BillingClientStateListener> mockStartConnection(int billingChoiceMode) {
10731187
Map<String, Object> arguments = new HashMap<>();
1074-
arguments.put(MethodArgs.HANDLE, 1);
1188+
arguments.put(MethodArgs.HANDLE, DEFAULT_HANDLE);
10751189
arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode);
10761190
MethodCall call = new MethodCall(START_CONNECTION, arguments);
10771191
ArgumentCaptor<BillingClientStateListener> captor =

0 commit comments

Comments
 (0)