Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[in_app_purchase_android] Add UserChoiceBilling mode. #6162

Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3427813
Add generated code, create java and dart objects that hold user choic…
reidbaker Feb 15, 2024
b8dc1ed
Analyzer warnings, test compile fix
reidbaker Feb 15, 2024
8440a75
do not remove disconnect call
reidbaker Feb 15, 2024
bfd7786
return list of products update java test to use userchoicebilling
reidbaker Feb 19, 2024
939545d
verify method channel is called in callback
reidbaker Feb 19, 2024
613f5f0
Add test that mimics opening a connection, closing a connection then …
reidbaker Feb 19, 2024
4f2ab49
formatting
reidbaker Feb 19, 2024
8106192
Changelog
reidbaker Feb 19, 2024
6f583d0
Add product details list to test, add flags required for deep json co…
reidbaker Feb 20, 2024
4ca1aeb
Add breadcrumb to documentation
reidbaker Feb 20, 2024
b2e00ce
java formatting
reidbaker Feb 21, 2024
d88603e
Merge branch 'main' into i143004-in-app-purchase-user-choice-billing-…
reidbaker Feb 21, 2024
818e800
Add billing client manager test for stream
reidbaker Feb 22, 2024
e2d30da
Add test for platform addition
reidbaker Feb 22, 2024
02e7638
Create api objects, add test for platform addition
reidbaker Feb 22, 2024
024bbf5
Add translator test with positive cases
reidbaker Feb 22, 2024
818adf2
Merge branch 'main' into i143004-in-app-purchase-user-choice-billing-…
reidbaker Feb 22, 2024
10ad143
dart formatting
reidbaker Feb 22, 2024
3c0067a
Add example of details in main
reidbaker Feb 22, 2024
dda2a57
formatting
reidbaker Feb 22, 2024
2e20b70
code review feedback
reidbaker Mar 8, 2024
0e8bc5b
Merge branch 'main' into i143004-in-app-purchase-user-choice-billing-…
reidbaker Mar 8, 2024
7efca0f
formatting
reidbaker Mar 8, 2024
6b282f8
Correct missmatch between java and dart and add over the wire test to…
reidbaker Mar 8, 2024
43e00d7
Formatting
reidbaker Mar 8, 2024
3df9dd5
Update packages/in_app_purchase/in_app_purchase_android/lib/src/billi…
reidbaker Mar 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this have a docs link too, like the above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about enforcing this being an error case, instead of defaulting? I think I might be confusing for developers to default to PLAY_BILLING_ONLY, even if they have made a mistake in how they configured for USER_CHOICE_BILLING.

Not a strong preference, though, as long as the messaging is clear

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reading further, we should never reach this case right? We define the listener, and we basically do it as a pass through where we pass the UserChoiceDetails in a copied hashmap to the dart side?

And the only time that listener is null is when the BillingChoiceMode is not USER_CHOICE_BILLING?

Can't we instead move that logic where we construct the listener (lines 547-557 in MethodCallHandlerImpl) inside this class and only invoke it when we actually need to make the listener, so it is never null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we should not every hit this case as the code is currently written but I added the additional fallaback logic to protect against future bugs and give us a way to know that we were in a bad state.

I didnt want to create the listener in this class because it needed to invoke methodChannel and I thought the methodchannelimpl class would be where you would look for that logic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of why I suggested this is that we have existing code where we do something similar to construct a listener in this plugin, see

.

I still lean towards moving the listener construction, so we can eliminate the bad case here, but if you have a strong preference to keep the structure as is I'm fine that way too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a compromise. I am worried about the march 13th deadline for apps to
migrate to this api. I have filed flutter/flutter#144851 and assigned it to myself along with some other cleanup bugs that I can do without impacting the public facing api.

"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) {
gmackall marked this conversation as resolved.
Show resolved Hide resolved
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