diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 72b8bb36ff3d..d495ff40c555 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.1 + +* Adds alternative-billing-only APIs to InAppPurchaseAndroidPlatformAddition. + ## 0.3.0+18 * Adds new getCountryCode() method to InAppPurchaseAndroidPlatformAddition to get a customer's country code. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index 726fdaba29d8..24d9fe296442 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -66,6 +66,6 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20231013' testImplementation 'org.mockito:mockito-core:5.4.0' - androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java index 81fdf27be88e..9324c9367ee8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java @@ -17,7 +17,10 @@ interface BillingClientFactory { * * @param context The context used to create the {@link BillingClient}. * @param channel The method channel used to create the {@link BillingClient}. + * @param billingChoiceMode Enables the ability to offer alternative billing or Google Play + * billing. * @return The {@link BillingClient} object that is created. */ - BillingClient createBillingClient(@NonNull Context context, @NonNull MethodChannel channel); + BillingClient createBillingClient( + @NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode); } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java index 201d57c7fa25..c6911f8314a3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java @@ -8,15 +8,19 @@ import androidx.annotation.NonNull; import com.android.billingclient.api.BillingClient; import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode; /** The implementation for {@link BillingClientFactory} for the plugin. */ final class BillingClientFactoryImpl implements BillingClientFactory { @Override public BillingClient createBillingClient( - @NonNull Context context, @NonNull MethodChannel channel) { + @NonNull Context context, @NonNull MethodChannel channel, int billingChoiceMode) { 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(); + } return builder.setListener(new PluginPurchaseListener(channel)).build(); } } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 247e037d3202..ecf88747779a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -4,6 +4,7 @@ package io.flutter.plugins.inapppurchase; +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; @@ -65,10 +66,36 @@ static final class MethodNames { static final String IS_FEATURE_SUPPORTED = "BillingClient#isFeatureSupported(String)"; static final String GET_CONNECTION_STATE = "BillingClient#getConnectionState()"; static final String GET_BILLING_CONFIG = "BillingClient#getBillingConfig()"; + static final String IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE = + "BillingClient#isAlternativeBillingOnlyAvailable()"; + static final String CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS = + "BillingClient#createAlternativeBillingOnlyReportingDetails()"; + static final String SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG = + "BillingClient#showAlternativeBillingOnlyInformationDialog()"; private MethodNames() {} } + @VisibleForTesting + static final class MethodArgs { + + // Key for an int argument passed into startConnection + static final String HANDLE = "handle"; + // Key for a boolean argument passed into startConnection. + static final String BILLING_CHOICE_MODE = "billingChoiceMode"; + + private MethodArgs() {} + } + + /** + * Values here must match values used in + * in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart + */ + static final class BillingChoiceMode { + static final int PLAY_BILLING_ONLY = 0; + static final int ALTERNATIVE_BILLING_ONLY = 1; + } + // TODO(gmackall): Replace uses of deprecated ProrationMode enum values with new // ReplacementMode enum values. // https://github.com/flutter/flutter/issues/128957. @@ -80,6 +107,7 @@ private MethodNames() {} private static final String TAG = "InAppPurchasePlugin"; private static final String LOAD_PRODUCT_DOC_URL = "https://github.com/flutter/packages/blob/main/packages/in_app_purchase/in_app_purchase/README.md#loading-products-for-sale"; + @VisibleForTesting static final String ACTIVITY_UNAVAILABLE = "ACTIVITY_UNAVAILABLE"; @Nullable private BillingClient billingClient; private final BillingClientFactory billingClientFactory; @@ -147,7 +175,12 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result isReady(result); break; case MethodNames.START_CONNECTION: - startConnection((int) call.argument("handle"), result); + final int handle = (int) call.argument(MethodArgs.HANDLE); + int billingChoiceMode = BillingChoiceMode.PLAY_BILLING_ONLY; + if (call.hasArgument(MethodArgs.BILLING_CHOICE_MODE)) { + billingChoiceMode = call.argument(MethodArgs.BILLING_CHOICE_MODE); + } + startConnection(handle, result, billingChoiceMode); break; case MethodNames.END_CONNECTION: endConnection(result); @@ -190,12 +223,61 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result case MethodNames.GET_BILLING_CONFIG: getBillingConfig(result); break; + case MethodNames.IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE: + isAlternativeBillingOnlyAvailable(result); + break; + case MethodNames.CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS: + createAlternativeBillingOnlyReportingDetails(result); + break; + case MethodNames.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG: + showAlternativeBillingOnlyInformationDialog(result); + break; default: result.notImplemented(); } } + private void showAlternativeBillingOnlyInformationDialog(final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + if (activity == null) { + result.error(ACTIVITY_UNAVAILABLE, "Not attempting to show dialog", null); + return; + } + billingClient.showAlternativeBillingOnlyInformationDialog( + activity, + billingResult -> { + result.success(fromBillingResult(billingResult)); + }); + } + + private void createAlternativeBillingOnlyReportingDetails(final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + billingClient.createAlternativeBillingOnlyReportingDetailsAsync( + ((billingResult, alternativeBillingOnlyReportingDetails) -> { + result.success( + fromAlternativeBillingOnlyReportingDetails( + billingResult, alternativeBillingOnlyReportingDetails)); + })); + } + + private void isAlternativeBillingOnlyAvailable(final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + billingClient.isAlternativeBillingOnlyAvailableAsync( + billingResult -> { + result.success(fromBillingResult(billingResult)); + }); + } + private void getBillingConfig(final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } billingClient.getBillingConfigAsync( GetBillingConfigParams.newBuilder().build(), (billingResult, billingConfig) -> { @@ -313,7 +395,7 @@ private void launchBillingFlow( if (activity == null) { result.error( - "ACTIVITY_UNAVAILABLE", + ACTIVITY_UNAVAILABLE, "Details for product " + product + " are not available. This method must be run with the app in foreground.", @@ -422,9 +504,12 @@ private void getConnectionState(final MethodChannel.Result result) { result.success(serialized); } - private void startConnection(final int handle, final MethodChannel.Result result) { + private void startConnection( + final int handle, final MethodChannel.Result result, int billingChoiceMode) { if (billingClient == null) { - billingClient = billingClientFactory.createBillingClient(applicationContext, methodChannel); + billingClient = + billingClientFactory.createBillingClient( + applicationContext, methodChannel, billingChoiceMode); } billingClient.startConnection( diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 95976d1a8afc..8c80b1797eda 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.AccountIdentifiers; +import com.android.billingclient.api.AlternativeBillingOnlyReportingDetails; import com.android.billingclient.api.BillingConfig; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ProductDetails; @@ -240,6 +241,18 @@ static HashMap fromBillingConfig( return info; } + /** + * Converter from {@link BillingResult} and {@link AlternativeBillingOnlyReportingDetails} to map. + */ + static HashMap fromAlternativeBillingOnlyReportingDetails( + BillingResult result, AlternativeBillingOnlyReportingDetails details) { + HashMap info = fromBillingResult(result); + if (details != null) { + info.put("externalTransactionToken", details.getExternalTransactionToken()); + } + return info; + } + /** * Gets the symbol of for the given currency code for the default {@link Locale.Category#DISPLAY * DISPLAY} locale. For example, for the US Dollar, the symbol is "$" if the default locale is the diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 2a826150a649..bfdf928ca511 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -4,10 +4,13 @@ package io.flutter.plugins.inapppurchase; +import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.ACTIVITY_UNAVAILABLE; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.ACKNOWLEDGE_PURCHASE; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.CONSUME_PURCHASE_ASYNC; +import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.END_CONNECTION; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.GET_BILLING_CONFIG; +import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.IS_FEATURE_SUPPORTED; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.IS_READY; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.LAUNCH_BILLING_FLOW; @@ -15,8 +18,10 @@ import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PRODUCT_DETAILS; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PURCHASES_ASYNC; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC; +import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG; import static io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodNames.START_CONNECTION; 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; @@ -42,11 +47,15 @@ import android.app.Activity; import android.content.Context; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.AcknowledgePurchaseParams; import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener; +import com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener; +import com.android.billingclient.api.AlternativeBillingOnlyReportingDetails; +import com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener; import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.BillingResponseCode; import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingConfig; import com.android.billingclient.api.BillingConfigResponseListener; @@ -68,6 +77,8 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.BillingChoiceMode; +import io.flutter.plugins.inapppurchase.MethodCallHandlerImpl.MethodArgs; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -87,7 +98,7 @@ public class MethodCallHandlerTest { private MethodCallHandlerImpl methodChannelHandler; - private BillingClientFactory factory; + @Mock BillingClientFactory factory; @Mock BillingClient mockBillingClient; @Mock MethodChannel mockMethodChannel; @Spy Result result; @@ -95,12 +106,17 @@ public class MethodCallHandlerTest { @Mock Context context; @Mock ActivityPluginBinding mockActivityPluginBinding; @Captor ArgumentCaptor> resultCaptor; - @Mock BillingConfig mockBillingConfig; @Before public void setUp() { MockitoAnnotations.openMocks(this); - factory = (@NonNull Context context, @NonNull MethodChannel channel) -> mockBillingClient; + // Use the same client no matter if alternative billing is enabled or not. + when(factory.createBillingClient( + context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY)) + .thenReturn(mockBillingClient); + when(factory.createBillingClient( + context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY)) + .thenReturn(mockBillingClient); methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory); when(mockActivityPluginBinding.getActivity()).thenReturn(activity); } @@ -144,8 +160,57 @@ public void isReady_clientDisconnected() { @Test public void startConnection() { - ArgumentCaptor captor = mockStartConnection(); + ArgumentCaptor captor = + mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY); + verify(result, never()).success(any()); + verify(factory, times(1)) + .createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + captor.getValue().onBillingSetupFinished(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void startConnectionAlternativeBillingOnly() { + ArgumentCaptor captor = + mockStartConnection(BillingChoiceMode.ALTERNATIVE_BILLING_ONLY); verify(result, never()).success(any()); + verify(factory, times(1)) + .createBillingClient( + context, mockMethodChannel, BillingChoiceMode.ALTERNATIVE_BILLING_ONLY); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + captor.getValue().onBillingSetupFinished(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void startConnectionAlternativeBillingUnset() { + // Logic is identical to mockStartConnection but does not set a value for + // ENABLE_ALTERNATIVE_BILLING to verify fallback behavior. + Map arguments = new HashMap<>(); + arguments.put(MethodArgs.HANDLE, 1); + MethodCall call = new MethodCall(START_CONNECTION, arguments); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BillingClientStateListener.class); + doNothing().when(mockBillingClient).startConnection(captor.capture()); + + methodChannelHandler.onMethodCall(call, result); + verify(result, never()).success(any()); + verify(factory, times(1)) + .createBillingClient(context, mockMethodChannel, BillingChoiceMode.PLAY_BILLING_ONLY); + BillingResult billingResult = BillingResult.newBuilder() .setResponseCode(100) @@ -199,25 +264,150 @@ public void getBillingConfigSuccess() { ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(BillingConfigResponseListener.class); MethodCall billingCall = new MethodCall(GET_BILLING_CONFIG, null); - methodChannelHandler.onMethodCall(billingCall, mock(Result.class)); BillingResult billingResult = BillingResult.newBuilder() .setResponseCode(100) .setDebugMessage("dummy debug message") .build(); final String expectedCountryCode = "US"; - final HashMap expectedResult = fromBillingResult(billingResult); - expectedResult.put("countryCode", expectedCountryCode); + final BillingConfig expectedConfig = mock(BillingConfig.class); + when(expectedConfig.getCountryCode()).thenReturn(expectedCountryCode); - when(mockBillingConfig.getCountryCode()).thenReturn(expectedCountryCode); doNothing() .when(mockBillingClient) .getBillingConfigAsync(paramsCaptor.capture(), listenerCaptor.capture()); methodChannelHandler.onMethodCall(billingCall, result); - listenerCaptor.getValue().onBillingConfigResponse(billingResult, mockBillingConfig); + listenerCaptor.getValue().onBillingConfigResponse(billingResult, expectedConfig); + + verify(result, times(1)).success(fromBillingConfig(billingResult, expectedConfig)); + } + + @Test + public void getBillingConfig_serviceDisconnected() { + MethodCall billingCall = new MethodCall(GET_BILLING_CONFIG, null); + methodChannelHandler.onMethodCall(billingCall, mock(Result.class)); + + methodChannelHandler.onMethodCall(billingCall, result); - verify(result, times(1)).success(fromBillingConfig(billingResult, mockBillingConfig)); + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + } + + @Test + public void createAlternativeBillingOnlyReportingDetailsSuccess() { + mockStartConnection(); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AlternativeBillingOnlyReportingDetailsListener.class); + MethodCall createABOReportingDetailsCall = + new MethodCall(CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS, null); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingResponseCode.OK) + .setDebugMessage("dummy debug message") + .build(); + final AlternativeBillingOnlyReportingDetails expectedDetails = + mock(AlternativeBillingOnlyReportingDetails.class); + final String expectedExternalTransactionToken = "abc123youandme"; + + when(expectedDetails.getExternalTransactionToken()) + .thenReturn(expectedExternalTransactionToken); + doNothing() + .when(mockBillingClient) + .createAlternativeBillingOnlyReportingDetailsAsync(listenerCaptor.capture()); + + methodChannelHandler.onMethodCall(createABOReportingDetailsCall, result); + listenerCaptor.getValue().onAlternativeBillingOnlyTokenResponse(billingResult, expectedDetails); + + verify(result, times(1)) + .success(fromAlternativeBillingOnlyReportingDetails(billingResult, expectedDetails)); + } + + @Test + public void createAlternativeBillingOnlyReportingDetails_serviceDisconnected() { + MethodCall createCall = new MethodCall(CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS, null); + methodChannelHandler.onMethodCall(createCall, mock(Result.class)); + + methodChannelHandler.onMethodCall(createCall, result); + + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + } + + @Test + public void isAlternativeBillingOnlyAvailableSuccess() { + mockStartConnection(); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AlternativeBillingOnlyAvailabilityListener.class); + MethodCall billingCall = new MethodCall(IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE, null); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.OK) + .setDebugMessage("dummy debug message") + .build(); + final HashMap expectedResult = fromBillingResult(billingResult); + + doNothing() + .when(mockBillingClient) + .isAlternativeBillingOnlyAvailableAsync(listenerCaptor.capture()); + + methodChannelHandler.onMethodCall(billingCall, result); + listenerCaptor.getValue().onAlternativeBillingOnlyAvailabilityResponse(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void isAlternativeBillingOnlyAvailable_serviceDisconnected() { + MethodCall billingCall = new MethodCall(IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE, null); + methodChannelHandler.onMethodCall(billingCall, mock(Result.class)); + + methodChannelHandler.onMethodCall(billingCall, result); + + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + } + + @Test + public void showAlternativeBillingOnlyInformationDialogSuccess() { + mockStartConnection(); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AlternativeBillingOnlyInformationDialogListener.class); + MethodCall showDialogCall = + new MethodCall(SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG, null); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingResponseCode.OK) + .setDebugMessage("dummy debug message") + .build(); + + when(mockBillingClient.showAlternativeBillingOnlyInformationDialog( + eq(activity), listenerCaptor.capture())) + .thenReturn(billingResult); + + methodChannelHandler.onMethodCall(showDialogCall, result); + listenerCaptor.getValue().onAlternativeBillingOnlyInformationDialogResponse(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void showAlternativeBillingOnlyInformationDialog_serviceDisconnected() { + MethodCall billingCall = new MethodCall(SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG, null); + + methodChannelHandler.onMethodCall(billingCall, result); + + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + } + + @Test + public void showAlternativeBillingOnlyInformationDialog_NullActivity() { + mockStartConnection(); + MethodCall showDialogCall = + new MethodCall(SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG, null); + + methodChannelHandler.setActivity(null); + methodChannelHandler.onMethodCall(showDialogCall, result); + + verify(result) + .error(contains(ACTIVITY_UNAVAILABLE), contains("Not attempting to show dialog"), any()); } @Test @@ -865,9 +1055,24 @@ public void isFutureSupported_false() { verify(result).success(false); } + /** + * Call {@link MethodCallHandlerImpl.START_CONNECTION] with startup params. + * + * Defaults to play billing only which is the default. + */ private ArgumentCaptor mockStartConnection() { + return mockStartConnection(BillingChoiceMode.PLAY_BILLING_ONLY); + } + + /** + * Call {@link MethodCallHandlerImpl.START_CONNECTION] with startup params. + * + *{@link billingChoiceMode} is one of the int value used from {@link BillingChoiceMode}. + */ + private ArgumentCaptor mockStartConnection(int billingChoiceMode) { Map arguments = new HashMap<>(); - arguments.put("handle", 1); + arguments.put(MethodArgs.HANDLE, 1); + arguments.put(MethodArgs.BILLING_CHOICE_MODE, billingChoiceMode); MethodCall call = new MethodCall(START_CONNECTION, arguments); ArgumentCaptor captor = ArgumentCaptor.forClass(BillingClientStateListener.class); @@ -881,7 +1086,7 @@ private void establishConnectedBillingClient( @Nullable Map arguments, @Nullable Result result) { if (arguments == null) { arguments = new HashMap<>(); - arguments.put("handle", 1); + arguments.put(MethodArgs.HANDLE, 1); } if (result == null) { result = mock(Result.class); diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/.gitignore b/packages/in_app_purchase/in_app_purchase_android/example/android/.gitignore new file mode 100644 index 000000000000..de72ca0c8080 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/.gitignore @@ -0,0 +1,2 @@ +# Keystore files +*.jks diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle b/packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle index 1cffa7dfb764..dfccaac37d6e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle @@ -70,7 +70,7 @@ android { defaultConfig { applicationId project.APP_ID minSdkVersion flutter.minSdkVersion - targetSdkVersion 30 + targetSdkVersion 34 versionCode project.VERSION_CODE versionName project.VERSION_NAME testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -107,7 +107,7 @@ flutter { } dependencies { - implementation 'com.android.billingclient:billing:5.0.0' + implementation 'com.android.billingclient:billing:6.1.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.1.1' testImplementation 'org.json:json:20231013' diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml index 1185a05b3530..43a74d844ca3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density" android:hardwareAccelerated="true" + android:exported="true" android:windowSoftInputMode="adjustResize"> diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties b/packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties index ccbbb3653569..8357b4beb609 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties @@ -1,7 +1,8 @@ +# https://developer.android.com/studio/publish/app-signing#generate-key storePassword=??? keyPassword=??? keyAlias=??? storeFile=??? appId=io.flutter.plugins.inapppurchaseexample.DEFAULT_DO_NOT_USE versionCode=1 -versionName=0.0.1 \ No newline at end of file +versionName=0.0.1 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index d3eafa8d7152..fbae24a5dcfd 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -49,6 +49,9 @@ class _MyAppState extends State<_MyApp> { List _purchases = []; List _consumables = []; String _countryCode = ''; + String _isAlternativeBillingOnlyAvailableResponseCode = ''; + String _showAlternativeBillingOnlyDialogResponseCode = ''; + String _alternativeBillingOnlyReportingDetailsToken = ''; bool _isAvailable = false; bool _purchasePending = false; bool _loading = true; @@ -145,6 +148,7 @@ class _MyAppState extends State<_MyApp> { _buildProductList(), _buildConsumableBox(), const _FeatureCard(), + _buildFetchButtons(), ], ), ); @@ -209,6 +213,119 @@ class _MyAppState extends State<_MyApp> { return Card(child: Column(children: children)); } + Card _buildFetchButtons() { + const ListTile header = ListTile(title: Text('AlternativeBilling Info')); + final List entries = []; + entries.add(ListTile( + title: Text('User Country Code', + style: TextStyle(color: ThemeData.light().colorScheme.primary)), + subtitle: Text(_countryCode))); + entries.add(ListTile( + title: Text('isAlternativeBillingOnlyAvailable response code', + style: TextStyle(color: ThemeData.light().colorScheme.primary)), + subtitle: Text(_isAlternativeBillingOnlyAvailableResponseCode))); + entries.add(ListTile( + title: Text('showAlternativeBillingOnlyDialog response code', + style: TextStyle(color: ThemeData.light().colorScheme.primary)), + subtitle: Text(_showAlternativeBillingOnlyDialogResponseCode))); + entries.add(ListTile( + title: Text('createAlternativeBillingOnlyReportingDetails contents', + style: TextStyle(color: ThemeData.light().colorScheme.primary)), + subtitle: Text(_alternativeBillingOnlyReportingDetailsToken))); + + final List buttons = []; + buttons.add(ListTile( + title: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; + unawaited(deliverCountryCode(addition.getCountryCode())); + }, + child: const Text('Fetch Country Code'), + ), + )); + buttons.add(ListTile( + title: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; + unawaited(deliverIsAlternativeBillingOnlyAvailable( + addition.isAlternativeBillingOnlyAvailable())); + }, + child: const Text('isAlternativeBillingOnlyAvailable'), + ), + )); + buttons.add(ListTile( + title: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; + unawaited(deliverShowAlternativeBillingOnlyInformationDialogResult( + addition.showAlternativeBillingOnlyInformationDialog())); + }, + child: const Text('showAlternativeBillingOnlyInformationDialog'), + ), + )); + buttons.add(ListTile( + title: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; + unawaited(addition + .setBillingChoice(BillingChoiceMode.alternativeBillingOnly)); + }, + child: const Text('setBillingChoice alternativeBillingOnly'), + ), + )); + buttons.add(ListTile( + title: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance! + as InAppPurchaseAndroidPlatformAddition; + unawaited(deliverCreateAlternativeBillingOnlyReportingDetails( + addition.createAlternativeBillingOnlyReportingDetails())); + }, + child: const Text('createAlternativeBillingOnlyReportingDetails'), + ), + )); + return Card( + child: Column( + children: [ + header, + const Divider(), + ...entries, + const Divider(), + ...buttons, + ], + ), + ); + } + Card _buildProductList() { if (_loading) { return const Card( @@ -229,11 +346,6 @@ class _MyAppState extends State<_MyApp> { 'This app needs special configuration to run. Please see example/README.md for instructions.'))); } - productList.add(ListTile( - title: Text('User Country Code', - style: TextStyle(color: ThemeData.light().colorScheme.error)), - subtitle: Text(_countryCode))); - // This loading previous purchases code is just a demo. Please do not use this as it is. // In your app you should always verify the purchase data using the `verificationData` inside the [PurchaseDetails] object before trusting it. // We recommend that you use your own server to verify the purchase data. @@ -352,12 +464,46 @@ class _MyAppState extends State<_MyApp> { }); } - Future deliverCountryCode(String countryCode) async { + Future deliverCountryCode(Future countryCodeFuture) async { + final String countryCode = await countryCodeFuture; setState(() { _countryCode = countryCode; }); } + Future deliverIsAlternativeBillingOnlyAvailable( + Future billingOnly) async { + final BillingResultWrapper wrapper = await billingOnly; + setState(() { + _isAlternativeBillingOnlyAvailableResponseCode = + wrapper.responseCode.name; + }); + } + + Future deliverShowAlternativeBillingOnlyInformationDialogResult( + Future billingResult) async { + final BillingResultWrapper wrapper = await billingResult; + setState(() { + _showAlternativeBillingOnlyDialogResponseCode = wrapper.responseCode.name; + }); + } + + Future deliverCreateAlternativeBillingOnlyReportingDetails( + Future + futureWrapper) async { + final AlternativeBillingOnlyReportingDetailsWrapper wrapper = + await futureWrapper; + setState(() { + if (wrapper.responseCode == BillingResponse.ok) { + _alternativeBillingOnlyReportingDetailsToken = + wrapper.externalTransactionToken; + } else { + _alternativeBillingOnlyReportingDetailsToken = + wrapper.responseCode.name; + } + }); + } + Future deliverProduct(PurchaseDetails purchaseDetails) async { // IMPORTANT!! Always verify purchase details before delivering the product. if (purchaseDetails.productID == _kConsumableId) { @@ -400,7 +546,6 @@ class _MyAppState extends State<_MyApp> { final InAppPurchaseAndroidPlatformAddition addition = InAppPurchasePlatformAddition.instance! as InAppPurchaseAndroidPlatformAddition; - unawaited(deliverCountryCode(await addition.getCountryCode())); if (purchaseDetails.status == PurchaseStatus.error) { handleError(purchaseDetails.error!); } else if (purchaseDetails.status == PurchaseStatus.purchased || diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart index 31133424afb6..8a53b95e9a7e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.dart'; export 'src/billing_client_wrappers/billing_client_manager.dart'; export 'src/billing_client_wrappers/billing_client_wrapper.dart'; export 'src/billing_client_wrappers/billing_response_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.dart new file mode 100644 index 000000000000..6e6dee97fb82 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.dart @@ -0,0 +1,79 @@ +// 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/material.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 'alternative_billing_only_reporting_details_wrapper.g.dart'; + +/// The error message shown when the map representing details is invalid from method channel. +/// +/// This usually indicates a serious underlying code issue in the plugin. +@visibleForTesting +const String kInvalidAlternativeBillingReportingDetailsErrorMessage = + 'Invalid AlternativeBillingReportingDetails map from method channel.'; + +/// Params containing the response code and the debug message from the Play Billing API response. +@JsonSerializable() +@BillingResponseConverter() +@immutable +class AlternativeBillingOnlyReportingDetailsWrapper + implements HasBillingResponse { + /// Constructs the object with [responseCode] and [debugMessage]. + const AlternativeBillingOnlyReportingDetailsWrapper( + {required this.responseCode, + this.debugMessage, + this.externalTransactionToken = ''}); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. + factory AlternativeBillingOnlyReportingDetailsWrapper.fromJson( + Map? map) { + if (map == null || map.isEmpty) { + return const AlternativeBillingOnlyReportingDetailsWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidAlternativeBillingReportingDetailsErrorMessage, + ); + } + return _$AlternativeBillingOnlyReportingDetailsWrapperFromJson(map); + } + + /// Response code returned in the Play Billing API calls. + @override + final BillingResponse responseCode; + + /// Debug message returned in the Play Billing API calls. + /// + /// Defaults to `null`. + /// This message uses an en-US locale and should not be shown to users. + @JsonKey(defaultValue: '') + final String? debugMessage; + + /// https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingOnlyReportingDetails#getExternalTransactionToken() + @JsonKey(defaultValue: '') + final String externalTransactionToken; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is AlternativeBillingOnlyReportingDetailsWrapper && + other.responseCode == responseCode && + other.debugMessage == debugMessage && + other.externalTransactionToken == externalTransactionToken; + } + + @override + int get hashCode => + Object.hash(responseCode, debugMessage, externalTransactionToken); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.g.dart new file mode 100644 index 000000000000..46b82645de0f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/alternative_billing_only_reporting_details_wrapper.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'alternative_billing_only_reporting_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AlternativeBillingOnlyReportingDetailsWrapper + _$AlternativeBillingOnlyReportingDetailsWrapperFromJson(Map json) => + AlternativeBillingOnlyReportingDetailsWrapper( + responseCode: const BillingResponseConverter() + .fromJson(json['responseCode'] as int?), + debugMessage: json['debugMessage'] as String? ?? '', + externalTransactionToken: + json['externalTransactionToken'] as String? ?? '', + ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart index 0eca29606a5c..789ba5e01cc3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_manager.dart @@ -32,7 +32,8 @@ class BillingClientManager { /// Creates the [BillingClientManager]. /// /// Immediately initializes connection to the underlying [BillingClient]. - BillingClientManager() { + BillingClientManager() + : _billingChoiceMode = BillingChoiceMode.playBillingOnly { _connect(); } @@ -53,6 +54,7 @@ class BillingClientManager { final StreamController _purchasesUpdatedController = StreamController.broadcast(); + BillingChoiceMode _billingChoiceMode; bool _isConnecting = false; bool _isDisposed = false; @@ -119,6 +121,19 @@ class BillingClientManager { _purchasesUpdatedController.close(); } + /// Ends connection to [BillingClient] and reconnects with [billingChoiceMode]. + /// + /// Callers need to check if [BillingChoiceMode.alternativeBillingOnly] is + /// available by calling [BillingClientWrapper.isAlternativeBillingOnlyAvailable] + /// first. + Future reconnectWithBillingChoiceMode( + BillingChoiceMode billingChoiceMode) async { + _billingChoiceMode = billingChoiceMode; + // Ends connection and triggers OnBillingServiceDisconnected, which causes reconnect. + await client.endConnection(); + await _connect(); + } + // If disposed, does nothing. // If currently connecting, waits for it to complete. // Otherwise, starts a new connection. @@ -131,7 +146,9 @@ class BillingClientManager { } _isConnecting = true; _readyFuture = Future.sync(() async { - await client.startConnection(onBillingServiceDisconnected: _connect); + await client.startConnection( + onBillingServiceDisconnected: _connect, + billingChoiceMode: _billingChoiceMode); _isConnecting = false; }); return _readyFuture; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index c54fc54e91b0..15dc4217fe69 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -109,8 +109,9 @@ class BillingClient { /// This triggers the creation of a new `BillingClient` instance in Java if /// one doesn't already exist. Future startConnection( - {required OnBillingServiceDisconnected - onBillingServiceDisconnected}) async { + {required OnBillingServiceDisconnected onBillingServiceDisconnected, + BillingChoiceMode billingChoiceMode = + BillingChoiceMode.playBillingOnly}) async { final List disconnectCallbacks = _callbacks[_kOnBillingServiceDisconnected] ??= []; disconnectCallbacks.add(onBillingServiceDisconnected); @@ -119,6 +120,8 @@ class BillingClient { 'BillingClient#startConnection(BillingClientStateListener)', { 'handle': disconnectCallbacks.length - 1, + 'billingChoiceMode': + const BillingChoiceModeConverter().toJson(billingChoiceMode), })) ?? {}); } @@ -330,7 +333,7 @@ class BillingClient { // Must match the value of GET_BILLING_CONFIG in // ../../../android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @visibleForTesting - final String getBillingConfigMethodString = + static const String getBillingConfigMethodString = 'BillingClient#getBillingConfig()'; /// Fetches billing config info into a [BillingConfigWrapper] object. @@ -340,6 +343,57 @@ class BillingClient { {}); } + /// isAlternativeBillingOnlyAvailable method channel string identifier. + // + // Must match the value of IS_ALTERNATIVE_BILLING_ONLY_AVAILABLE in + // ../../../android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java + @visibleForTesting + static const String isAlternativeBillingOnlyAvailableMethodString = + 'BillingClient#isAlternativeBillingOnlyAvailable()'; + + /// Checks if "AlterntitiveBillingOnly" feature is available. + Future isAlternativeBillingOnlyAvailable() async { + return BillingResultWrapper.fromJson( + (await channel.invokeMapMethod( + isAlternativeBillingOnlyAvailableMethodString)) ?? + {}); + } + + /// showAlternativeBillingOnlyInformationDialog method channel string identifier. + // + // Must match the value of SHOW_ALTERNATIVE_BILLING_ONLY_INFORMATION_DIALOG in + // ../../../android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java + @visibleForTesting + static const String showAlternativeBillingOnlyInformationDialogMethodString = + 'BillingClient#showAlternativeBillingOnlyInformationDialog()'; + + /// Shows the alternative billing only information dialog on top of the calling app. + Future + showAlternativeBillingOnlyInformationDialog() async { + return BillingResultWrapper.fromJson( + (await channel.invokeMapMethod( + showAlternativeBillingOnlyInformationDialogMethodString)) ?? + {}); + } + + /// createAlternativeBillingOnlyReportingDetails method channel string identifier. + // + // Must match the value of CREATE_ALTERNATIVE_BILLING_ONLY_REPORTING_DETAILS in + // ../../../android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java + @visibleForTesting + static const String createAlternativeBillingOnlyReportingDetailsMethodString = + 'BillingClient#createAlternativeBillingOnlyReportingDetails()'; + + /// The details used to report transactions made via alternative billing + /// without user choice to use Google Play billing. + Future + createAlternativeBillingOnlyReportingDetails() async { + return AlternativeBillingOnlyReportingDetailsWrapper.fromJson( + (await channel.invokeMapMethod( + createAlternativeBillingOnlyReportingDetailsMethodString)) ?? + {}); + } + /// The method call handler for [channel]. @visibleForTesting Future callHandler(MethodCall call) async { @@ -434,6 +488,48 @@ enum BillingResponse { networkError, } +/// Plugin concept to cover billing modes. +/// +/// [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. + // Run `flutter packages pub run build_runner watch` to rebuild and watch for + // further changes. + // 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. + @JsonValue(0) + playBillingOnly, + + /// Billing through app provided flow. + @JsonValue(1) + alternativeBillingOnly, +} + +/// Serializer for [BillingChoiceMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@BillingChoiceModeConverter()`. +class BillingChoiceModeConverter + implements JsonConverter { + /// Default const constructor. + const BillingChoiceModeConverter(); + + @override + BillingChoiceMode fromJson(int? json) { + if (json == null) { + return BillingChoiceMode.playBillingOnly; + } + return $enumDecode(_$BillingChoiceModeEnumMap, json); + } + + @override + int toJson(BillingChoiceMode object) => _$BillingChoiceModeEnumMap[object]!; +} + /// Serializer for [BillingResponse]. /// /// Use these in `@JsonSerializable()` classes by annotating them with diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart index f0f99ee55255..eb7b41afce14 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart @@ -22,6 +22,11 @@ const _$BillingResponseEnumMap = { BillingResponse.networkError: 12, }; +const _$BillingChoiceModeEnumMap = { + BillingChoiceMode.playBillingOnly: 0, + BillingChoiceMode.alternativeBillingOnly: 1, +}; + const _$ProductTypeEnumMap = { ProductType.inapp: 'inapp', ProductType.subs: 'subs', diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index aa660192ee39..732840802d28 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -157,4 +157,51 @@ class InAppPurchaseAndroidPlatformAddition .runWithClient((BillingClient client) => client.getBillingConfig()); return billingConfig.countryCode; } + + /// Returns if the caller can use alternative billing only without giving the + /// user a choice to use Play billing. + /// + /// See: https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isAlternativeBillingOnlyAvailableAsync(com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener) + Future isAlternativeBillingOnlyAvailable() async { + final BillingResultWrapper wrapper = + await _billingClientManager.runWithClient((BillingClient client) => + client.isAlternativeBillingOnlyAvailable()); + return wrapper; + } + + /// Shows the alternative billing only information dialog on top of the calling app. + /// + /// See: https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showAlternativeBillingOnlyInformationDialog(android.app.Activity,%20com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener) + Future + showAlternativeBillingOnlyInformationDialog() async { + final BillingResultWrapper wrapper = + await _billingClientManager.runWithClient((BillingClient client) => + client.showAlternativeBillingOnlyInformationDialog()); + return wrapper; + } + + /// The details used to report transactions made via alternative billing + /// without user choice to use Google Play billing. + /// + /// See: https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingOnlyReportingDetails + Future + createAlternativeBillingOnlyReportingDetails() async { + final AlternativeBillingOnlyReportingDetailsWrapper wrapper = + await _billingClientManager.runWithClient((BillingClient client) => + client.createAlternativeBillingOnlyReportingDetails()); + return wrapper; + } + + /// Disconnects, sets AlternativeBillingOnly to true, and reconnects to + /// the [BillingClient]. + /// + /// [BillingChoiceMode.playBillingOnly] is the default state used. + /// [BillingChoiceMode.alternativeBillingOnly] will enable alternative billing only. + /// + /// Play apis have requirements for when this method can be called. + /// See: https://developer.android.com/google/play/billing/alternative/alternative-billing-without-user-choice-in-app + Future setBillingChoice(BillingChoiceMode billingChoiceMode) { + return _billingClientManager + .reconnectWithBillingChoiceMode(billingChoiceMode); + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index d97518aa751e..08f34f113fbc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -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.0+18 +version: 0.3.1 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart index 85af3aa15d9d..b53bb5c96c3f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_manager_test.dart @@ -81,6 +81,36 @@ void main() { expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); }); + test('re-connects when host calls reconnectWithBillingChoiceMode', + () async { + connectedCompleter.complete(); + // Ensures all asynchronous connected code finishes. + await manager.runWithClientNonRetryable((_) async {}); + + await manager.reconnectWithBillingChoiceMode( + BillingChoiceMode.alternativeBillingOnly); + // Verify that connection was ended. + expect(stubPlatform.countPreviousCalls(endConnectionCall), equals(1)); + + stubPlatform.reset(); + + late Map arguments; + stubPlatform.addResponse( + name: startConnectionCall, + additionalStepBeforeReturn: (dynamic value) => + arguments = value as Map, + ); + + /// Fake the disconnect that we would expect from a endConnectionCall. + await manager.client.callHandler( + const MethodCall(onBillingServiceDisconnectedCallback, + {'handle': 0}), + ); + // Verify that after connection ended reconnect was called. + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); + expect(arguments['billingChoiceMode'], 1); + }); + test( 're-connects when operation returns BillingResponse.serviceDisconnected', () async { diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index df1c6278a1c3..3ffcd8d5d08e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -106,7 +106,34 @@ void main() { ); await billingClient.startConnection(onBillingServiceDisconnected: () {}); final MethodCall call = stubPlatform.previousCallMatching(methodName); - expect(call.arguments, equals({'handle': 0})); + expect( + call.arguments, + equals({ + 'handle': 0, + 'billingChoiceMode': 0, + })); + }); + + test('passes billingChoiceMode when set', () async { + const String debugMessage = 'dummy message'; + const BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse( + name: methodName, + value: { + 'responseCode': const BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + await billingClient.startConnection( + onBillingServiceDisconnected: () {}, + billingChoiceMode: BillingChoiceMode.alternativeBillingOnly); + final MethodCall call = stubPlatform.previousCallMatching(methodName); + expect( + call.arguments, + equals({ + 'handle': 0, + 'billingChoiceMode': 1, + })); }); test('handles method channel returning null', () async { @@ -644,14 +671,13 @@ void main() { }); group('billingConfig', () { - const String billingConfigMethodName = 'BillingClient#getBillingConfig()'; test('billingConfig returns object', () async { const BillingConfigWrapper expected = BillingConfigWrapper( countryCode: 'US', responseCode: BillingResponse.ok, debugMessage: ''); stubPlatform.addResponse( - name: billingConfigMethodName, + name: BillingClient.getBillingConfigMethodString, value: buildBillingConfigMap(expected), ); final BillingConfigWrapper result = @@ -662,7 +688,7 @@ void main() { test('handles method channel returning null', () async { stubPlatform.addResponse( - name: billingConfigMethodName, + name: BillingClient.getBillingConfigMethodString, ); final BillingConfigWrapper result = await billingClient.getBillingConfig(); @@ -674,6 +700,79 @@ void main() { ))); }); }); + + group('isAlternativeBillingOnlyAvailable', () { + test('returns object', () async { + const BillingResultWrapper expected = + BillingResultWrapper(responseCode: BillingResponse.ok); + stubPlatform.addResponse( + name: BillingClient.isAlternativeBillingOnlyAvailableMethodString, + value: buildBillingResultMap(expected)); + final BillingResultWrapper result = + await billingClient.isAlternativeBillingOnlyAvailable(); + expect(result, expected); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: BillingClient.isAlternativeBillingOnlyAvailableMethodString, + ); + final BillingResultWrapper result = + await billingClient.isAlternativeBillingOnlyAvailable(); + expect(result.responseCode, BillingResponse.error); + }); + }); + + group('createAlternativeBillingOnlyReportingDetails', () { + test('returns object', () async { + const AlternativeBillingOnlyReportingDetailsWrapper expected = + AlternativeBillingOnlyReportingDetailsWrapper( + responseCode: BillingResponse.ok, + debugMessage: 'debug', + externalTransactionToken: 'abc123youandme'); + stubPlatform.addResponse( + name: BillingClient + .createAlternativeBillingOnlyReportingDetailsMethodString, + value: buildAlternativeBillingOnlyReportingDetailsMap(expected)); + final AlternativeBillingOnlyReportingDetailsWrapper result = + await billingClient.createAlternativeBillingOnlyReportingDetails(); + expect(result, equals(expected)); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: BillingClient + .createAlternativeBillingOnlyReportingDetailsMethodString, + ); + final AlternativeBillingOnlyReportingDetailsWrapper result = + await billingClient.createAlternativeBillingOnlyReportingDetails(); + expect(result.responseCode, BillingResponse.error); + }); + }); + + group('showAlternativeBillingOnlyInformationDialog', () { + test('returns object', () async { + const BillingResultWrapper expected = + BillingResultWrapper(responseCode: BillingResponse.ok); + stubPlatform.addResponse( + name: BillingClient + .showAlternativeBillingOnlyInformationDialogMethodString, + value: buildBillingResultMap(expected)); + final BillingResultWrapper result = + await billingClient.showAlternativeBillingOnlyInformationDialog(); + expect(result, expected); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: BillingClient + .showAlternativeBillingOnlyInformationDialogMethodString, + ); + final BillingResultWrapper result = + await billingClient.showAlternativeBillingOnlyInformationDialog(); + expect(result.responseCode, BillingResponse.error); + }); + }); } Map buildBillingConfigMap(BillingConfigWrapper original) { @@ -684,3 +783,14 @@ Map buildBillingConfigMap(BillingConfigWrapper original) { 'countryCode': original.countryCode, }; } + +Map buildAlternativeBillingOnlyReportingDetailsMap( + AlternativeBillingOnlyReportingDetailsWrapper original) { + return { + 'responseCode': + const BillingResponseConverter().toJson(original.responseCode), + 'debugMessage': original.debugMessage, + // from: io/flutter/plugins/inapppurchase/Translator.java + 'externalTransactionToken': original.externalTransactionToken, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 4a97a43df5e9..718bd3cdde1c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -22,6 +22,9 @@ void main() { const String startConnectionCall = 'BillingClient#startConnection(BillingClientStateListener)'; const String endConnectionCall = 'BillingClient#endConnection()'; + const String onBillingServiceDisconnectedCallback = + 'BillingClientStateListener#onBillingServiceDisconnected()'; + late BillingClientManager manager; setUpAll(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -39,8 +42,8 @@ void main() { name: startConnectionCall, value: buildBillingResultMap(expectedBillingResult)); stubPlatform.addResponse(name: endConnectionCall); - iapAndroidPlatformAddition = - InAppPurchaseAndroidPlatformAddition(BillingClientManager()); + manager = BillingClientManager(); + iapAndroidPlatformAddition = InAppPurchaseAndroidPlatformAddition(manager); }); group('consume purchases', () { @@ -64,7 +67,6 @@ void main() { }); group('billingConfig', () { - const String billingConfigMethodName = 'BillingClient#getBillingConfig()'; test('getCountryCode success', () async { const String expectedCountryCode = 'US'; const BillingConfigWrapper expected = BillingConfigWrapper( @@ -73,7 +75,7 @@ void main() { debugMessage: 'dummy message'); stubPlatform.addResponse( - name: billingConfigMethodName, + name: BillingClient.getBillingConfigMethodString, value: buildBillingConfigMap(expected), ); final String countryCode = @@ -83,6 +85,90 @@ void main() { }); }); + group('setBillingChoice', () { + late Map arguments; + test('setAlternativeBillingOnlyState', () async { + stubPlatform.reset(); + stubPlatform.addResponse( + name: startConnectionCall, + additionalStepBeforeReturn: (dynamic value) => + arguments = value as Map, + ); + stubPlatform.addResponse(name: endConnectionCall); + await iapAndroidPlatformAddition + .setBillingChoice(BillingChoiceMode.alternativeBillingOnly); + + // Fake the disconnect that we would expect from a endConnectionCall. + await manager.client.callHandler( + const MethodCall(onBillingServiceDisconnectedCallback, + {'handle': 0}), + ); + // Verify that after connection ended reconnect was called. + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + expect( + arguments['billingChoiceMode'], + const BillingChoiceModeConverter() + .toJson(BillingChoiceMode.alternativeBillingOnly)); + }); + + test('setPlayBillingState', () async { + stubPlatform.reset(); + stubPlatform.addResponse( + name: startConnectionCall, + additionalStepBeforeReturn: (dynamic value) => + arguments = value as Map, + ); + stubPlatform.addResponse(name: endConnectionCall); + await iapAndroidPlatformAddition + .setBillingChoice(BillingChoiceMode.playBillingOnly); + + // Fake the disconnect that we would expect from a endConnectionCall. + await manager.client.callHandler( + const MethodCall(onBillingServiceDisconnectedCallback, + {'handle': 0}), + ); + // Verify that after connection ended reconnect was called. + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(2)); + expect( + arguments['billingChoiceMode'], + const BillingChoiceModeConverter() + .toJson(BillingChoiceMode.playBillingOnly)); + }); + }); + + group('isAlternativeBillingOnlyAvailable', () { + test('isAlternativeBillingOnlyAvailable success', () async { + const BillingResultWrapper expected = BillingResultWrapper( + responseCode: BillingResponse.ok, debugMessage: 'dummy message'); + + stubPlatform.addResponse( + name: BillingClient.isAlternativeBillingOnlyAvailableMethodString, + value: buildBillingResultMap(expected), + ); + final BillingResultWrapper result = + await iapAndroidPlatformAddition.isAlternativeBillingOnlyAvailable(); + + expect(result, equals(expected)); + }); + }); + + group('showAlternativeBillingOnlyInformationDialog', () { + test('showAlternativeBillingOnlyInformationDialog success', () async { + const BillingResultWrapper expected = BillingResultWrapper( + responseCode: BillingResponse.ok, debugMessage: 'dummy message'); + + stubPlatform.addResponse( + name: BillingClient + .showAlternativeBillingOnlyInformationDialogMethodString, + value: buildBillingResultMap(expected), + ); + final BillingResultWrapper result = + await iapAndroidPlatformAddition.isAlternativeBillingOnlyAvailable(); + + expect(result, equals(expected)); + }); + }); + group('queryPastPurchase', () { group('queryPurchaseDetails', () { const String queryMethodName =