diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md
new file mode 100644
index 000000000000..d46c124b9011
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0
+
+* Initial open-source release.
\ No newline at end of file
diff --git a/packages/in_app_purchase/in_app_purchase_android/LICENSE b/packages/in_app_purchase/in_app_purchase_android/LICENSE
new file mode 100644
index 000000000000..c6823b81eb84
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/LICENSE
@@ -0,0 +1,25 @@
+Copyright 2013 The Flutter Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md
new file mode 100644
index 000000000000..41618fa15d7b
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/README.md
@@ -0,0 +1,38 @@
+# in_app_purchase_android
+
+The Android implementation of [`in_app_purchase`][1].
+
+## Usage
+
+### Import the package
+
+This package has been endorsed, meaning that you only need to add `in_app_purchase`
+as a dependency in your `pubspec.yaml`. It will be automatically included in your app
+when you depend on `package:in_app_purchase`.
+
+This is what the above means to your `pubspec.yaml`:
+
+```yaml
+...
+dependencies:
+ ...
+ in_app_purchase: ^0.6.0
+ ...
+```
+
+If you wish to use the Android package only, you can add `in_app_purchase_android` as a
+dependency:
+
+```yaml
+...
+dependencies:
+ ...
+ in_app_purchase_android: ^1.0.0
+ ...
+```
+
+## TODO
+- [ ] Add an example application demonstrating the use of the [in_app_purchase_android] package (see also issue [flutter/flutter#81695](https://github.com/flutter/flutter/issues/81695)).
+
+
+[1]: ../in_app_purchase/in_app_purchase
\ No newline at end of file
diff --git a/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml
new file mode 100644
index 000000000000..5aeb4e7c5e21
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml
@@ -0,0 +1 @@
+include: ../../../analysis_options_legacy.yaml
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
new file mode 100644
index 000000000000..a36f70137129
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle
@@ -0,0 +1,48 @@
+group 'io.flutter.plugins.inapppurchase'
+version '1.0-SNAPSHOT'
+
+buildscript {
+ repositories {
+ google()
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.3.0'
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 29
+
+ defaultConfig {
+ minSdkVersion 16
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ lintOptions {
+ disable 'InvalidPackage'
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ implementation 'androidx.annotation:annotation:1.0.0'
+ implementation 'com.android.billingclient:billing:3.0.2'
+ testImplementation 'junit:junit:4.12'
+ testImplementation 'org.json:json:20180813'
+ testImplementation 'org.mockito:mockito-core:3.6.0'
+ androidTestImplementation 'androidx.test:runner:1.1.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties
new file mode 100644
index 000000000000..8bd86f680510
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties
@@ -0,0 +1 @@
+org.gradle.jvmargs=-Xmx1536M
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000000..73eba353b126
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Oct 29 10:30:44 PDT 2018
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle b/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle
new file mode 100644
index 000000000000..58efd2e9323e
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'in_app_purchase'
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml b/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000000..ae902de2368a
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
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
new file mode 100644
index 000000000000..7b21cbf2e6f5
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java
@@ -0,0 +1,26 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import com.android.billingclient.api.BillingClient;
+import io.flutter.plugin.common.MethodChannel;
+
+/** Responsible for creating a {@link BillingClient} object. */
+interface BillingClientFactory {
+
+ /**
+ * Creates and returns a {@link BillingClient}.
+ *
+ * @param context The context used to create the {@link BillingClient}.
+ * @param channel The method channel used to create the {@link BillingClient}.
+ * @param enablePendingPurchases Whether to enable pending purchases. Throws an exception if it is
+ * false.
+ * @return The {@link BillingClient} object that is created.
+ */
+ BillingClient createBillingClient(
+ @NonNull Context context, @NonNull MethodChannel channel, boolean enablePendingPurchases);
+}
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
new file mode 100644
index 000000000000..c256d2c59551
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java
@@ -0,0 +1,23 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import android.content.Context;
+import com.android.billingclient.api.BillingClient;
+import io.flutter.plugin.common.MethodChannel;
+
+/** The implementation for {@link BillingClientFactory} for the plugin. */
+final class BillingClientFactoryImpl implements BillingClientFactory {
+
+ @Override
+ public BillingClient createBillingClient(
+ Context context, MethodChannel channel, boolean enablePendingPurchases) {
+ BillingClient.Builder builder = BillingClient.newBuilder(context);
+ if (enablePendingPurchases) {
+ builder.enablePendingPurchases();
+ }
+ 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/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java
new file mode 100644
index 000000000000..e4719f030d53
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java
@@ -0,0 +1,106 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import androidx.annotation.VisibleForTesting;
+import com.android.billingclient.api.BillingClient;
+import io.flutter.embedding.engine.plugins.FlutterPlugin;
+import io.flutter.embedding.engine.plugins.activity.ActivityAware;
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
+import io.flutter.plugin.common.BinaryMessenger;
+import io.flutter.plugin.common.MethodChannel;
+
+/** Wraps a {@link BillingClient} instance and responds to Dart calls for it. */
+public class InAppPurchasePlugin implements FlutterPlugin, ActivityAware {
+
+ @VisibleForTesting
+ static final class MethodNames {
+ static final String IS_READY = "BillingClient#isReady()";
+ static final String START_CONNECTION =
+ "BillingClient#startConnection(BillingClientStateListener)";
+ static final String END_CONNECTION = "BillingClient#endConnection()";
+ static final String ON_DISCONNECT = "BillingClientStateListener#onBillingServiceDisconnected()";
+ static final String QUERY_SKU_DETAILS =
+ "BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)";
+ static final String LAUNCH_BILLING_FLOW =
+ "BillingClient#launchBillingFlow(Activity, BillingFlowParams)";
+ static final String ON_PURCHASES_UPDATED =
+ "PurchasesUpdatedListener#onPurchasesUpdated(int, List)";
+ static final String QUERY_PURCHASES = "BillingClient#queryPurchases(String)";
+ static final String QUERY_PURCHASE_HISTORY_ASYNC =
+ "BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)";
+ static final String CONSUME_PURCHASE_ASYNC =
+ "BillingClient#consumeAsync(String, ConsumeResponseListener)";
+ static final String ACKNOWLEDGE_PURCHASE =
+ "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)";
+
+ private MethodNames() {};
+ }
+
+ private MethodChannel methodChannel;
+ private MethodCallHandlerImpl methodCallHandler;
+
+ /** Plugin registration. */
+ @SuppressWarnings("deprecation")
+ public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) {
+ InAppPurchasePlugin plugin = new InAppPurchasePlugin();
+ plugin.setupMethodChannel(registrar.activity(), registrar.messenger(), registrar.context());
+ ((Application) registrar.context().getApplicationContext())
+ .registerActivityLifecycleCallbacks(plugin.methodCallHandler);
+ }
+
+ @Override
+ public void onAttachedToEngine(FlutterPlugin.FlutterPluginBinding binding) {
+ setupMethodChannel(
+ /*activity=*/ null, binding.getBinaryMessenger(), binding.getApplicationContext());
+ }
+
+ @Override
+ public void onDetachedFromEngine(FlutterPlugin.FlutterPluginBinding binding) {
+ teardownMethodChannel();
+ }
+
+ @Override
+ public void onAttachedToActivity(ActivityPluginBinding binding) {
+ methodCallHandler.setActivity(binding.getActivity());
+ }
+
+ @Override
+ public void onDetachedFromActivity() {
+ methodCallHandler.setActivity(null);
+ methodCallHandler.onDetachedFromActivity();
+ }
+
+ @Override
+ public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) {
+ onAttachedToActivity(binding);
+ }
+
+ @Override
+ public void onDetachedFromActivityForConfigChanges() {
+ methodCallHandler.setActivity(null);
+ }
+
+ private void setupMethodChannel(Activity activity, BinaryMessenger messenger, Context context) {
+ methodChannel = new MethodChannel(messenger, "plugins.flutter.io/in_app_purchase");
+ methodCallHandler =
+ new MethodCallHandlerImpl(activity, context, methodChannel, new BillingClientFactoryImpl());
+ methodChannel.setMethodCallHandler(methodCallHandler);
+ }
+
+ private void teardownMethodChannel() {
+ methodChannel.setMethodCallHandler(null);
+ methodChannel = null;
+ methodCallHandler = null;
+ }
+
+ @VisibleForTesting
+ void setMethodCallHandler(MethodCallHandlerImpl methodCallHandler) {
+ this.methodCallHandler = methodCallHandler;
+ }
+}
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
new file mode 100644
index 000000000000..cfcb81ae05b5
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java
@@ -0,0 +1,382 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult;
+import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+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.BillingClient;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.BillingFlowParams.ProrationMode;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.ConsumeParams;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.PurchaseHistoryRecord;
+import com.android.billingclient.api.PurchaseHistoryResponseListener;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import io.flutter.plugin.common.MethodCall;
+import io.flutter.plugin.common.MethodChannel;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Handles method channel for the plugin. */
+class MethodCallHandlerImpl
+ implements MethodChannel.MethodCallHandler, Application.ActivityLifecycleCallbacks {
+
+ private static final String TAG = "InAppPurchasePlugin";
+ private static final String LOAD_SKU_DOC_URL =
+ "https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale";
+
+ @Nullable private BillingClient billingClient;
+ private final BillingClientFactory billingClientFactory;
+
+ @Nullable private Activity activity;
+ private final Context applicationContext;
+ private final MethodChannel methodChannel;
+
+ private HashMap cachedSkus = new HashMap<>();
+
+ /** Constructs the MethodCallHandlerImpl */
+ MethodCallHandlerImpl(
+ @Nullable Activity activity,
+ @NonNull Context applicationContext,
+ @NonNull MethodChannel methodChannel,
+ @NonNull BillingClientFactory billingClientFactory) {
+ this.billingClientFactory = billingClientFactory;
+ this.applicationContext = applicationContext;
+ this.activity = activity;
+ this.methodChannel = methodChannel;
+ }
+
+ /**
+ * Sets the activity. Should be called as soon as the the activity is available. When the activity
+ * becomes unavailable, call this method again with {@code null}.
+ */
+ void setActivity(@Nullable Activity activity) {
+ this.activity = activity;
+ }
+
+ @Override
+ public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
+
+ @Override
+ public void onActivityStarted(Activity activity) {}
+
+ @Override
+ public void onActivityResumed(Activity activity) {}
+
+ @Override
+ public void onActivityPaused(Activity activity) {}
+
+ @Override
+ public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
+
+ @Override
+ public void onActivityDestroyed(Activity activity) {
+ if (this.activity == activity && this.applicationContext != null) {
+ ((Application) this.applicationContext).unregisterActivityLifecycleCallbacks(this);
+ endBillingClientConnection();
+ }
+ }
+
+ @Override
+ public void onActivityStopped(Activity activity) {}
+
+ void onDetachedFromActivity() {
+ endBillingClientConnection();
+ }
+
+ @Override
+ public void onMethodCall(MethodCall call, MethodChannel.Result result) {
+ switch (call.method) {
+ case InAppPurchasePlugin.MethodNames.IS_READY:
+ isReady(result);
+ break;
+ case InAppPurchasePlugin.MethodNames.START_CONNECTION:
+ startConnection(
+ (int) call.argument("handle"),
+ (boolean) call.argument("enablePendingPurchases"),
+ result);
+ break;
+ case InAppPurchasePlugin.MethodNames.END_CONNECTION:
+ endConnection(result);
+ break;
+ case InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS:
+ List skusList = call.argument("skusList");
+ querySkuDetailsAsync((String) call.argument("skuType"), skusList, result);
+ break;
+ case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW:
+ launchBillingFlow(
+ (String) call.argument("sku"),
+ (String) call.argument("accountId"),
+ (String) call.argument("obfuscatedProfileId"),
+ (String) call.argument("oldSku"),
+ (String) call.argument("purchaseToken"),
+ call.hasArgument("prorationMode")
+ ? (int) call.argument("prorationMode")
+ : ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY,
+ result);
+ break;
+ case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES:
+ queryPurchases((String) call.argument("skuType"), result);
+ break;
+ case InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC:
+ queryPurchaseHistoryAsync((String) call.argument("skuType"), result);
+ break;
+ case InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC:
+ consumeAsync((String) call.argument("purchaseToken"), result);
+ break;
+ case InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE:
+ acknowledgePurchase((String) call.argument("purchaseToken"), result);
+ break;
+ default:
+ result.notImplemented();
+ }
+ }
+
+ private void endConnection(final MethodChannel.Result result) {
+ endBillingClientConnection();
+ result.success(null);
+ }
+
+ private void endBillingClientConnection() {
+ if (billingClient != null) {
+ billingClient.endConnection();
+ billingClient = null;
+ }
+ }
+
+ private void isReady(MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ result.success(billingClient.isReady());
+ }
+
+ private void querySkuDetailsAsync(
+ final String skuType, final List skusList, final MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ SkuDetailsParams params =
+ SkuDetailsParams.newBuilder().setType(skuType).setSkusList(skusList).build();
+ billingClient.querySkuDetailsAsync(
+ params,
+ new SkuDetailsResponseListener() {
+ @Override
+ public void onSkuDetailsResponse(
+ BillingResult billingResult, List skuDetailsList) {
+ updateCachedSkus(skuDetailsList);
+ final Map skuDetailsResponse = new HashMap<>();
+ skuDetailsResponse.put("billingResult", Translator.fromBillingResult(billingResult));
+ skuDetailsResponse.put("skuDetailsList", fromSkuDetailsList(skuDetailsList));
+ result.success(skuDetailsResponse);
+ }
+ });
+ }
+
+ private void launchBillingFlow(
+ String sku,
+ @Nullable String accountId,
+ @Nullable String obfuscatedProfileId,
+ @Nullable String oldSku,
+ @Nullable String purchaseToken,
+ int prorationMode,
+ MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ SkuDetails skuDetails = cachedSkus.get(sku);
+ if (skuDetails == null) {
+ result.error(
+ "NOT_FOUND",
+ String.format(
+ "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s",
+ sku, LOAD_SKU_DOC_URL),
+ null);
+ return;
+ }
+
+ if (oldSku == null
+ && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) {
+ result.error(
+ "IN_APP_PURCHASE_REQUIRE_OLD_SKU",
+ "launchBillingFlow failed because oldSku is null. You must provide a valid oldSku in order to use a proration mode.",
+ null);
+ return;
+ } else if (oldSku != null && !cachedSkus.containsKey(oldSku)) {
+ result.error(
+ "IN_APP_PURCHASE_INVALID_OLD_SKU",
+ String.format(
+ "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s",
+ oldSku, LOAD_SKU_DOC_URL),
+ null);
+ return;
+ }
+
+ if (activity == null) {
+ result.error(
+ "ACTIVITY_UNAVAILABLE",
+ "Details for sku "
+ + sku
+ + " are not available. This method must be run with the app in foreground.",
+ null);
+ return;
+ }
+
+ BillingFlowParams.Builder paramsBuilder =
+ BillingFlowParams.newBuilder().setSkuDetails(skuDetails);
+ if (accountId != null && !accountId.isEmpty()) {
+ paramsBuilder.setObfuscatedAccountId(accountId);
+ }
+ if (obfuscatedProfileId != null && !obfuscatedProfileId.isEmpty()) {
+ paramsBuilder.setObfuscatedProfileId(obfuscatedProfileId);
+ }
+ if (oldSku != null && !oldSku.isEmpty()) {
+ paramsBuilder.setOldSku(oldSku, purchaseToken);
+ }
+ // The proration mode value has to match one of the following declared in
+ // https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode
+ paramsBuilder.setReplaceSkusProrationMode(prorationMode);
+ result.success(
+ Translator.fromBillingResult(
+ billingClient.launchBillingFlow(activity, paramsBuilder.build())));
+ }
+
+ private void consumeAsync(String purchaseToken, final MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ ConsumeResponseListener listener =
+ new ConsumeResponseListener() {
+ @Override
+ public void onConsumeResponse(BillingResult billingResult, String outToken) {
+ result.success(Translator.fromBillingResult(billingResult));
+ }
+ };
+ ConsumeParams.Builder paramsBuilder =
+ ConsumeParams.newBuilder().setPurchaseToken(purchaseToken);
+
+ ConsumeParams params = paramsBuilder.build();
+
+ billingClient.consumeAsync(params, listener);
+ }
+
+ private void queryPurchases(String skuType, MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ // Like in our connect call, consider the billing client responding a "success" here regardless
+ // of status code.
+ result.success(fromPurchasesResult(billingClient.queryPurchases(skuType)));
+ }
+
+ private void queryPurchaseHistoryAsync(String skuType, final MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+
+ billingClient.queryPurchaseHistoryAsync(
+ skuType,
+ new PurchaseHistoryResponseListener() {
+ @Override
+ public void onPurchaseHistoryResponse(
+ BillingResult billingResult, List purchasesList) {
+ final Map serialized = new HashMap<>();
+ serialized.put("billingResult", Translator.fromBillingResult(billingResult));
+ serialized.put(
+ "purchaseHistoryRecordList", fromPurchaseHistoryRecordList(purchasesList));
+ result.success(serialized);
+ }
+ });
+ }
+
+ private void startConnection(
+ final int handle, final boolean enablePendingPurchases, final MethodChannel.Result result) {
+ if (billingClient == null) {
+ billingClient =
+ billingClientFactory.createBillingClient(
+ applicationContext, methodChannel, enablePendingPurchases);
+ }
+
+ billingClient.startConnection(
+ new BillingClientStateListener() {
+ private boolean alreadyFinished = false;
+
+ @Override
+ public void onBillingSetupFinished(BillingResult billingResult) {
+ if (alreadyFinished) {
+ Log.d(TAG, "Tried to call onBilllingSetupFinished multiple times.");
+ return;
+ }
+ alreadyFinished = true;
+ // Consider the fact that we've finished a success, leave it to the Dart side to
+ // validate the responseCode.
+ result.success(Translator.fromBillingResult(billingResult));
+ }
+
+ @Override
+ public void onBillingServiceDisconnected() {
+ final Map arguments = new HashMap<>();
+ arguments.put("handle", handle);
+ methodChannel.invokeMethod(InAppPurchasePlugin.MethodNames.ON_DISCONNECT, arguments);
+ }
+ });
+ }
+
+ private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) {
+ if (billingClientError(result)) {
+ return;
+ }
+ AcknowledgePurchaseParams params =
+ AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build();
+ billingClient.acknowledgePurchase(
+ params,
+ new AcknowledgePurchaseResponseListener() {
+ @Override
+ public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
+ result.success(Translator.fromBillingResult(billingResult));
+ }
+ });
+ }
+
+ private void updateCachedSkus(@Nullable List skuDetailsList) {
+ if (skuDetailsList == null) {
+ return;
+ }
+
+ for (SkuDetails skuDetails : skuDetailsList) {
+ cachedSkus.put(skuDetails.getSku(), skuDetails);
+ }
+ }
+
+ private boolean billingClientError(MethodChannel.Result result) {
+ if (billingClient != null) {
+ return false;
+ }
+
+ result.error("UNAVAILABLE", "BillingClient is unset. Try reconnecting.", null);
+ return true;
+ }
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java
new file mode 100644
index 000000000000..54c775d0ad0f
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java
@@ -0,0 +1,34 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
+
+import androidx.annotation.Nullable;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.PurchasesUpdatedListener;
+import io.flutter.plugin.common.MethodChannel;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class PluginPurchaseListener implements PurchasesUpdatedListener {
+ private final MethodChannel channel;
+
+ PluginPurchaseListener(MethodChannel channel) {
+ this.channel = channel;
+ }
+
+ @Override
+ public void onPurchasesUpdated(BillingResult billingResult, @Nullable List purchases) {
+ final Map callbackArgs = new HashMap<>();
+ callbackArgs.put("billingResult", fromBillingResult(billingResult));
+ callbackArgs.put("responseCode", billingResult.getResponseCode());
+ callbackArgs.put("purchasesList", fromPurchasesList(purchases));
+ channel.invokeMethod(InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED, callbackArgs);
+ }
+}
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
new file mode 100644
index 000000000000..37e30cbfed06
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java
@@ -0,0 +1,120 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import androidx.annotation.Nullable;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.Purchase.PurchasesResult;
+import com.android.billingclient.api.PurchaseHistoryRecord;
+import com.android.billingclient.api.SkuDetails;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+/** Handles serialization of {@link com.android.billingclient.api.BillingClient} related objects. */
+/*package*/ class Translator {
+ static HashMap fromSkuDetail(SkuDetails detail) {
+ HashMap info = new HashMap<>();
+ info.put("title", detail.getTitle());
+ info.put("description", detail.getDescription());
+ info.put("freeTrialPeriod", detail.getFreeTrialPeriod());
+ info.put("introductoryPrice", detail.getIntroductoryPrice());
+ info.put("introductoryPriceAmountMicros", detail.getIntroductoryPriceAmountMicros());
+ info.put("introductoryPriceCycles", detail.getIntroductoryPriceCycles());
+ info.put("introductoryPricePeriod", detail.getIntroductoryPricePeriod());
+ info.put("price", detail.getPrice());
+ info.put("priceAmountMicros", detail.getPriceAmountMicros());
+ info.put("priceCurrencyCode", detail.getPriceCurrencyCode());
+ info.put("sku", detail.getSku());
+ info.put("type", detail.getType());
+ info.put("subscriptionPeriod", detail.getSubscriptionPeriod());
+ info.put("originalPrice", detail.getOriginalPrice());
+ info.put("originalPriceAmountMicros", detail.getOriginalPriceAmountMicros());
+ return info;
+ }
+
+ static List> fromSkuDetailsList(
+ @Nullable List skuDetailsList) {
+ if (skuDetailsList == null) {
+ return Collections.emptyList();
+ }
+
+ ArrayList> output = new ArrayList<>();
+ for (SkuDetails detail : skuDetailsList) {
+ output.add(fromSkuDetail(detail));
+ }
+ return output;
+ }
+
+ static HashMap fromPurchase(Purchase purchase) {
+ HashMap info = new HashMap<>();
+ info.put("orderId", purchase.getOrderId());
+ info.put("packageName", purchase.getPackageName());
+ info.put("purchaseTime", purchase.getPurchaseTime());
+ info.put("purchaseToken", purchase.getPurchaseToken());
+ info.put("signature", purchase.getSignature());
+ info.put("sku", purchase.getSku());
+ info.put("isAutoRenewing", purchase.isAutoRenewing());
+ info.put("originalJson", purchase.getOriginalJson());
+ info.put("developerPayload", purchase.getDeveloperPayload());
+ info.put("isAcknowledged", purchase.isAcknowledged());
+ info.put("purchaseState", purchase.getPurchaseState());
+ return info;
+ }
+
+ static HashMap fromPurchaseHistoryRecord(
+ PurchaseHistoryRecord purchaseHistoryRecord) {
+ HashMap info = new HashMap<>();
+ info.put("purchaseTime", purchaseHistoryRecord.getPurchaseTime());
+ info.put("purchaseToken", purchaseHistoryRecord.getPurchaseToken());
+ info.put("signature", purchaseHistoryRecord.getSignature());
+ info.put("sku", purchaseHistoryRecord.getSku());
+ info.put("developerPayload", purchaseHistoryRecord.getDeveloperPayload());
+ info.put("originalJson", purchaseHistoryRecord.getOriginalJson());
+ return info;
+ }
+
+ static List> fromPurchasesList(@Nullable List purchases) {
+ if (purchases == null) {
+ return Collections.emptyList();
+ }
+
+ List> serialized = new ArrayList<>();
+ for (Purchase purchase : purchases) {
+ serialized.add(fromPurchase(purchase));
+ }
+ return serialized;
+ }
+
+ static List> fromPurchaseHistoryRecordList(
+ @Nullable List purchaseHistoryRecords) {
+ if (purchaseHistoryRecords == null) {
+ return Collections.emptyList();
+ }
+
+ List> serialized = new ArrayList<>();
+ for (PurchaseHistoryRecord purchaseHistoryRecord : purchaseHistoryRecords) {
+ serialized.add(fromPurchaseHistoryRecord(purchaseHistoryRecord));
+ }
+ return serialized;
+ }
+
+ static HashMap fromPurchasesResult(PurchasesResult purchasesResult) {
+ HashMap info = new HashMap<>();
+ info.put("responseCode", purchasesResult.getResponseCode());
+ info.put("billingResult", fromBillingResult(purchasesResult.getBillingResult()));
+ info.put("purchasesList", fromPurchasesList(purchasesResult.getPurchasesList()));
+ return info;
+ }
+
+ static HashMap fromBillingResult(BillingResult billingResult) {
+ HashMap info = new HashMap<>();
+ info.put("responseCode", billingResult.getResponseCode());
+ info.put("debugMessage", billingResult.getDebugMessage());
+ return info;
+ }
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java
new file mode 100644
index 000000000000..d997ae1dcaa0
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java
@@ -0,0 +1,11 @@
+// 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.
+
+package android.text;
+
+public class TextUtils {
+ public static boolean isEmpty(CharSequence str) {
+ return str == null || str.length() == 0;
+ }
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java
new file mode 100644
index 000000000000..310b9ad89cdf
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java
@@ -0,0 +1,27 @@
+// 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.
+
+package android.util;
+
+public class Log {
+ public static int d(String tag, String msg) {
+ System.out.println("DEBUG: " + tag + ": " + msg);
+ return 0;
+ }
+
+ public static int i(String tag, String msg) {
+ System.out.println("INFO: " + tag + ": " + msg);
+ return 0;
+ }
+
+ public static int w(String tag, String msg) {
+ System.out.println("WARN: " + tag + ": " + msg);
+ return 0;
+ }
+
+ public static int e(String tag, String msg) {
+ System.out.println("ERROR: " + tag + ": " + msg);
+ return 0;
+ }
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java
new file mode 100644
index 000000000000..bcee5428eac9
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java
@@ -0,0 +1,40 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import io.flutter.plugin.common.BinaryMessenger;
+import io.flutter.plugin.common.PluginRegistry;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class InAppPurchasePluginTest {
+ @Mock Activity activity;
+ @Mock Context context;
+ @Mock PluginRegistry.Registrar mockRegistrar; // For v1 embedding
+ @Mock BinaryMessenger mockMessenger;
+ @Mock Application mockApplication;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ when(mockRegistrar.activity()).thenReturn(activity);
+ when(mockRegistrar.messenger()).thenReturn(mockMessenger);
+ when(mockRegistrar.context()).thenReturn(context);
+ }
+
+ @Test
+ public void registerWith_doNotCrashWhenRegisterContextIsActivity_V1Embedding() {
+ when(mockRegistrar.context()).thenReturn(activity);
+ when(activity.getApplicationContext()).thenReturn(mockApplication);
+ InAppPurchasePlugin.registerWith(mockRegistrar);
+ }
+}
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
new file mode 100644
index 000000000000..4d7a02220cf5
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java
@@ -0,0 +1,812 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS;
+import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.START_CONNECTION;
+import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult;
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList;
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList;
+import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult;
+import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.unmodifiableList;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.refEq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+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.BillingClient;
+import com.android.billingclient.api.BillingClient.SkuType;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.ConsumeParams;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.Purchase.PurchasesResult;
+import com.android.billingclient.api.PurchaseHistoryRecord;
+import com.android.billingclient.api.PurchaseHistoryResponseListener;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
+import io.flutter.plugin.common.MethodCall;
+import io.flutter.plugin.common.MethodChannel;
+import io.flutter.plugin.common.MethodChannel.Result;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.json.JSONException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+
+public class MethodCallHandlerTest {
+ private MethodCallHandlerImpl methodChannelHandler;
+ private BillingClientFactory factory;
+ @Mock BillingClient mockBillingClient;
+ @Mock MethodChannel mockMethodChannel;
+ @Spy Result result;
+ @Mock Activity activity;
+ @Mock Context context;
+ @Mock ActivityPluginBinding mockActivityPluginBinding;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ factory =
+ (@NonNull Context context,
+ @NonNull MethodChannel channel,
+ boolean enablePendingPurchases) -> mockBillingClient;
+ methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory);
+ when(mockActivityPluginBinding.getActivity()).thenReturn(activity);
+ }
+
+ @Test
+ public void invalidMethod() {
+ MethodCall call = new MethodCall("invalid", null);
+ methodChannelHandler.onMethodCall(call, result);
+ verify(result, times(1)).notImplemented();
+ }
+
+ @Test
+ public void isReady_true() {
+ mockStartConnection();
+ MethodCall call = new MethodCall(IS_READY, null);
+ when(mockBillingClient.isReady()).thenReturn(true);
+ methodChannelHandler.onMethodCall(call, result);
+ verify(result).success(true);
+ }
+
+ @Test
+ public void isReady_false() {
+ mockStartConnection();
+ MethodCall call = new MethodCall(IS_READY, null);
+ when(mockBillingClient.isReady()).thenReturn(false);
+ methodChannelHandler.onMethodCall(call, result);
+ verify(result).success(false);
+ }
+
+ @Test
+ public void isReady_clientDisconnected() {
+ MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
+ methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class));
+ MethodCall isReadyCall = new MethodCall(IS_READY, null);
+
+ methodChannelHandler.onMethodCall(isReadyCall, result);
+
+ verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void startConnection() {
+ ArgumentCaptor captor = mockStartConnection();
+ verify(result, never()).success(any());
+ 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 startConnection_multipleCalls() {
+ Map arguments = new HashMap<>();
+ arguments.put("handle", 1);
+ arguments.put("enablePendingPurchases", true);
+ 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());
+ BillingResult billingResult1 =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ BillingResult billingResult2 =
+ BillingResult.newBuilder()
+ .setResponseCode(200)
+ .setDebugMessage("dummy debug message")
+ .build();
+ BillingResult billingResult3 =
+ BillingResult.newBuilder()
+ .setResponseCode(300)
+ .setDebugMessage("dummy debug message")
+ .build();
+
+ captor.getValue().onBillingSetupFinished(billingResult1);
+ captor.getValue().onBillingSetupFinished(billingResult2);
+ captor.getValue().onBillingSetupFinished(billingResult3);
+
+ verify(result, times(1)).success(fromBillingResult(billingResult1));
+ verify(result, times(1)).success(any());
+ }
+
+ @Test
+ public void endConnection() {
+ // Set up a connected BillingClient instance
+ final int disconnectCallbackHandle = 22;
+ Map arguments = new HashMap<>();
+ arguments.put("handle", disconnectCallbackHandle);
+ arguments.put("enablePendingPurchases", true);
+ MethodCall connectCall = new MethodCall(START_CONNECTION, arguments);
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BillingClientStateListener.class);
+ doNothing().when(mockBillingClient).startConnection(captor.capture());
+ methodChannelHandler.onMethodCall(connectCall, mock(Result.class));
+ final BillingClientStateListener stateListener = captor.getValue();
+
+ // Disconnect the connected client
+ 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 expectedInvocation = new HashMap<>();
+ expectedInvocation.put("handle", disconnectCallbackHandle);
+ verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation);
+ }
+
+ @Test
+ public void querySkuDetailsAsync() {
+ // Connect a billing client and set up the SKU query listeners
+ establishConnectedBillingClient(/* arguments= */ null, /* result= */ null);
+ String skuType = BillingClient.SkuType.INAPP;
+ List skusList = asList("id1", "id2");
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", skuType);
+ arguments.put("skusList", skusList);
+ MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments);
+
+ // Query for SKU details
+ methodChannelHandler.onMethodCall(queryCall, result);
+
+ // Assert the arguments were forwarded correctly to BillingClient
+ ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(SkuDetailsParams.class);
+ ArgumentCaptor listenerCaptor =
+ ArgumentCaptor.forClass(SkuDetailsResponseListener.class);
+ verify(mockBillingClient).querySkuDetailsAsync(paramCaptor.capture(), listenerCaptor.capture());
+ assertEquals(paramCaptor.getValue().getSkuType(), skuType);
+ assertEquals(paramCaptor.getValue().getSkusList(), skusList);
+
+ // Assert that we handed result BillingClient's response
+ int responseCode = 200;
+ List skuDetailsResponse = asList(buildSkuDetails("foo"));
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse);
+ ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class);
+ verify(result).success(resultCaptor.capture());
+ HashMap resultData = resultCaptor.getValue();
+ assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult));
+ assertEquals(resultData.get("skuDetailsList"), fromSkuDetailsList(skuDetailsResponse));
+ }
+
+ @Test
+ public void querySkuDetailsAsync_clientDisconnected() {
+ // Disconnect the Billing client and prepare a querySkuDetails call
+ MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
+ methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class));
+ String skuType = BillingClient.SkuType.INAPP;
+ List skusList = asList("id1", "id2");
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", skuType);
+ arguments.put("skusList", skusList);
+ MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments);
+
+ // Query for SKU details
+ methodChannelHandler.onMethodCall(queryCall, result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any());
+ verify(result, never()).success(any());
+ }
+
+ // Test launchBillingFlow not crash if `accountId` is `null`
+ // Ideally, we should check if the `accountId` is null in the parameter; however,
+ // since PBL 3.0, the `accountId` variable is not public.
+ @Test
+ public void launchBillingFlow_null_AccountId_do_not_crash() {
+ // Fetch the sku details first and then prepare the launch billing flow call
+ String skuId = "foo";
+ queryForSkus(singletonList(skuId));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", null);
+ arguments.put("obfuscatedProfileId", null);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the arguments to the billing flow
+ ArgumentCaptor billingFlowParamsCaptor =
+ ArgumentCaptor.forClass(BillingFlowParams.class);
+ verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
+ BillingFlowParams params = billingFlowParamsCaptor.getValue();
+ assertEquals(params.getSku(), skuId);
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void launchBillingFlow_ok_null_OldSku() {
+ // Fetch the sku details first and then prepare the launch billing flow call
+ String skuId = "foo";
+ String accountId = "account";
+ queryForSkus(singletonList(skuId));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ arguments.put("oldSku", null);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the arguments to the billing flow
+ ArgumentCaptor billingFlowParamsCaptor =
+ ArgumentCaptor.forClass(BillingFlowParams.class);
+ verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
+ BillingFlowParams params = billingFlowParamsCaptor.getValue();
+ assertEquals(params.getSku(), skuId);
+ assertNull(params.getOldSku());
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void launchBillingFlow_ok_null_Activity() {
+ methodChannelHandler.setActivity(null);
+
+ // Fetch the sku details first and then prepare the launch billing flow call
+ String skuId = "foo";
+ String accountId = "account";
+ queryForSkus(singletonList(skuId));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the response code to result
+ verify(result).error(contains("ACTIVITY_UNAVAILABLE"), contains("foreground"), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void launchBillingFlow_ok_oldSku() {
+ // Fetch the sku details first and query the method call
+ String skuId = "foo";
+ String accountId = "account";
+ String oldSkuId = "oldFoo";
+ queryForSkus(unmodifiableList(asList(skuId, oldSkuId)));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ arguments.put("oldSku", oldSkuId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the arguments to the billing flow
+ ArgumentCaptor billingFlowParamsCaptor =
+ ArgumentCaptor.forClass(BillingFlowParams.class);
+ verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
+ BillingFlowParams params = billingFlowParamsCaptor.getValue();
+ assertEquals(params.getSku(), skuId);
+ assertEquals(params.getOldSku(), oldSkuId);
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void launchBillingFlow_ok_AccountId() {
+ // Fetch the sku details first and query the method call
+ String skuId = "foo";
+ String accountId = "account";
+ queryForSkus(singletonList(skuId));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the arguments to the billing flow
+ ArgumentCaptor billingFlowParamsCaptor =
+ ArgumentCaptor.forClass(BillingFlowParams.class);
+ verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
+ BillingFlowParams params = billingFlowParamsCaptor.getValue();
+ assertEquals(params.getSku(), skuId);
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void launchBillingFlow_ok_Proration() {
+ // Fetch the sku details first and query the method call
+ String skuId = "foo";
+ String oldSkuId = "oldFoo";
+ String purchaseToken = "purchaseTokenFoo";
+ String accountId = "account";
+ int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE;
+ queryForSkus(unmodifiableList(asList(skuId, oldSkuId)));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ arguments.put("oldSku", oldSkuId);
+ arguments.put("purchaseToken", purchaseToken);
+ arguments.put("prorationMode", prorationMode);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Verify we pass the arguments to the billing flow
+ ArgumentCaptor billingFlowParamsCaptor =
+ ArgumentCaptor.forClass(BillingFlowParams.class);
+ verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
+ BillingFlowParams params = billingFlowParamsCaptor.getValue();
+ assertEquals(params.getSku(), skuId);
+ assertEquals(params.getOldSku(), oldSkuId);
+ assertEquals(params.getOldSkuPurchaseToken(), purchaseToken);
+ assertEquals(params.getReplaceSkusProrationMode(), prorationMode);
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void launchBillingFlow_ok_Proration_with_null_OldSku() {
+ // Fetch the sku details first and query the method call
+ String skuId = "foo";
+ String accountId = "account";
+ String queryOldSkuId = "oldFoo";
+ String oldSkuId = null;
+ int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE;
+ queryForSkus(unmodifiableList(asList(skuId, queryOldSkuId)));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ arguments.put("oldSku", oldSkuId);
+ arguments.put("prorationMode", prorationMode);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ // Launch the billing flow
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Assert that we sent an error back.
+ verify(result)
+ .error(
+ contains("IN_APP_PURCHASE_REQUIRE_OLD_SKU"),
+ contains("launchBillingFlow failed because oldSku is null"),
+ any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void launchBillingFlow_clientDisconnected() {
+ // Prepare the launch call after disconnecting the client
+ MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
+ methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class));
+ String skuId = "foo";
+ String accountId = "account";
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void launchBillingFlow_skuNotFound() {
+ // Try to launch the billing flow for a random sku ID
+ establishConnectedBillingClient(null, null);
+ String skuId = "foo";
+ String accountId = "account";
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("NOT_FOUND"), contains(skuId), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void launchBillingFlow_oldSkuNotFound() {
+ // Try to launch the billing flow for a random sku ID
+ establishConnectedBillingClient(null, null);
+ String skuId = "foo";
+ String accountId = "account";
+ String oldSkuId = "oldSku";
+ queryForSkus(singletonList(skuId));
+ HashMap arguments = new HashMap<>();
+ arguments.put("sku", skuId);
+ arguments.put("accountId", accountId);
+ arguments.put("oldSku", oldSkuId);
+ MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
+
+ methodChannelHandler.onMethodCall(launchCall, result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_SKU"), contains(oldSkuId), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void queryPurchases() {
+ establishConnectedBillingClient(null, null);
+ PurchasesResult purchasesResult = mock(PurchasesResult.class);
+ Purchase purchase = buildPurchase("foo");
+ when(purchasesResult.getPurchasesList()).thenReturn(asList(purchase));
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ when(purchasesResult.getBillingResult()).thenReturn(billingResult);
+ when(mockBillingClient.queryPurchases(SkuType.INAPP)).thenReturn(purchasesResult);
+
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", SkuType.INAPP);
+ methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES, arguments), result);
+
+ // Verify we pass the response to result
+ ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class);
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(resultCaptor.capture());
+ assertEquals(fromPurchasesResult(purchasesResult), resultCaptor.getValue());
+ }
+
+ @Test
+ public void queryPurchases_clientDisconnected() {
+ // Prepare the launch call after disconnecting the client
+ methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class));
+
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", SkuType.INAPP);
+ methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES, arguments), result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void queryPurchaseHistoryAsync() {
+ // Set up an established billing client and all our mocked responses
+ establishConnectedBillingClient(null, null);
+ ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class);
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ List purchasesList = asList(buildPurchaseHistoryRecord("foo"));
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", SkuType.INAPP);
+ ArgumentCaptor listenerCaptor =
+ ArgumentCaptor.forClass(PurchaseHistoryResponseListener.class);
+
+ methodChannelHandler.onMethodCall(
+ new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result);
+
+ // Verify we pass the data to result
+ verify(mockBillingClient)
+ .queryPurchaseHistoryAsync(eq(SkuType.INAPP), listenerCaptor.capture());
+ listenerCaptor.getValue().onPurchaseHistoryResponse(billingResult, purchasesList);
+ verify(result).success(resultCaptor.capture());
+ HashMap resultData = resultCaptor.getValue();
+ assertEquals(fromBillingResult(billingResult), resultData.get("billingResult"));
+ assertEquals(
+ fromPurchaseHistoryRecordList(purchasesList), resultData.get("purchaseHistoryRecordList"));
+ }
+
+ @Test
+ public void queryPurchaseHistoryAsync_clientDisconnected() {
+ // Prepare the launch call after disconnecting the client
+ methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class));
+
+ HashMap arguments = new HashMap<>();
+ arguments.put("skuType", SkuType.INAPP);
+ methodChannelHandler.onMethodCall(
+ new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result);
+
+ // Assert that we sent an error back.
+ verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any());
+ verify(result, never()).success(any());
+ }
+
+ @Test
+ public void onPurchasesUpdatedListener() {
+ PluginPurchaseListener listener = new PluginPurchaseListener(mockMethodChannel);
+
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ List purchasesList = asList(buildPurchase("foo"));
+ ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class);
+ doNothing()
+ .when(mockMethodChannel)
+ .invokeMethod(eq(ON_PURCHASES_UPDATED), resultCaptor.capture());
+ listener.onPurchasesUpdated(billingResult, purchasesList);
+
+ HashMap resultData = resultCaptor.getValue();
+ assertEquals(fromBillingResult(billingResult), resultData.get("billingResult"));
+ assertEquals(fromPurchasesList(purchasesList), resultData.get("purchasesList"));
+ }
+
+ @Test
+ public void consumeAsync() {
+ establishConnectedBillingClient(null, null);
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class);
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ HashMap arguments = new HashMap<>();
+ arguments.put("purchaseToken", "mockToken");
+ arguments.put("developerPayload", "mockPayload");
+ ArgumentCaptor listenerCaptor =
+ ArgumentCaptor.forClass(ConsumeResponseListener.class);
+
+ methodChannelHandler.onMethodCall(new MethodCall(CONSUME_PURCHASE_ASYNC, arguments), result);
+
+ ConsumeParams params = ConsumeParams.newBuilder().setPurchaseToken("mockToken").build();
+
+ // Verify we pass the data to result
+ verify(mockBillingClient).consumeAsync(refEq(params), listenerCaptor.capture());
+
+ listenerCaptor.getValue().onConsumeResponse(billingResult, "mockToken");
+ verify(result).success(resultCaptor.capture());
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void acknowledgePurchase() {
+ establishConnectedBillingClient(null, null);
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class);
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ HashMap arguments = new HashMap<>();
+ arguments.put("purchaseToken", "mockToken");
+ arguments.put("developerPayload", "mockPayload");
+ ArgumentCaptor listenerCaptor =
+ ArgumentCaptor.forClass(AcknowledgePurchaseResponseListener.class);
+
+ methodChannelHandler.onMethodCall(new MethodCall(ACKNOWLEDGE_PURCHASE, arguments), result);
+
+ AcknowledgePurchaseParams params =
+ AcknowledgePurchaseParams.newBuilder().setPurchaseToken("mockToken").build();
+
+ // Verify we pass the data to result
+ verify(mockBillingClient).acknowledgePurchase(refEq(params), listenerCaptor.capture());
+
+ listenerCaptor.getValue().onAcknowledgePurchaseResponse(billingResult);
+ verify(result).success(resultCaptor.capture());
+
+ // Verify we pass the response code to result
+ verify(result, never()).error(any(), any(), any());
+ verify(result, times(1)).success(fromBillingResult(billingResult));
+ }
+
+ @Test
+ public void endConnection_if_activity_dettached() {
+ InAppPurchasePlugin plugin = new InAppPurchasePlugin();
+ plugin.setMethodCallHandler(methodChannelHandler);
+ mockStartConnection();
+ plugin.onDetachedFromActivity();
+ verify(mockBillingClient).endConnection();
+ }
+
+ private ArgumentCaptor mockStartConnection() {
+ Map arguments = new HashMap<>();
+ arguments.put("handle", 1);
+ arguments.put("enablePendingPurchases", true);
+ MethodCall call = new MethodCall(START_CONNECTION, arguments);
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(BillingClientStateListener.class);
+ doNothing().when(mockBillingClient).startConnection(captor.capture());
+
+ methodChannelHandler.onMethodCall(call, result);
+ return captor;
+ }
+
+ private void establishConnectedBillingClient(
+ @Nullable Map arguments, @Nullable Result result) {
+ if (arguments == null) {
+ arguments = new HashMap<>();
+ arguments.put("handle", 1);
+ arguments.put("enablePendingPurchases", true);
+ }
+ if (result == null) {
+ result = mock(Result.class);
+ }
+
+ MethodCall connectCall = new MethodCall(START_CONNECTION, arguments);
+ methodChannelHandler.onMethodCall(connectCall, result);
+ }
+
+ private void queryForSkus(List skusList) {
+ // Set up the query method call
+ establishConnectedBillingClient(/* arguments= */ null, /* result= */ null);
+ HashMap arguments = new HashMap<>();
+ String skuType = SkuType.INAPP;
+ arguments.put("skuType", skuType);
+ arguments.put("skusList", skusList);
+ MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments);
+
+ // Call the method.
+ methodChannelHandler.onMethodCall(queryCall, mock(Result.class));
+
+ // Respond to the call with a matching set of Sku details.
+ ArgumentCaptor listenerCaptor =
+ ArgumentCaptor.forClass(SkuDetailsResponseListener.class);
+ verify(mockBillingClient).querySkuDetailsAsync(any(), listenerCaptor.capture());
+ List skuDetailsResponse =
+ skusList.stream().map(this::buildSkuDetails).collect(toList());
+
+ BillingResult billingResult =
+ BillingResult.newBuilder()
+ .setResponseCode(100)
+ .setDebugMessage("dummy debug message")
+ .build();
+ listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse);
+ }
+
+ private SkuDetails buildSkuDetails(String id) {
+ String json =
+ String.format(
+ "{\"packageName\": \"dummyPackageName\",\"productId\":\"%s\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}",
+ id);
+ SkuDetails details = null;
+ try {
+ details = new SkuDetails(json);
+ } catch (JSONException e) {
+ fail("buildSkuDetails failed with JSONException " + e.toString());
+ }
+ return details;
+ }
+
+ private Purchase buildPurchase(String orderId) {
+ Purchase purchase = mock(Purchase.class);
+ when(purchase.getOrderId()).thenReturn(orderId);
+ return purchase;
+ }
+
+ private PurchaseHistoryRecord buildPurchaseHistoryRecord(String purchaseToken) {
+ PurchaseHistoryRecord purchase = mock(PurchaseHistoryRecord.class);
+ when(purchase.getPurchaseToken()).thenReturn(purchaseToken);
+ return purchase;
+ }
+}
diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java
new file mode 100644
index 000000000000..47147e772bce
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java
@@ -0,0 +1,213 @@
+// 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.
+
+package io.flutter.plugins.inapppurchase;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.Purchase.PurchasesResult;
+import com.android.billingclient.api.PurchaseHistoryRecord;
+import com.android.billingclient.api.SkuDetails;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.json.JSONException;
+import org.junit.Test;
+
+public class TranslatorTest {
+ private static final String SKU_DETAIL_EXAMPLE_JSON =
+ "{\"productId\":\"example\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}";
+ private static final String PURCHASE_EXAMPLE_JSON =
+ "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}";
+
+ @Test
+ public void fromSkuDetail() throws JSONException {
+ final SkuDetails expected = new SkuDetails(SKU_DETAIL_EXAMPLE_JSON);
+
+ Map serialized = Translator.fromSkuDetail(expected);
+
+ assertSerialized(expected, serialized);
+ }
+
+ @Test
+ public void fromSkuDetailsList() throws JSONException {
+ final String SKU_DETAIL_EXAMPLE_2_JSON =
+ "{\"productId\":\"example2\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}";
+ final List expected =
+ Arrays.asList(
+ new SkuDetails(SKU_DETAIL_EXAMPLE_JSON), new SkuDetails(SKU_DETAIL_EXAMPLE_2_JSON));
+
+ final List> serialized = Translator.fromSkuDetailsList(expected);
+
+ assertEquals(expected.size(), serialized.size());
+ assertSerialized(expected.get(0), serialized.get(0));
+ assertSerialized(expected.get(1), serialized.get(1));
+ }
+
+ @Test
+ public void fromSkuDetailsList_null() {
+ assertEquals(Collections.emptyList(), Translator.fromSkuDetailsList(null));
+ }
+
+ @Test
+ public void fromPurchase() throws JSONException {
+ final Purchase expected = new Purchase(PURCHASE_EXAMPLE_JSON, "signature");
+ assertSerialized(expected, Translator.fromPurchase(expected));
+ }
+
+ @Test
+ public void fromPurchaseHistoryRecord() throws JSONException {
+ final PurchaseHistoryRecord expected =
+ new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, "signature");
+ assertSerialized(expected, Translator.fromPurchaseHistoryRecord(expected));
+ }
+
+ @Test
+ public void fromPurchasesHistoryRecordList() throws JSONException {
+ final String purchase2Json =
+ "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}";
+ final String signature = "signature";
+ final List expected =
+ Arrays.asList(
+ new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, signature),
+ new PurchaseHistoryRecord(purchase2Json, signature));
+
+ final List> serialized =
+ Translator.fromPurchaseHistoryRecordList(expected);
+
+ assertEquals(expected.size(), serialized.size());
+ assertSerialized(expected.get(0), serialized.get(0));
+ assertSerialized(expected.get(1), serialized.get(1));
+ }
+
+ @Test
+ public void fromPurchasesHistoryRecordList_null() {
+ assertEquals(Collections.emptyList(), Translator.fromPurchaseHistoryRecordList(null));
+ }
+
+ @Test
+ public void fromPurchasesList() throws JSONException {
+ final String purchase2Json =
+ "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}";
+ final String signature = "signature";
+ final List expected =
+ Arrays.asList(
+ new Purchase(PURCHASE_EXAMPLE_JSON, signature), new Purchase(purchase2Json, signature));
+
+ final List> serialized = Translator.fromPurchasesList(expected);
+
+ assertEquals(expected.size(), serialized.size());
+ assertSerialized(expected.get(0), serialized.get(0));
+ assertSerialized(expected.get(1), serialized.get(1));
+ }
+
+ @Test
+ public void fromPurchasesList_null() {
+ assertEquals(Collections.emptyList(), Translator.fromPurchasesList(null));
+ }
+
+ @Test
+ public void fromPurchasesResult() throws JSONException {
+ PurchasesResult result = mock(PurchasesResult.class);
+ final String purchase2Json =
+ "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}";
+ final String signature = "signature";
+ final List expectedPurchases =
+ Arrays.asList(
+ new Purchase(PURCHASE_EXAMPLE_JSON, signature), new Purchase(purchase2Json, signature));
+ when(result.getPurchasesList()).thenReturn(expectedPurchases);
+ when(result.getResponseCode()).thenReturn(BillingClient.BillingResponseCode.OK);
+ BillingResult newBillingResult =
+ BillingResult.newBuilder()
+ .setDebugMessage("dummy debug message")
+ .setResponseCode(BillingClient.BillingResponseCode.OK)
+ .build();
+ when(result.getBillingResult()).thenReturn(newBillingResult);
+ final HashMap serialized = Translator.fromPurchasesResult(result);
+
+ assertEquals(BillingClient.BillingResponseCode.OK, serialized.get("responseCode"));
+ List