Skip to content

Commit

Permalink
[in_app_purchase_android] Add UserChoiceBilling mode. (flutter#6162)
Browse files Browse the repository at this point in the history
Add UserChoiceBilling billing mode option. 

Fixes 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.
  • Loading branch information
reidbaker authored Mar 8, 2024
1 parent a10b360 commit d489d84
Show file tree
Hide file tree
Showing 23 changed files with 874 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodChannel;

/** Responsible for creating a {@link BillingClient} object. */
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.Log;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode;

Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
import static io.flutter.plugins.inapppurchase.Translator.fromUserChoiceDetails;
import static io.flutter.plugins.inapppurchase.Translator.toProductList;

import android.app.Activity;
Expand All @@ -33,6 +34,7 @@
import com.android.billingclient.api.QueryProductDetailsParams.Product;
import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.UserChoiceBillingListener;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import java.util.ArrayList;
Expand Down Expand Up @@ -72,6 +74,8 @@ static final class MethodNames {
"BillingClient#createAlternativeBillingOnlyReportingDetails()";
static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG =
"BillingClient#showAlternativeBillingOnlyInformationDialog()";
static final String USER_SELECTED_ALTERNATIVE_BILLING =
"UserChoiceBillingListener#userSelectedAlternativeBilling(UserChoiceDetails)";

private MethodNames() {}
}
Expand All @@ -94,6 +98,7 @@ private MethodArgs() {}
static final class BillingChoiceMode {
static final int PLAY_BILLING_ONLY = 0;
static final int ALTERNATIVE_BILLING_ONLY = 1;
static final int USER_CHOICE_BILLING = 2;
}

// TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new
Expand Down Expand Up @@ -507,9 +512,10 @@ private void getConnectionState(final MethodChannel.Result result) {
private void startConnection(
final int handle, final MethodChannel.Result result, int billingChoiceMode) {
if (billingClient == null) {
UserChoiceBillingListener listener = getUserChoiceBillingListener(billingChoiceMode);
billingClient =
billingClientFactory.createBillingClient(
applicationContext, methodChannel, billingChoiceMode);
applicationContext, methodChannel, billingChoiceMode, listener);
}

billingClient.startConnection(
Expand Down Expand Up @@ -537,6 +543,19 @@ public void onBillingServiceDisconnected() {
});
}

@Nullable
private UserChoiceBillingListener getUserChoiceBillingListener(int billingChoiceMode) {
UserChoiceBillingListener listener = null;
if (billingChoiceMode == BillingChoiceMode.USER_CHOICE_BILLING) {
listener =
userChoiceDetails -> {
final Map<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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.UserChoiceDetails;
import com.android.billingclient.api.UserChoiceDetails.Product;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Currency;
Expand Down Expand Up @@ -233,6 +235,34 @@ static HashMap<String, Object> fromBillingResult(BillingResult billingResult) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
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;
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;
Expand Down Expand Up @@ -73,6 +75,8 @@
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchaseHistoryParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.UserChoiceBillingListener;
import com.android.billingclient.api.UserChoiceDetails;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
Expand All @@ -82,6 +86,7 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand All @@ -92,6 +97,7 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.mockito.stubbing.Answer;
Expand All @@ -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, null))
.thenReturn(mockBillingClient);
when(factory.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY))
any(Context.class),
any(MethodChannel.class),
eq(BillingChoiceMode.USER_CHOICE_BILLING),
any(UserChoiceBillingListener.class)))
.thenReturn(mockBillingClient);
methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
Expand Down Expand Up @@ -164,7 +178,7 @@ public void startConnection() {
mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);

BillingResult billingResult =
BillingResult.newBuilder()
Expand All @@ -183,7 +197,7 @@ public void startConnectionAlternativeBillingOnly() {
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY);
context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY, null);

BillingResult billingResult =
BillingResult.newBuilder()
Expand All @@ -209,7 +223,7 @@ public void startConnectionAlternativeBillingUnset() {
methodChannelHandler.onMethodCall(call, result);
verify(result, never()).success(any());
verify(factory, times(1))
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY);
.createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY, null);

BillingResult billingResult =
BillingResult.newBuilder()
Expand All @@ -221,6 +235,106 @@ public void startConnectionAlternativeBillingUnset() {
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<>();
Expand Down Expand Up @@ -1071,7 +1185,7 @@ private ArgumentCaptor<BillingClientStateListener> mockStartConnection() {
*/
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 =
Expand Down
Loading

0 comments on commit d489d84

Please sign in to comment.