From 4f16c511967894ec8eb31056843b0ff8c9a32a3c Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 29 Apr 2021 13:20:10 +0200 Subject: [PATCH 01/14] Implement BillingClientWrapper and unit-tests --- .../in_app_purchase_android/CHANGELOG.md | 3 + .../in_app_purchase_android/LICENSE | 25 + .../in_app_purchase_android/README.md | 34 + .../analysis_options.yaml | 1 + .../android/build.gradle | 48 ++ .../android/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.properties | 6 + .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../inapppurchase/BillingClientFactory.java | 26 + .../BillingClientFactoryImpl.java | 23 + .../inapppurchase/InAppPurchasePlugin.java | 106 +++ .../inapppurchase/MethodCallHandlerImpl.java | 382 ++++++++ .../inapppurchase/PluginPurchaseListener.java | 34 + .../plugins/inapppurchase/Translator.java | 120 +++ .../src/test/java/android/text/TextUtils.java | 11 + .../src/test/java/android/util/Log.java | 27 + .../InAppPurchasePluginTest.java | 40 + .../inapppurchase/MethodCallHandlerTest.java | 812 ++++++++++++++++++ .../plugins/inapppurchase/TranslatorTest.java | 213 +++++ .../in_app_purchase_android/build.yaml | 7 + .../lib/billing_client_wrappers.dart | 7 + .../lib/in_app_purchase_android.dart | 6 + .../lib/src/billing_client_wrappers/README.md | 6 + .../billing_client_wrapper.dart | 448 ++++++++++ .../enum_converters.dart | 120 +++ .../enum_converters.g.dart | 85 ++ .../purchase_wrapper.dart | 332 +++++++ .../purchase_wrapper.g.dart | 109 +++ .../sku_details_wrapper.dart | 244 ++++++ .../sku_details_wrapper.g.dart | 83 ++ .../lib/src/channel.dart | 9 + .../src/in_app_purchase_android_platform.dart | 9 + .../types/google_play_product_details.dart | 49 ++ .../types/google_play_purchase_details.dart | 71 ++ .../lib/src/types/types.dart | 5 + .../in_app_purchase_android/pubspec.yaml | 35 + .../billing_client_wrapper_test.dart | 547 ++++++++++++ .../purchase_wrapper_test.dart | 214 +++++ .../sku_details_wrapper_test.dart | 149 ++++ .../test/stub_in_app_purchase_platform.dart | 45 + 41 files changed, 4496 insertions(+) create mode 100644 packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md create mode 100644 packages/in_app_purchase/in_app_purchase_android/LICENSE create mode 100644 packages/in_app_purchase/in_app_purchase_android/README.md create mode 100644 packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/build.gradle create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/gradle.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/settings.gradle create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/build.yaml create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/pubspec.yaml create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart 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..e6b089e6a243 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -0,0 +1,34 @@ +# 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 + ... +``` + +[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..96ec04bdbae9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../analysis_options_legacy.yaml \ No newline at end of file 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> serializedPurchases = + (List>) serialized.get("purchasesList"); + assertEquals(expectedPurchases.size(), serializedPurchases.size()); + assertSerialized(expectedPurchases.get(0), serializedPurchases.get(0)); + assertSerialized(expectedPurchases.get(1), serializedPurchases.get(1)); + + Map billingResultMap = (Map) serialized.get("billingResult"); + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + @Test + public void fromBillingResult() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder() + .setDebugMessage("dummy debug message") + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); + + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + @Test + public void fromBillingResult_debugMessageNull() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); + + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + private void assertSerialized(SkuDetails expected, Map serialized) { + assertEquals(expected.getDescription(), serialized.get("description")); + assertEquals(expected.getFreeTrialPeriod(), serialized.get("freeTrialPeriod")); + assertEquals(expected.getIntroductoryPrice(), serialized.get("introductoryPrice")); + assertEquals( + expected.getIntroductoryPriceAmountMicros(), + serialized.get("introductoryPriceAmountMicros")); + assertEquals(expected.getIntroductoryPriceCycles(), serialized.get("introductoryPriceCycles")); + assertEquals(expected.getIntroductoryPricePeriod(), serialized.get("introductoryPricePeriod")); + assertEquals(expected.getPrice(), serialized.get("price")); + assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); + assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getSubscriptionPeriod(), serialized.get("subscriptionPeriod")); + assertEquals(expected.getTitle(), serialized.get("title")); + assertEquals(expected.getType(), serialized.get("type")); + assertEquals(expected.getOriginalPrice(), serialized.get("originalPrice")); + assertEquals( + expected.getOriginalPriceAmountMicros(), serialized.get("originalPriceAmountMicros")); + } + + private void assertSerialized(Purchase expected, Map serialized) { + assertEquals(expected.getOrderId(), serialized.get("orderId")); + assertEquals(expected.getPackageName(), serialized.get("packageName")); + assertEquals(expected.getPurchaseTime(), serialized.get("purchaseTime")); + assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); + assertEquals(expected.getSignature(), serialized.get("signature")); + assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); + assertEquals(expected.isAcknowledged(), serialized.get("isAcknowledged")); + assertEquals(expected.getPurchaseState(), serialized.get("purchaseState")); + } + + private void assertSerialized(PurchaseHistoryRecord expected, Map serialized) { + assertEquals(expected.getPurchaseTime(), serialized.get("purchaseTime")); + assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); + assertEquals(expected.getSignature(), serialized.get("signature")); + assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/build.yaml b/packages/in_app_purchase/in_app_purchase_android/build.yaml new file mode 100644 index 000000000000..e15cf14b85fd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + json_serializable: + options: + any_map: true + create_to_json: true 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 new file mode 100644 index 000000000000..1dac19f825b8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart @@ -0,0 +1,7 @@ +// 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. + +export 'src/billing_client_wrappers/billing_client_wrapper.dart'; +export 'src/billing_client_wrappers/purchase_wrapper.dart'; +export 'src/billing_client_wrappers/sku_details_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart new file mode 100644 index 000000000000..9d74a562b272 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart @@ -0,0 +1,6 @@ +// 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. + +export 'src/in_app_purchase_android_platform.dart'; +export 'src/types/types.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md new file mode 100644 index 000000000000..54e76b528b48 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md @@ -0,0 +1,6 @@ +# billing_client_wrappers + +This exposes a way Dart endpoints through to [Google Play Billing +Library](https://developer.android.com/google/play/billing/billing_library_overview). +Can be used as an alternative to +[in_app_purchase](../in_app_purchase/README.md). \ No newline at end of file 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 new file mode 100644 index 000000000000..1f43b3a8fbdd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -0,0 +1,448 @@ +// 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 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import '../../billing_client_wrappers.dart'; +import '../channel.dart'; +import 'purchase_wrapper.dart'; +import 'sku_details_wrapper.dart'; +import 'enum_converters.dart'; + +/// Method identifier for the OnPurchaseUpdated method channel method. +@visibleForTesting +const String kOnPurchasesUpdated = + 'PurchasesUpdatedListener#onPurchasesUpdated(int, List)'; +const String _kOnBillingServiceDisconnected = + 'BillingClientStateListener#onBillingServiceDisconnected()'; + +/// Callback triggered by Play in response to purchase activity. +/// +/// This callback is triggered in response to all purchase activity while an +/// instance of `BillingClient` is active. This includes purchases initiated by +/// the app ([BillingClient.launchBillingFlow]) as well as purchases made in +/// Play itself while this app is open. +/// +/// This does not provide any hooks for purchases made in the past. See +/// [BillingClient.queryPurchases] and [BillingClient.queryPurchaseHistory]. +/// +/// All purchase information should also be verified manually, with your server +/// if at all possible. See ["Verify a +/// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). +/// +/// Wraps a +/// [`PurchasesUpdatedListener`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener.html). +typedef void PurchasesUpdatedListener(PurchasesResultWrapper purchasesResult); + +/// This class can be used directly instead of [InAppPurchaseConnection] to call +/// Play-specific billing APIs. +/// +/// Wraps a +/// [`com.android.billingclient.api.BillingClient`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient) +/// instance. +/// +/// +/// In general this API conforms to the Java +/// `com.android.billingclient.api.BillingClient` API as much as possible, with +/// some minor changes to account for language differences. Callbacks have been +/// converted to futures where appropriate. +class BillingClient { + bool _enablePendingPurchases = false; + + /// Creates a billing client. + BillingClient(PurchasesUpdatedListener onPurchasesUpdated) { + channel.setMethodCallHandler(callHandler); + _callbacks[kOnPurchasesUpdated] = [onPurchasesUpdated]; + } + + // Occasionally methods in the native layer require a Dart callback to be + // triggered in response to a Java callback. For example, + // [startConnection] registers an [OnBillingServiceDisconnected] callback. + // This list of names to callbacks is used to trigger Dart callbacks in + // response to those Java callbacks. Dart sends the Java layer a handle to the + // matching callback here to remember, and then once its twin is triggered it + // sends the handle back over the platform channel. We then access that handle + // in this array and call it in Dart code. See also [_callHandler]. + Map> _callbacks = >{}; + + /// Calls + /// [`BillingClient#isReady()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#isReady()) + /// to get the ready status of the BillingClient instance. + Future isReady() async { + final bool? ready = + await channel.invokeMethod('BillingClient#isReady()'); + return ready ?? false; + } + + /// Enable the [BillingClientWrapper] to handle pending purchases. + /// + /// Play requires that you call this method when initializing your application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// + /// Failure to call this method before any other method in the [startConnection] will throw an exception. + void enablePendingPurchases() { + _enablePendingPurchases = true; + } + + /// Calls + /// [`BillingClient#startConnection(BillingClientStateListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#startconnection) + /// to create and connect a `BillingClient` instance. + /// + /// [onBillingServiceConnected] has been converted from a callback parameter + /// to the Future result returned by this function. This returns the + /// `BillingClient.BillingResultWrapper` describing the connection result. + /// + /// This triggers the creation of a new `BillingClient` instance in Java if + /// one doesn't already exist. + Future startConnection( + {required OnBillingServiceDisconnected + onBillingServiceDisconnected}) async { + assert(_enablePendingPurchases, + 'enablePendingPurchases() must be called before calling startConnection'); + List disconnectCallbacks = + _callbacks[_kOnBillingServiceDisconnected] ??= []; + disconnectCallbacks.add(onBillingServiceDisconnected); + return BillingResultWrapper.fromJson((await channel + .invokeMapMethod( + "BillingClient#startConnection(BillingClientStateListener)", + { + 'handle': disconnectCallbacks.length - 1, + 'enablePendingPurchases': _enablePendingPurchases + })) ?? + {}); + } + + /// Calls + /// [`BillingClient#endConnection(BillingClientStateListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#endconnect + /// to disconnect a `BillingClient` instance. + /// + /// Will trigger the [OnBillingServiceDisconnected] callback passed to [startConnection]. + /// + /// This triggers the destruction of the `BillingClient` instance in Java. + Future endConnection() async { + return channel.invokeMethod("BillingClient#endConnection()", null); + } + + /// Returns a list of [SkuDetailsWrapper]s that have [SkuDetailsWrapper.sku] + /// in `skusList`, and [SkuDetailsWrapper.type] matching `skuType`. + /// + /// Calls through to [`BillingClient#querySkuDetailsAsync(SkuDetailsParams, + /// SkuDetailsResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querySkuDetailsAsync(com.android.billingclient.api.SkuDetailsParams,%20com.android.billingclient.api.SkuDetailsResponseListener)) + /// Instead of taking a callback parameter, it returns a Future + /// [SkuDetailsResponseWrapper]. It also takes the values of + /// `SkuDetailsParams` as direct arguments instead of requiring it constructed + /// and passed in as a class. + Future querySkuDetails( + {required SkuType skuType, required List skusList}) async { + final Map arguments = { + 'skuType': SkuTypeConverter().toJson(skuType), + 'skusList': skusList + }; + return SkuDetailsResponseWrapper.fromJson((await channel.invokeMapMethod< + String, dynamic>( + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)', + arguments)) ?? + {}); + } + + /// Attempt to launch the Play Billing Flow for a given [skuDetails]. + /// + /// The [skuDetails] needs to have already been fetched in a [querySkuDetails] + /// call. The [accountId] is an optional hashed string associated with the user + /// that's unique to your app. It's used by Google to detect unusual behavior. + /// Do not pass in a cleartext [accountId], and do not use this field to store any Personally Identifiable Information (PII) + /// such as emails in cleartext. Attempting to store PII in this field will result in purchases being blocked. + /// Google Play recommends that you use either encryption or a one-way hash to generate an obfuscated identifier to send to Google Play. + /// + /// Specifies an optional [obfuscatedProfileId] that is uniquely associated with the user's profile in your app. + /// Some applications allow users to have multiple profiles within a single account. Use this method to send the user's profile identifier to Google. + /// Setting this field requests the user's obfuscated account id. + /// + /// Calling this attemps to show the Google Play purchase UI. The user is free + /// to complete the transaction there. + /// + /// This method returns a [BillingResultWrapper] representing the initial attempt + /// to show the Google Play billing flow. Actual purchase updates are + /// delivered via the [PurchasesUpdatedListener]. + /// + /// This method calls through to + /// [`BillingClient#launchBillingFlow`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchbillingflow). + /// It constructs a + /// [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) + /// instance by [setting the given skuDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setskudetails), + /// [the given accountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)) + /// and the [obfuscatedProfileId] (https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid). + /// + /// When this method is called to purchase a subscription, an optional `oldSku` + /// can be passed in. This will tell Google Play that rather than purchasing a new subscription, + /// the user needs to upgrade/downgrade the existing subscription. + /// The [oldSku](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setoldsku) and [purchaseToken] are the SKU id and purchase token that the user is upgrading or downgrading from. + /// [purchaseToken] must not be `null` if [oldSku] is not `null`. + /// The [prorationMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setreplaceskusprorationmode) is the mode of proration during subscription upgrade/downgrade. + /// This value will only be effective if the `oldSku` is also set. + Future launchBillingFlow( + {required String sku, + String? accountId, + String? obfuscatedProfileId, + String? oldSku, + String? purchaseToken, + ProrationMode? prorationMode}) async { + assert(sku != null); + assert((oldSku == null) == (purchaseToken == null), + 'oldSku and purchaseToken must both be set, or both be null.'); + final Map arguments = { + 'sku': sku, + 'accountId': accountId, + 'obfuscatedProfileId': obfuscatedProfileId, + 'oldSku': oldSku, + 'purchaseToken': purchaseToken, + 'prorationMode': ProrationModeConverter().toJson(prorationMode ?? + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy) + }; + return BillingResultWrapper.fromJson( + (await channel.invokeMapMethod( + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)', + arguments)) ?? + {}); + } + + /// Fetches recent purchases for the given [SkuType]. + /// + /// Unlike [queryPurchaseHistory], This does not make a network request and + /// does not return items that are no longer owned. + /// + /// All purchase information should also be verified manually, with your + /// server if at all possible. See ["Verify a + /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). + /// + /// This wraps [`BillingClient#queryPurchases(String + /// skutype)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchases). + Future queryPurchases(SkuType skuType) async { + assert(skuType != null); + return PurchasesResultWrapper.fromJson((await channel + .invokeMapMethod( + 'BillingClient#queryPurchases(String)', { + 'skuType': SkuTypeConverter().toJson(skuType) + })) ?? + {}); + } + + /// Fetches purchase history for the given [SkuType]. + /// + /// Unlike [queryPurchases], this makes a network request via Play and returns + /// the most recent purchase for each [SkuDetailsWrapper] of the given + /// [SkuType] even if the item is no longer owned. + /// + /// All purchase information should also be verified manually, with your + /// server if at all possible. See ["Verify a + /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). + /// + /// This wraps [`BillingClient#queryPurchaseHistoryAsync(String skuType, + /// PurchaseHistoryResponseListener + /// listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchasehistoryasync). + Future queryPurchaseHistory(SkuType skuType) async { + assert(skuType != null); + return PurchasesHistoryResult.fromJson((await channel.invokeMapMethod< + String, dynamic>( + 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)', + { + 'skuType': SkuTypeConverter().toJson(skuType) + })) ?? + {}); + } + + /// Consumes a given in-app product. + /// + /// Consuming can only be done on an item that's owned, and as a result of consumption, the user will no longer own it. + /// Consumption is done asynchronously. The method returns a Future containing a [BillingResultWrapper]. + /// + /// This wraps [`BillingClient#consumeAsync(String, ConsumeResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#consumeAsync(java.lang.String,%20com.android.billingclient.api.ConsumeResponseListener)) + Future consumeAsync(String purchaseToken) async { + assert(purchaseToken != null); + return BillingResultWrapper.fromJson((await channel + .invokeMapMethod( + 'BillingClient#consumeAsync(String, ConsumeResponseListener)', + { + 'purchaseToken': purchaseToken, + })) ?? + {}); + } + + /// Acknowledge an in-app purchase. + /// + /// The developer must acknowledge all in-app purchases after they have been granted to the user. + /// If this doesn't happen within three days of the purchase, the purchase will be refunded. + /// + /// Consumables are already implicitly acknowledged by calls to [consumeAsync] and + /// do not need to be explicitly acknowledged by using this method. + /// However this method can be called for them in order to explicitly acknowledge them if desired. + /// + /// Be sure to only acknowledge a purchase after it has been granted to the user. + /// [PurchaseWrapper.purchaseState] should be [PurchaseStateWrapper.purchased] and + /// the purchase should be validated. See [Verify a purchase](https://developer.android.com/google/play/billing/billing_library_overview#Verify) on verifying purchases. + /// + /// Please refer to [acknowledge](https://developer.android.com/google/play/billing/billing_library_overview#acknowledge) for more + /// details. + /// + /// This wraps [`BillingClient#acknowledgePurchase(String, AcknowledgePurchaseResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#acknowledgePurchase(com.android.billingclient.api.AcknowledgePurchaseParams,%20com.android.billingclient.api.AcknowledgePurchaseResponseListener)) + Future acknowledgePurchase(String purchaseToken) async { + assert(purchaseToken != null); + return BillingResultWrapper.fromJson((await channel.invokeMapMethod( + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)', + { + 'purchaseToken': purchaseToken, + })) ?? + {}); + } + + /// The method call handler for [channel]. + @visibleForTesting + Future callHandler(MethodCall call) async { + switch (call.method) { + case kOnPurchasesUpdated: + // The purchases updated listener is a singleton. + assert(_callbacks[kOnPurchasesUpdated]!.length == 1); + final PurchasesUpdatedListener listener = + _callbacks[kOnPurchasesUpdated]!.first as PurchasesUpdatedListener; + listener(PurchasesResultWrapper.fromJson( + call.arguments.cast())); + break; + case _kOnBillingServiceDisconnected: + final int handle = call.arguments['handle']; + await _callbacks[_kOnBillingServiceDisconnected]![handle](); + break; + } + } +} + +/// Callback triggered when the [BillingClientWrapper] is disconnected. +/// +/// Wraps +/// [`com.android.billingclient.api.BillingClientStateListener.onServiceDisconnected()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClientStateListener.html#onBillingServiceDisconnected()) +/// to call back on `BillingClient` disconnect. +typedef void OnBillingServiceDisconnected(); + +/// Possible `BillingClient` response statuses. +/// +/// Wraps +/// [`BillingClient.BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse). +/// See the `BillingResponse` docs for more explanation of the different +/// constants. +enum BillingResponse { + // 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. + + /// The request has reached the maximum timeout before Google Play responds. + @JsonValue(-3) + serviceTimeout, + + /// The requested feature is not supported by Play Store on the current device. + @JsonValue(-2) + featureNotSupported, + + /// The play Store service is not connected now - potentially transient state. + @JsonValue(-1) + serviceDisconnected, + + /// Success. + @JsonValue(0) + ok, + + /// The user pressed back or canceled a dialog. + @JsonValue(1) + userCanceled, + + /// The network connection is down. + @JsonValue(2) + serviceUnavailable, + + /// The billing API version is not supported for the type requested. + @JsonValue(3) + billingUnavailable, + + /// The requested product is not available for purchase. + @JsonValue(4) + itemUnavailable, + + /// Invalid arguments provided to the API. + @JsonValue(5) + developerError, + + /// Fatal error during the API action. + @JsonValue(6) + error, + + /// Failure to purchase since item is already owned. + @JsonValue(7) + itemAlreadyOwned, + + /// Failure to consume since item is not owned. + @JsonValue(8) + itemNotOwned, +} + +/// Enum representing potential [SkuDetailsWrapper.type]s. +/// +/// Wraps +/// [`BillingClient.SkuType`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.SkuType) +/// See the linked documentation for an explanation of the different constants. +enum SkuType { + // 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. + + /// A one time product. Acquired in a single transaction. + @JsonValue('inapp') + inapp, + + /// A product requiring a recurring charge over time. + @JsonValue('subs') + subs, +} + +/// Enum representing the proration mode. +/// +/// When upgrading or downgrading a subscription, set this mode to provide details +/// about the proration that will be applied when the subscription changes. +/// +/// Wraps [`BillingFlowParams.ProrationMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode) +/// See the linked documentation for an explanation of the different constants. +enum ProrationMode { +// 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. + + /// Unknown upgrade or downgrade policy. + @JsonValue(0) + unknownSubscriptionUpgradeDowngradePolicy, + + /// Replacement takes effect immediately, and the remaining time will be prorated and credited to the user. + /// + /// This is the current default behavior. + @JsonValue(1) + immediateWithTimeProration, + + /// Replacement takes effect immediately, and the billing cycle remains the same. + /// + /// The price for the remaining period will be charged. + /// This option is only available for subscription upgrade. + @JsonValue(2) + immediateAndChargeProratedPrice, + + /// Replacement takes effect immediately, and the new price will be charged on next recurrence time. + /// + /// The billing cycle stays the same. + @JsonValue(3) + immediateWithoutProration, + + /// Replacement takes effect when the old plan expires, and the new price will be charged at the same time. + @JsonValue(4) + deferred, +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart new file mode 100644 index 000000000000..46d6843af846 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart @@ -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. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../billing_client_wrappers.dart'; + +part 'enum_converters.g.dart'; + +/// Serializer for [BillingResponse]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@BillingResponseConverter()`. +class BillingResponseConverter implements JsonConverter { + /// Default const constructor. + const BillingResponseConverter(); + + @override + BillingResponse fromJson(int? json) { + if (json == null) { + return BillingResponse.error; + } + return _$enumDecode( + _$BillingResponseEnumMap.cast(), json); + } + + @override + int toJson(BillingResponse object) => _$BillingResponseEnumMap[object]!; +} + +/// Serializer for [SkuType]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@SkuTypeConverter()`. +class SkuTypeConverter implements JsonConverter { + /// Default const constructor. + const SkuTypeConverter(); + + @override + SkuType fromJson(String? json) { + if (json == null) { + return SkuType.inapp; + } + return _$enumDecode( + _$SkuTypeEnumMap.cast(), json); + } + + @override + String toJson(SkuType object) => _$SkuTypeEnumMap[object]!; +} + +/// Serializer for [ProrationMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@ProrationModeConverter()`. +class ProrationModeConverter implements JsonConverter { + /// Default const constructor. + const ProrationModeConverter(); + + @override + ProrationMode fromJson(int? json) { + if (json == null) { + return ProrationMode.unknownSubscriptionUpgradeDowngradePolicy; + } + return _$enumDecode( + _$ProrationModeEnumMap.cast(), json); + } + + @override + int toJson(ProrationMode object) => _$ProrationModeEnumMap[object]!; +} + +// Define a class so we generate serializer helper methods for the enums +@JsonSerializable() +class _SerializedEnums { + late BillingResponse response; + late SkuType type; + late PurchaseStateWrapper purchaseState; + late ProrationMode prorationMode; +} + +/// Serializer for [PurchaseStateWrapper]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@PurchaseStateConverter()`. +class PurchaseStateConverter + implements JsonConverter { + /// Default const constructor. + const PurchaseStateConverter(); + + @override + PurchaseStateWrapper fromJson(int? json) { + if (json == null) { + return PurchaseStateWrapper.unspecified_state; + } + return _$enumDecode( + _$PurchaseStateWrapperEnumMap.cast(), + json); + } + + @override + int toJson(PurchaseStateWrapper object) => + _$PurchaseStateWrapperEnumMap[object]!; + + /// Converts the purchase state stored in `object` to a [PurchaseStatus]. + /// + /// [PurchaseStateWrapper.unspecified_state] is mapped to [PurchaseStatus.error]. + PurchaseStatus toPurchaseStatus(PurchaseStateWrapper object) { + switch (object) { + case PurchaseStateWrapper.pending: + return PurchaseStatus.pending; + case PurchaseStateWrapper.purchased: + return PurchaseStatus.purchased; + case PurchaseStateWrapper.unspecified_state: + return PurchaseStatus.error; + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart new file mode 100644 index 000000000000..4186a2a24252 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'enum_converters.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SerializedEnums _$_SerializedEnumsFromJson(Map json) { + return _SerializedEnums() + ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) + ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) + ..purchaseState = + _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) + ..prorationMode = + _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']); +} + +Map _$_SerializedEnumsToJson(_SerializedEnums instance) => + { + 'response': _$BillingResponseEnumMap[instance.response], + 'type': _$SkuTypeEnumMap[instance.type], + 'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState], + 'prorationMode': _$ProrationModeEnumMap[instance.prorationMode], + }; + +K _$enumDecode( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + throw ArgumentError( + 'A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + return enumValues.entries.singleWhere( + (e) => e.value == source, + orElse: () { + if (unknownValue == null) { + throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ); + } + return MapEntry(unknownValue, enumValues.values.first); + }, + ).key; +} + +const _$BillingResponseEnumMap = { + BillingResponse.serviceTimeout: -3, + BillingResponse.featureNotSupported: -2, + BillingResponse.serviceDisconnected: -1, + BillingResponse.ok: 0, + BillingResponse.userCanceled: 1, + BillingResponse.serviceUnavailable: 2, + BillingResponse.billingUnavailable: 3, + BillingResponse.itemUnavailable: 4, + BillingResponse.developerError: 5, + BillingResponse.error: 6, + BillingResponse.itemAlreadyOwned: 7, + BillingResponse.itemNotOwned: 8, +}; + +const _$SkuTypeEnumMap = { + SkuType.inapp: 'inapp', + SkuType.subs: 'subs', +}; + +const _$PurchaseStateWrapperEnumMap = { + PurchaseStateWrapper.unspecified_state: 0, + PurchaseStateWrapper.purchased: 1, + PurchaseStateWrapper.pending: 2, +}; + +const _$ProrationModeEnumMap = { + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy: 0, + ProrationMode.immediateWithTimeProration: 1, + ProrationMode.immediateAndChargeProratedPrice: 2, + ProrationMode.immediateWithoutProration: 3, + ProrationMode.deferred: 4, +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart new file mode 100644 index 000000000000..7ef089f4af8d --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -0,0 +1,332 @@ +// 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 'dart:ui' show hashValues; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'enum_converters.dart'; +import 'billing_client_wrapper.dart'; +import 'sku_details_wrapper.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 'purchase_wrapper.g.dart'; + +/// Data structure representing a successful purchase. +/// +/// All purchase information should also be verified manually, with your +/// server if at all possible. See ["Verify a +/// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). +/// +/// This wraps [`com.android.billlingclient.api.Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase) +@JsonSerializable() +@PurchaseStateConverter() +class PurchaseWrapper { + /// Creates a purchase wrapper with the given purchase details. + @visibleForTesting + PurchaseWrapper( + {required this.orderId, + required this.packageName, + required this.purchaseTime, + required this.purchaseToken, + required this.signature, + required this.sku, + required this.isAutoRenewing, + required this.originalJson, + this.developerPayload, + required this.isAcknowledged, + required this.purchaseState}); + + /// Factory for creating a [PurchaseWrapper] from a [Map] with the purchase details. + factory PurchaseWrapper.fromJson(Map map) => + _$PurchaseWrapperFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchaseWrapper typedOther = other as PurchaseWrapper; + return typedOther.orderId == orderId && + typedOther.packageName == packageName && + typedOther.purchaseTime == purchaseTime && + typedOther.purchaseToken == purchaseToken && + typedOther.signature == signature && + typedOther.sku == sku && + typedOther.isAutoRenewing == isAutoRenewing && + typedOther.originalJson == originalJson && + typedOther.isAcknowledged == isAcknowledged && + typedOther.purchaseState == purchaseState; + } + + @override + int get hashCode => hashValues( + orderId, + packageName, + purchaseTime, + purchaseToken, + signature, + sku, + isAutoRenewing, + originalJson, + isAcknowledged, + purchaseState); + + /// The unique ID for this purchase. Corresponds to the Google Payments order + /// ID. + @JsonKey(defaultValue: '') + final String orderId; + + /// The package name the purchase was made from. + @JsonKey(defaultValue: '') + final String packageName; + + /// When the purchase was made, as an epoch timestamp. + @JsonKey(defaultValue: 0) + final int purchaseTime; + + /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + @JsonKey(defaultValue: '') + final String purchaseToken; + + /// Signature of purchase data, signed with the developer's private key. Uses + /// RSASSA-PKCS1-v1_5. + @JsonKey(defaultValue: '') + final String signature; + + /// The product ID of this purchase. + @JsonKey(defaultValue: '') + final String sku; + + /// True for subscriptions that renew automatically. Does not apply to + /// [SkuType.inapp] products. + /// + /// For [SkuType.subs] this means that the subscription is canceled when it is + /// false. + /// + /// The value is `false` for [SkuType.inapp] products. + final bool isAutoRenewing; + + /// Details about this purchase, in JSON. + /// + /// This can be used verify a purchase. See ["Verify a purchase on a + /// device"](https://developer.android.com/google/play/billing/billing_library_overview#Verify-purchase-device). + /// Note though that verifying a purchase locally is inherently insecure (see + /// the article for more details). + @JsonKey(defaultValue: '') + final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + /// + /// The value is `null` if it wasn't specified when the purchase was acknowledged or consumed. + /// The `developerPayload` is removed from [BillingClientWrapper.acknowledgePurchase], [BillingClientWrapper.consumeAsync], [InAppPurchaseConnection.completePurchase], [InAppPurchaseConnection.consumePurchase] + /// after plugin version `0.5.0`. As a result, this will be `null` for new purchases that happen after updating to `0.5.0`. + final String? developerPayload; + + /// Whether the purchase has been acknowledged. + /// + /// A successful purchase has to be acknowledged within 3 days after the purchase via [BillingClient.acknowledgePurchase]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + @JsonKey(defaultValue: false) + final bool isAcknowledged; + + /// Determines the current state of the purchase. + /// + /// [BillingClient.acknowledgePurchase] should only be called when the `purchaseState` is [PurchaseStateWrapper.purchased]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + final PurchaseStateWrapper purchaseState; +} + +/// Data structure representing a purchase history record. +/// +/// This class includes a subset of fields in [PurchaseWrapper]. +/// +/// This wraps [`com.android.billlingclient.api.PurchaseHistoryRecord`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord) +/// +/// * See also: [BillingClient.queryPurchaseHistory] for obtaining a [PurchaseHistoryRecordWrapper]. +// We can optionally make [PurchaseWrapper] extend or implement [PurchaseHistoryRecordWrapper]. +// For now, we keep them separated classes to be consistent with Android's BillingClient implementation. +@JsonSerializable() +class PurchaseHistoryRecordWrapper { + /// Creates a [PurchaseHistoryRecordWrapper] with the given record details. + @visibleForTesting + PurchaseHistoryRecordWrapper({ + required this.purchaseTime, + required this.purchaseToken, + required this.signature, + required this.sku, + required this.originalJson, + required this.developerPayload, + }); + + /// Factory for creating a [PurchaseHistoryRecordWrapper] from a [Map] with the record details. + factory PurchaseHistoryRecordWrapper.fromJson(Map map) => + _$PurchaseHistoryRecordWrapperFromJson(map); + + /// When the purchase was made, as an epoch timestamp. + @JsonKey(defaultValue: 0) + final int purchaseTime; + + /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + @JsonKey(defaultValue: '') + final String purchaseToken; + + /// Signature of purchase data, signed with the developer's private key. Uses + /// RSASSA-PKCS1-v1_5. + @JsonKey(defaultValue: '') + final String signature; + + /// The product ID of this purchase. + @JsonKey(defaultValue: '') + final String sku; + + /// Details about this purchase, in JSON. + /// + /// This can be used verify a purchase. See ["Verify a purchase on a + /// device"](https://developer.android.com/google/play/billing/billing_library_overview#Verify-purchase-device). + /// Note though that verifying a purchase locally is inherently insecure (see + /// the article for more details). + @JsonKey(defaultValue: '') + final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + /// + /// The value is `null` if it wasn't specified when the purchase was acknowledged or consumed. + final String? developerPayload; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchaseHistoryRecordWrapper typedOther = + other as PurchaseHistoryRecordWrapper; + return typedOther.purchaseTime == purchaseTime && + typedOther.purchaseToken == purchaseToken && + typedOther.signature == signature && + typedOther.sku == sku && + typedOther.originalJson == originalJson && + typedOther.developerPayload == developerPayload; + } + + @override + int get hashCode => hashValues(purchaseTime, purchaseToken, signature, sku, + originalJson, developerPayload); +} + +/// A data struct representing the result of a transaction. +/// +/// Contains a potentially empty list of [PurchaseWrapper]s, a [BillingResultWrapper] +/// that contains a detailed description of the status and a +/// [BillingResponse] to signify the overall state of the transaction. +/// +/// Wraps [`com.android.billingclient.api.Purchase.PurchasesResult`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchasesResult). +@JsonSerializable() +@BillingResponseConverter() +class PurchasesResultWrapper { + /// Creates a [PurchasesResultWrapper] with the given purchase result details. + PurchasesResultWrapper( + {required this.responseCode, + required this.billingResult, + required this.purchasesList}); + + /// Factory for creating a [PurchaseResultWrapper] from a [Map] with the result details. + factory PurchasesResultWrapper.fromJson(Map map) => + _$PurchasesResultWrapperFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchasesResultWrapper typedOther = other as PurchasesResultWrapper; + return typedOther.responseCode == responseCode && + typedOther.purchasesList == purchasesList && + typedOther.billingResult == billingResult; + } + + @override + int get hashCode => hashValues(billingResult, responseCode, purchasesList); + + /// The detailed description of the status of the operation. + final BillingResultWrapper billingResult; + + /// The status of the operation. + /// + /// This can represent either the status of the "query purchase history" half + /// of the operation and the "user made purchases" transaction itself. + final BillingResponse responseCode; + + /// The list of successful purchases made in this transaction. + /// + /// May be empty, especially if [responseCode] is not [BillingResponse.ok]. + @JsonKey(defaultValue: []) + final List purchasesList; +} + +/// A data struct representing the result of a purchase history. +/// +/// Contains a potentially empty list of [PurchaseHistoryRecordWrapper]s and a [BillingResultWrapper] +/// that contains a detailed description of the status. +@JsonSerializable() +@BillingResponseConverter() +class PurchasesHistoryResult { + /// Creates a [PurchasesHistoryResult] with the provided history. + PurchasesHistoryResult( + {required this.billingResult, required this.purchaseHistoryRecordList}); + + /// Factory for creating a [PurchasesHistoryResult] from a [Map] with the history result details. + factory PurchasesHistoryResult.fromJson(Map map) => + _$PurchasesHistoryResultFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchasesHistoryResult typedOther = other as PurchasesHistoryResult; + return typedOther.purchaseHistoryRecordList == purchaseHistoryRecordList && + typedOther.billingResult == billingResult; + } + + @override + int get hashCode => hashValues(billingResult, purchaseHistoryRecordList); + + /// The detailed description of the status of the [BillingClient.queryPurchaseHistory]. + final BillingResultWrapper billingResult; + + /// The list of queried purchase history records. + /// + /// May be empty, especially if [billingResult.responseCode] is not [BillingResponse.ok]. + @JsonKey(defaultValue: []) + final List purchaseHistoryRecordList; +} + +/// Possible state of a [PurchaseWrapper]. +/// +/// Wraps +/// [`BillingClient.api.Purchase.PurchaseState`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState.html). +/// * See also: [PurchaseWrapper]. +enum PurchaseStateWrapper { + /// The state is unspecified. + /// + /// No actions on the [PurchaseWrapper] should be performed on this state. + /// This is a catch-all. It should never be returned by the Play Billing Library. + @JsonValue(0) + unspecified_state, + + /// The user has completed the purchase process. + /// + /// The production should be delivered and then the purchase should be acknowledged. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + @JsonValue(1) + purchased, + + /// The user has started the purchase process. + /// + /// The user should follow the instructions that were given to them by the Play + /// Billing Library to complete the purchase. + /// + /// You can also choose to remind the user to complete the purchase if you detected a + /// [PurchaseWrapper] is still in the `pending` state in the future while calling [BillingClient.queryPurchases]. + @JsonValue(2) + pending, +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart new file mode 100644 index 000000000000..5f0d936e09c2 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'purchase_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PurchaseWrapper _$PurchaseWrapperFromJson(Map json) { + return PurchaseWrapper( + orderId: json['orderId'] as String? ?? '', + packageName: json['packageName'] as String? ?? '', + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + isAutoRenewing: json['isAutoRenewing'] as bool, + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + isAcknowledged: json['isAcknowledged'] as bool? ?? false, + purchaseState: + const PurchaseStateConverter().fromJson(json['purchaseState'] as int?), + ); +} + +Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => + { + 'orderId': instance.orderId, + 'packageName': instance.packageName, + 'purchaseTime': instance.purchaseTime, + 'purchaseToken': instance.purchaseToken, + 'signature': instance.signature, + 'sku': instance.sku, + 'isAutoRenewing': instance.isAutoRenewing, + 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, + 'isAcknowledged': instance.isAcknowledged, + 'purchaseState': + const PurchaseStateConverter().toJson(instance.purchaseState), + }; + +PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) { + return PurchaseHistoryRecordWrapper( + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + ); +} + +Map _$PurchaseHistoryRecordWrapperToJson( + PurchaseHistoryRecordWrapper instance) => + { + 'purchaseTime': instance.purchaseTime, + 'purchaseToken': instance.purchaseToken, + 'signature': instance.signature, + 'sku': instance.sku, + 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, + }; + +PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) { + return PurchasesResultWrapper( + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int?), + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchasesList: (json['purchasesList'] as List?) + ?.map((e) => + PurchaseWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$PurchasesResultWrapperToJson( + PurchasesResultWrapper instance) => + { + 'billingResult': instance.billingResult, + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), + 'purchasesList': instance.purchasesList, + }; + +PurchasesHistoryResult _$PurchasesHistoryResultFromJson(Map json) { + return PurchasesHistoryResult( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchaseHistoryRecordList: + (json['purchaseHistoryRecordList'] as List?) + ?.map((e) => PurchaseHistoryRecordWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$PurchasesHistoryResultToJson( + PurchasesHistoryResult instance) => + { + 'billingResult': instance.billingResult, + 'purchaseHistoryRecordList': instance.purchaseHistoryRecordList, + }; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart new file mode 100644 index 000000000000..e3d13df2262a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -0,0 +1,244 @@ +// 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 'dart:ui' show hashValues; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'billing_client_wrapper.dart'; +import 'enum_converters.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 'sku_details_wrapper.g.dart'; + +/// The error message shown when the map represents billing result is invalid from method channel. +/// +/// This usually indicates a series underlining code issue in the plugin. +@visibleForTesting +const kInvalidBillingResultErrorMessage = + 'Invalid billing result map from method channel.'; + +/// Dart wrapper around [`com.android.billingclient.api.SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails). +/// +/// Contains the details of an available product in Google Play Billing. +@JsonSerializable() +@SkuTypeConverter() +class SkuDetailsWrapper { + /// Creates a [SkuDetailsWrapper] with the given purchase details. + @visibleForTesting + SkuDetailsWrapper({ + required this.description, + required this.freeTrialPeriod, + required this.introductoryPrice, + required this.introductoryPriceMicros, + required this.introductoryPriceCycles, + required this.introductoryPricePeriod, + required this.price, + required this.priceAmountMicros, + required this.priceCurrencyCode, + required this.sku, + required this.subscriptionPeriod, + required this.title, + required this.type, + required this.originalPrice, + required this.originalPriceAmountMicros, + }); + + /// 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. + @visibleForTesting + factory SkuDetailsWrapper.fromJson(Map map) => + _$SkuDetailsWrapperFromJson(map); + + /// Textual description of the product. + @JsonKey(defaultValue: '') + final String description; + + /// Trial period in ISO 8601 format. + @JsonKey(defaultValue: '') + final String freeTrialPeriod; + + /// Introductory price, only applies to [SkuType.subs]. Formatted ("$0.99"). + @JsonKey(defaultValue: '') + final String introductoryPrice; + + /// [introductoryPrice] in micro-units 990000 + @JsonKey(defaultValue: '') + final String introductoryPriceMicros; + + /// The number of subscription billing periods for which the user will be given the introductory price, such as 3. + /// Returns 0 if the SKU is not a subscription or doesn't have an introductory period. + @JsonKey(defaultValue: 0) + final int introductoryPriceCycles; + + /// The billing period of [introductoryPrice], in ISO 8601 format. + @JsonKey(defaultValue: '') + final String introductoryPricePeriod; + + /// Formatted with currency symbol ("$0.99"). + @JsonKey(defaultValue: '') + final String price; + + /// [price] in micro-units ("990000"). + @JsonKey(defaultValue: 0) + final int priceAmountMicros; + + /// [price] ISO 4217 currency code. + @JsonKey(defaultValue: '') + final String priceCurrencyCode; + + /// The product ID in Google Play Console. + @JsonKey(defaultValue: '') + final String sku; + + /// Applies to [SkuType.subs], formatted in ISO 8601. + @JsonKey(defaultValue: '') + final String subscriptionPeriod; + + /// The product's title. + @JsonKey(defaultValue: '') + final String title; + + /// The [SkuType] of the product. + final SkuType type; + + /// The original price that the user purchased this product for. + @JsonKey(defaultValue: '') + final String originalPrice; + + /// [originalPrice] in micro-units ("990000"). + @JsonKey(defaultValue: 0) + final int originalPriceAmountMicros; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final SkuDetailsWrapper typedOther = other; + return typedOther is SkuDetailsWrapper && + typedOther.description == description && + typedOther.freeTrialPeriod == freeTrialPeriod && + typedOther.introductoryPrice == introductoryPrice && + typedOther.introductoryPriceMicros == introductoryPriceMicros && + typedOther.introductoryPriceCycles == introductoryPriceCycles && + typedOther.introductoryPricePeriod == introductoryPricePeriod && + typedOther.price == price && + typedOther.priceAmountMicros == priceAmountMicros && + typedOther.sku == sku && + typedOther.subscriptionPeriod == subscriptionPeriod && + typedOther.title == title && + typedOther.type == type && + typedOther.originalPrice == originalPrice && + typedOther.originalPriceAmountMicros == originalPriceAmountMicros; + } + + @override + int get hashCode { + return hashValues( + description.hashCode, + freeTrialPeriod.hashCode, + introductoryPrice.hashCode, + introductoryPriceMicros.hashCode, + introductoryPriceCycles.hashCode, + introductoryPricePeriod.hashCode, + price.hashCode, + priceAmountMicros.hashCode, + sku.hashCode, + subscriptionPeriod.hashCode, + title.hashCode, + type.hashCode, + originalPrice, + originalPriceAmountMicros); + } +} + +/// Translation of [`com.android.billingclient.api.SkuDetailsResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetailsResponseListener.html). +/// +/// Returned by [BillingClient.querySkuDetails]. +@JsonSerializable() +class SkuDetailsResponseWrapper { + /// Creates a [SkuDetailsResponseWrapper] with the given purchase details. + @visibleForTesting + SkuDetailsResponseWrapper( + {required this.billingResult, required this.skuDetailsList}); + + /// 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 SkuDetailsResponseWrapper.fromJson(Map map) => + _$SkuDetailsResponseWrapperFromJson(map); + + /// The final result of the [BillingClient.querySkuDetails] call. + final BillingResultWrapper billingResult; + + /// A list of [SkuDetailsWrapper] matching the query to [BillingClient.querySkuDetails]. + @JsonKey(defaultValue: []) + final List skuDetailsList; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final SkuDetailsResponseWrapper typedOther = other; + return typedOther is SkuDetailsResponseWrapper && + typedOther.billingResult == billingResult && + typedOther.skuDetailsList == skuDetailsList; + } + + @override + int get hashCode => hashValues(billingResult, skuDetailsList); +} + +/// Params containing the response code and the debug message from the Play Billing API response. +@JsonSerializable() +@BillingResponseConverter() +class BillingResultWrapper { + /// Constructs the object with [responseCode] and [debugMessage]. + BillingResultWrapper({required this.responseCode, this.debugMessage}); + + /// 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 BillingResultWrapper.fromJson(Map? map) { + if (map == null || map.isEmpty) { + return BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage); + } + return _$BillingResultWrapperFromJson(map); + } + + /// Response code returned in the Play Billing API calls. + 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. + final String? debugMessage; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final BillingResultWrapper typedOther = other; + return typedOther is BillingResultWrapper && + typedOther.responseCode == responseCode && + typedOther.debugMessage == debugMessage; + } + + @override + int get hashCode => hashValues(responseCode, debugMessage); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart new file mode 100644 index 000000000000..a14affdf9ed3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sku_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { + return SkuDetailsWrapper( + description: json['description'] as String? ?? '', + freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', + introductoryPrice: json['introductoryPrice'] as String? ?? '', + introductoryPriceMicros: json['introductoryPriceMicros'] as String? ?? '', + introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, + introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', + price: json['price'] as String? ?? '', + priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, + priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + sku: json['sku'] as String? ?? '', + subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', + title: json['title'] as String? ?? '', + type: const SkuTypeConverter().fromJson(json['type'] as String?), + originalPrice: json['originalPrice'] as String? ?? '', + originalPriceAmountMicros: json['originalPriceAmountMicros'] as int? ?? 0, + ); +} + +Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => + { + 'description': instance.description, + 'freeTrialPeriod': instance.freeTrialPeriod, + 'introductoryPrice': instance.introductoryPrice, + 'introductoryPriceMicros': instance.introductoryPriceMicros, + 'introductoryPriceCycles': instance.introductoryPriceCycles, + 'introductoryPricePeriod': instance.introductoryPricePeriod, + 'price': instance.price, + 'priceAmountMicros': instance.priceAmountMicros, + 'priceCurrencyCode': instance.priceCurrencyCode, + 'sku': instance.sku, + 'subscriptionPeriod': instance.subscriptionPeriod, + 'title': instance.title, + 'type': const SkuTypeConverter().toJson(instance.type), + 'originalPrice': instance.originalPrice, + 'originalPriceAmountMicros': instance.originalPriceAmountMicros, + }; + +SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { + return SkuDetailsResponseWrapper( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + skuDetailsList: (json['skuDetailsList'] as List?) + ?.map((e) => + SkuDetailsWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$SkuDetailsResponseWrapperToJson( + SkuDetailsResponseWrapper instance) => + { + 'billingResult': instance.billingResult, + 'skuDetailsList': instance.skuDetailsList, + }; + +BillingResultWrapper _$BillingResultWrapperFromJson(Map json) { + return BillingResultWrapper( + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int?), + debugMessage: json['debugMessage'] as String?, + ); +} + +Map _$BillingResultWrapperToJson( + BillingResultWrapper instance) => + { + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), + 'debugMessage': instance.debugMessage, + }; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart new file mode 100644 index 000000000000..f8ab4d48be7e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart @@ -0,0 +1,9 @@ +// 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/services.dart'; + +/// Method channel for the plugin's platform<-->Dart calls. +const MethodChannel channel = + MethodChannel('plugins.flutter.io/in_app_purchase'); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart new file mode 100644 index 000000000000..9f87307ff21b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -0,0 +1,9 @@ +// 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. + +/// [IAPError.code] code for failed purchases. +const String kPurchaseErrorCode = 'purchase_error'; + +/// Indicates store front is Google Play +const String kIAPSource = 'google_play'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart new file mode 100644 index 000000000000..62589038804e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -0,0 +1,49 @@ +// 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:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +/// The class represents the information of a product as registered in at +/// Google Play store front. +class GooglePlayProductDetails extends ProductDetails { + /// Creates a new Google Play specific product details object with the + /// provided details. + GooglePlayProductDetails({ + required String id, + required String title, + required String description, + required String price, + required double rawPrice, + required String currencyCode, + required this.skuDetails, + }) : super( + id: id, + title: title, + description: description, + price: price, + rawPrice: rawPrice, + currencyCode: currencyCode, + ); + + /// Points back to the [SkuDetailsWrapper] object that was used to generate + /// this [GooglePlayProductDetails] object. + final SkuDetailsWrapper skuDetails; + + /// Generate a [GooglePlayProductDetails] object based on an Android + /// [SkuDetailsWrapper] object. + factory GooglePlayProductDetails.fromSkuDetails( + SkuDetailsWrapper skuDetails, + ) { + return GooglePlayProductDetails( + id: skuDetails.sku, + title: skuDetails.title, + description: skuDetails.description, + price: skuDetails.price, + rawPrice: ((skuDetails.priceAmountMicros) / 1000000.0).toDouble(), + currencyCode: skuDetails.priceCurrencyCode, + skuDetails: skuDetails, + ); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart new file mode 100644 index 000000000000..66e3a8f5a590 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart @@ -0,0 +1,71 @@ +// 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:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../billing_client_wrappers.dart'; +import '../in_app_purchase_android_platform.dart'; + +/// The class represents the information of a purchase made using Google Play. +class GooglePlayPurchaseDetails extends PurchaseDetails { + /// Creates a new Google Play specific purchase details object with the + /// provided details. + GooglePlayPurchaseDetails({ + String? purchaseID, + required String productID, + required PurchaseVerificationData verificationData, + required String? transactionDate, + required this.billingClientPurchase, + required PurchaseStatus status, + }) : super( + productID: productID, + purchaseID: purchaseID, + transactionDate: transactionDate, + verificationData: verificationData, + status: status) { + this.status = status; + } + + /// Points back to the [PurchaseWrapper] which was used to generate this + /// [GooglePlayPurchaseDetails] object. + final PurchaseWrapper billingClientPurchase; + + late PurchaseStatus _status; + + /// The status that this [PurchaseDetails] is currently on. + PurchaseStatus get status => _status; + set status(PurchaseStatus status) { + _pendingCompletePurchase = status == PurchaseStatus.purchased; + _status = status; + } + + bool _pendingCompletePurchase = false; + bool get pendingCompletePurchase => _pendingCompletePurchase; + + /// Generate a [PurchaseDetails] object based on an Android [Purchase] object. + factory GooglePlayPurchaseDetails.fromPurchase(PurchaseWrapper purchase) { + final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails( + purchaseID: purchase.orderId, + productID: purchase.sku, + verificationData: PurchaseVerificationData( + localVerificationData: purchase.originalJson, + serverVerificationData: purchase.purchaseToken, + source: kIAPSource), + transactionDate: purchase.purchaseTime.toString(), + billingClientPurchase: purchase, + status: PurchaseStateConverter().toPurchaseStatus(purchase.purchaseState), + ); + + if (purchaseDetails.status == PurchaseStatus.error) { + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: '', + ); + } + + return purchaseDetails; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart new file mode 100644 index 000000000000..a0aa9ea8c2a8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart @@ -0,0 +1,5 @@ +// 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. + +export 'google_play_purchase_details.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml new file mode 100644 index 000000000000..41cb3e87e185 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -0,0 +1,35 @@ +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/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android +version: 0.1.0 + +# TODO(mvanbeusekom): Remove when in_app_purchase_platform_interface is published +publish_to: 'none' + +flutter: + plugin: + platforms: + android: + package: io.flutter.plugins.inapppurchase + pluginClass: InAppPurchasePlugin + +dependencies: + # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published + in_app_purchase_platform_interface: + path: ../in_app_purchase_platform_interface + + flutter: + sdk: flutter + + meta: ^1.3.0 + test: ^1.16.0 + +dev_dependencies: + build_runner: ^1.11.1 + json_serializable: ^4.1.1 + flutter_test: + sdk: flutter + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" \ No newline at end of file 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 new file mode 100644 index 000000000000..ec7289735ade --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -0,0 +1,547 @@ +// 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_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; + +import '../stub_in_app_purchase_platform.dart'; +import 'sku_details_wrapper_test.dart'; +import 'purchase_wrapper_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late BillingClient billingClient; + + setUpAll(() => + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler)); + + setUp(() { + billingClient = BillingClient((PurchasesResultWrapper _) {}); + billingClient.enablePendingPurchases(); + stubPlatform.reset(); + }); + + group('isReady', () { + test('true', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: true); + expect(await billingClient.isReady(), isTrue); + }); + + test('false', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: false); + expect(await billingClient.isReady(), isFalse); + }); + }); + + // Make sure that the enum values are supported and that the converter call + // does not fail + test('response states', () async { + BillingResponseConverter converter = BillingResponseConverter(); + converter.fromJson(-3); + converter.fromJson(-2); + converter.fromJson(-1); + converter.fromJson(0); + converter.fromJson(1); + converter.fromJson(2); + converter.fromJson(3); + converter.fromJson(4); + converter.fromJson(5); + converter.fromJson(6); + converter.fromJson(7); + converter.fromJson(8); + }); + + group('startConnection', () { + final String methodName = + 'BillingClient#startConnection(BillingClientStateListener)'; + test('returns BillingResultWrapper', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse( + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect( + await billingClient.startConnection( + onBillingServiceDisconnected: () {}), + equals(billingResult)); + }); + + test('passes handle to onBillingServiceDisconnected', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse( + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + await billingClient.startConnection(onBillingServiceDisconnected: () {}); + final MethodCall call = stubPlatform.previousCallMatching(methodName); + expect( + call.arguments, + equals( + {'handle': 0, 'enablePendingPurchases': true})); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: methodName, + value: null, + ); + + expect( + await billingClient.startConnection( + onBillingServiceDisconnected: () {}), + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + test('endConnection', () async { + final String endConnectionName = 'BillingClient#endConnection()'; + expect(stubPlatform.countPreviousCalls(endConnectionName), equals(0)); + stubPlatform.addResponse(name: endConnectionName, value: null); + await billingClient.endConnection(); + expect(stubPlatform.countPreviousCalls(endConnectionName), equals(1)); + }); + + group('querySkuDetails', () { + final String queryMethodName = + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + + test('handles empty skuDetails', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[] + }); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, isEmpty); + }); + + test('returns SkuDetailsResponseWrapper', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, contains(dummySkuDetails)); + }); + + test('handles null method channel response', () async { + stubPlatform.addResponse(name: queryMethodName, value: null); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, isEmpty); + }); + }); + + group('launchBillingFlow', () { + final String launchMethodName = + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)'; + + test('serializes and deserializes data', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + final String profileId = "hashedProfileId"; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + }); + + test( + 'Change subscription throws assertion error `oldSku` and `purchaseToken` has different nullability', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + + expect( + billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + purchaseToken: null), + throwsAssertionError); + + expect( + billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: null, + purchaseToken: dummyOldPurchase.purchaseToken), + throwsAssertionError); + }); + + test( + 'serializes and deserializes data on change subscription without proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + purchaseToken: dummyOldPurchase.purchaseToken), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect( + arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + }); + + test( + 'serializes and deserializes data on change subscription with proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + final prorationMode = ProrationMode.immediateAndChargeProratedPrice; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + prorationMode: prorationMode, + purchaseToken: dummyOldPurchase.purchaseToken), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + expect( + arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); + expect(arguments['prorationMode'], + ProrationModeConverter().toJson(prorationMode)); + }); + + test('handles null accountId', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + + expect(await billingClient.launchBillingFlow(sku: skuDetails.sku), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], isNull); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: launchMethodName, + value: null, + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + expect( + await billingClient.launchBillingFlow(sku: skuDetails.sku), + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + group('queryPurchases', () { + const String queryPurchasesMethodName = + 'BillingClient#queryPurchases(String)'; + + test('serializes and deserializes data', () async { + final BillingResponse expectedCode = BillingResponse.ok; + final List expectedList = [ + dummyPurchase + ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform + .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(expectedCode), + 'purchasesList': expectedList + .map((PurchaseWrapper purchase) => buildPurchaseMap(purchase)) + .toList(), + }); + + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.responseCode, equals(expectedCode)); + expect(response.purchasesList, equals(expectedList)); + }); + + test('handles empty purchases', () async { + final BillingResponse expectedCode = BillingResponse.userCanceled; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform + .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(expectedCode), + 'purchasesList': [], + }); + + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.responseCode, equals(expectedCode)); + expect(response.purchasesList, isEmpty); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: queryPurchasesMethodName, + value: null, + ); + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect( + response.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(response.responseCode, BillingResponse.error); + expect(response.purchasesList, isEmpty); + }); + }); + + group('queryPurchaseHistory', () { + const String queryPurchaseHistoryMethodName = + 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)'; + + test('serializes and deserializes data', () async { + final BillingResponse expectedCode = BillingResponse.ok; + final List expectedList = + [ + dummyPurchaseHistoryRecord, + ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: queryPurchaseHistoryMethodName, + value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': expectedList + .map((PurchaseHistoryRecordWrapper purchaseHistoryRecord) => + buildPurchaseHistoryRecordMap(purchaseHistoryRecord)) + .toList(), + }); + + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, equals(expectedList)); + }); + + test('handles empty purchases', () async { + final BillingResponse expectedCode = BillingResponse.userCanceled; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryPurchaseHistoryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': [], + }); + + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, isEmpty); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: queryPurchaseHistoryMethodName, + value: null, + ); + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + + expect( + response.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(response.purchaseHistoryRecordList, isEmpty); + }); + }); + + group('consume purchases', () { + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + test('consume purchase async success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final BillingResultWrapper billingResult = + await billingClient.consumeAsync('dummy token'); + + expect(billingResult, equals(expectedBillingResult)); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: consumeMethodName, + value: null, + ); + final BillingResultWrapper billingResult = + await billingClient.consumeAsync('dummy token'); + + expect( + billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + group('acknowledge purchases', () { + const String acknowledgeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('acknowledge purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: acknowledgeMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final BillingResultWrapper billingResult = + await billingClient.acknowledgePurchase('dummy token'); + + expect(billingResult, equals(expectedBillingResult)); + }); + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: acknowledgeMethodName, + value: null, + ); + final BillingResultWrapper billingResult = + await billingClient.acknowledgePurchase('dummy token'); + + expect( + billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart new file mode 100644 index 000000000000..a3e80a89fa7e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -0,0 +1,214 @@ +// 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:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:test/test.dart'; + +final PurchaseWrapper dummyPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: false, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = + PurchaseHistoryRecordWrapper( + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + originalJson: '', + developerPayload: 'dummy payload', +); + +final PurchaseWrapper dummyOldPurchase = PurchaseWrapper( + orderId: 'oldOrderId', + packageName: 'oldPackageName', + purchaseTime: 0, + signature: 'oldSignature', + sku: 'oldSku', + purchaseToken: 'oldPurchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'old dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + +void main() { + group('PurchaseWrapper', () { + test('converts from map', () { + final PurchaseWrapper expected = dummyPurchase; + final PurchaseWrapper parsed = + PurchaseWrapper.fromJson(buildPurchaseMap(expected)); + + expect(parsed, equals(expected)); + }); + + test('toPurchaseDetails() should return correct PurchaseDetail object', () { + final GooglePlayPurchaseDetails details = + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase); + expect(details.purchaseID, dummyPurchase.orderId); + expect(details.productID, dummyPurchase.sku); + expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); + expect(details.verificationData, isNotNull); + expect(details.verificationData.source, kIAPSource); + expect(details.verificationData.localVerificationData, + dummyPurchase.originalJson); + expect(details.verificationData.serverVerificationData, + dummyPurchase.purchaseToken); + expect(details.billingClientPurchase, dummyPurchase); + expect(details.pendingCompletePurchase, true); + }); + }); + + group('PurchaseHistoryRecordWrapper', () { + test('converts from map', () { + final PurchaseHistoryRecordWrapper expected = dummyPurchaseHistoryRecord; + final PurchaseHistoryRecordWrapper parsed = + PurchaseHistoryRecordWrapper.fromJson( + buildPurchaseHistoryRecordMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + + group('PurchasesResultWrapper', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + final List purchases = [ + dummyPurchase, + dummyPurchase + ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final PurchasesResultWrapper expected = PurchasesResultWrapper( + billingResult: billingResult, + responseCode: responseCode, + purchasesList: purchases); + final PurchasesResultWrapper parsed = + PurchasesResultWrapper.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[ + buildPurchaseMap(dummyPurchase), + buildPurchaseMap(dummyPurchase) + ] + }); + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.responseCode, equals(expected.responseCode)); + expect(parsed.purchasesList, containsAll(expected.purchasesList)); + }); + + test('parsed from empty map', () { + final PurchasesResultWrapper parsed = + PurchasesResultWrapper.fromJson({}); + expect( + parsed.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(parsed.responseCode, BillingResponse.error); + expect(parsed.purchasesList, isEmpty); + }); + }); + + group('PurchasesHistoryResult', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + final List purchaseHistoryRecordList = + [ + dummyPurchaseHistoryRecord, + dummyPurchaseHistoryRecord + ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final PurchasesHistoryResult expected = PurchasesHistoryResult( + billingResult: billingResult, + purchaseHistoryRecordList: purchaseHistoryRecordList); + final PurchasesHistoryResult parsed = + PurchasesHistoryResult.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), + 'purchaseHistoryRecordList': >[ + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord), + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord) + ] + }); + expect(parsed.billingResult, equals(billingResult)); + expect(parsed.purchaseHistoryRecordList, + containsAll(expected.purchaseHistoryRecordList)); + }); + + test('parsed from empty map', () { + final PurchasesHistoryResult parsed = + PurchasesHistoryResult.fromJson({}); + expect( + parsed.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(parsed.purchaseHistoryRecordList, isEmpty); + }); + }); +} + +Map buildPurchaseMap(PurchaseWrapper original) { + return { + 'orderId': original.orderId, + 'packageName': original.packageName, + 'purchaseTime': original.purchaseTime, + 'signature': original.signature, + 'sku': original.sku, + 'purchaseToken': original.purchaseToken, + 'isAutoRenewing': original.isAutoRenewing, + 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + 'purchaseState': PurchaseStateConverter().toJson(original.purchaseState), + 'isAcknowledged': original.isAcknowledged, + }; +} + +Map buildPurchaseHistoryRecordMap( + PurchaseHistoryRecordWrapper original) { + return { + 'purchaseTime': original.purchaseTime, + 'signature': original.signature, + 'sku': original.sku, + 'purchaseToken': original.purchaseToken, + 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + }; +} + +Map buildBillingResultMap(BillingResultWrapper original) { + return { + 'responseCode': BillingResponseConverter().toJson(original.responseCode), + 'debugMessage': original.debugMessage, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart new file mode 100644 index 000000000000..ead6d26576f3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -0,0 +1,149 @@ +// 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:in_app_purchase_android/src/types/google_play_product_details.dart'; +import 'package:test/test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; + +final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + introductoryPriceMicros: 'introductoryPriceMicros', + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, +); + +void main() { + group('SkuDetailsWrapper', () { + test('converts from map', () { + final SkuDetailsWrapper expected = dummySkuDetails; + final SkuDetailsWrapper parsed = + SkuDetailsWrapper.fromJson(buildSkuMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + + group('SkuDetailsResponseWrapper', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final List skusDetails = [ + dummySkuDetails, + dummySkuDetails + ]; + BillingResultWrapper result = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( + billingResult: result, skuDetailsList: skusDetails); + + final SkuDetailsResponseWrapper parsed = + SkuDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[ + buildSkuMap(dummySkuDetails), + buildSkuMap(dummySkuDetails) + ] + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); + }); + + test('toProductDetails() should return correct Product object', () { + final SkuDetailsWrapper wrapper = + SkuDetailsWrapper.fromJson(buildSkuMap(dummySkuDetails)); + final GooglePlayProductDetails product = + GooglePlayProductDetails.fromSkuDetails(wrapper); + expect(product.title, wrapper.title); + expect(product.description, wrapper.description); + expect(product.id, wrapper.sku); + expect(product.price, wrapper.price); + expect(product.skuDetails, wrapper); + }); + + test('handles empty list of skuDetails', () { + final BillingResponse responseCode = BillingResponse.error; + const String debugMessage = 'dummy message'; + final List skusDetails = []; + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( + billingResult: billingResult, skuDetailsList: skusDetails); + + final SkuDetailsResponseWrapper parsed = + SkuDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[] + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); + }); + + test('fromJson creates an object with default values', () { + final SkuDetailsResponseWrapper skuDetails = + SkuDetailsResponseWrapper.fromJson({}); + expect( + skuDetails.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(skuDetails.skuDetailsList, isEmpty); + }); + }); + + group('BillingResultWrapper', () { + test('fromJson on empty map creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson({}); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + + test('fromJson on null creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson(null); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + }); +} + +Map buildSkuMap(SkuDetailsWrapper original) { + return { + 'description': original.description, + 'freeTrialPeriod': original.freeTrialPeriod, + 'introductoryPrice': original.introductoryPrice, + 'introductoryPriceMicros': original.introductoryPriceMicros, + 'introductoryPriceCycles': original.introductoryPriceCycles, + 'introductoryPricePeriod': original.introductoryPricePeriod, + 'price': original.price, + 'priceAmountMicros': original.priceAmountMicros, + 'priceCurrencyCode': original.priceCurrencyCode, + 'sku': original.sku, + 'subscriptionPeriod': original.subscriptionPeriod, + 'title': original.title, + 'type': original.type.toString().substring(8), + 'originalPrice': original.originalPrice, + 'originalPriceAmountMicros': original.originalPriceAmountMicros, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart new file mode 100644 index 000000000000..11a3426335d5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart @@ -0,0 +1,45 @@ +// 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 'dart:async'; +import 'package:flutter/services.dart'; + +typedef void AdditionalSteps(dynamic args); + +class StubInAppPurchasePlatform { + Map _expectedCalls = {}; + Map _additionalSteps = {}; + void addResponse( + {required String name, + dynamic value, + AdditionalSteps? additionalStepBeforeReturn}) { + _additionalSteps[name] = additionalStepBeforeReturn; + _expectedCalls[name] = value; + } + + List _previousCalls = []; + List get previousCalls => _previousCalls; + MethodCall previousCallMatching(String name) => + _previousCalls.firstWhere((MethodCall call) => call.method == name); + int countPreviousCalls(String name) => + _previousCalls.where((MethodCall call) => call.method == name).length; + + void reset() { + _expectedCalls.clear(); + _previousCalls.clear(); + _additionalSteps.clear(); + } + + Future fakeMethodCallHandler(MethodCall call) async { + _previousCalls.add(call); + if (_expectedCalls.containsKey(call.method)) { + if (_additionalSteps[call.method] != null) { + _additionalSteps[call.method]!(call.arguments); + } + return Future.sync(() => _expectedCalls[call.method]); + } else { + return Future.sync(() => null); + } + } +} From e3e40c3b18ad0a5e0be1f3b7507a0e3107e681f2 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 3 May 2021 18:10:09 +0200 Subject: [PATCH 02/14] Android specific implementation --- .../src/in_app_purchase_android_platform.dart | 318 ++++++++ .../src/types/change_subscription_param.dart | 21 + .../src/types/google_play_purchase_param.dart | 24 + .../src/types/in_app_purchase_exception.dart | 23 + .../lib/src/types/types.dart | 3 + ...in_app_purchase_android_platform_test.dart | 679 ++++++++++++++++++ 6 files changed, 1068 insertions(+) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 9f87307ff21b..fba73669cf8f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -2,8 +2,326 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../billing_client_wrappers.dart'; + /// [IAPError.code] code for failed purchases. const String kPurchaseErrorCode = 'purchase_error'; +/// [IAPError.code] code used when a consuming a purchased item fails. +const String kConsumptionFailedErrorCode = 'consume_purchase_failed'; + +/// [IAPError.code] code used when a query for previouys transaction has failed. +const String kRestoredPurchaseErrorCode = 'restore_transactions_failed'; + /// Indicates store front is Google Play const String kIAPSource = 'google_play'; + +/// An [InAppPurchasePlatform] that wraps Android BillingClient. +/// +/// This translates various `BillingClient` calls and responses into the +/// generic plugin API. +class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform + with WidgetsBindingObserver { + InAppPurchaseAndroidPlatform._() + : billingClient = + BillingClient((PurchasesResultWrapper resultWrapper) async { + _purchaseUpdatedController + .add(await _getPurchaseDetailsFromResult(resultWrapper)); + }) { + if (InAppPurchaseAndroidPlatform.enablePendingPurchase) { + billingClient.enablePendingPurchases(); + } + + _readyFuture = _connect(); + WidgetsBinding.instance!.addObserver(this); + _purchaseUpdatedController = StreamController.broadcast(); + ; + } + + /// Returns the singleton instance of the [InAppPurchaseAndroidPlatform]. + static InAppPurchaseAndroidPlatform get instance => _getOrCreateInstance(); + static InAppPurchaseAndroidPlatform? _instance; + + /// Whether pending purchase is enabled. + /// + /// See also [enablePendingPurchases] for more on pending purchases. + static bool get enablePendingPurchase => _enablePendingPurchase; + static bool _enablePendingPurchase = false; + + /// Enable the [InAppPurchaseConnection] to handle pending purchases. + /// + /// This method is required to be called when initialize the application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// Failure to call this method before access [instance] will throw an exception. + static void enablePendingPurchases() { + _enablePendingPurchase = true; + } + + Stream> get purchaseStream => + _purchaseUpdatedController.stream; + static late StreamController> + _purchaseUpdatedController; + + /// The [BillingClient] that's abstracted by [GooglePlayConnection]. + /// + /// This field should not be used out of test code. + @visibleForTesting + late final BillingClient billingClient; + + late Future _readyFuture; + static Set _productIdsToConsume = Set(); + + @override + Future isAvailable() async { + await _readyFuture; + return billingClient.isReady(); + } + + @override + Future buyNonConsumable({required PurchaseParam purchaseParam}) async { + if (!(purchaseParam is GooglePlayPurchaseParam)) { + throw ArgumentError( + 'On Android, the `purchaseParam` should always be of type `GooglePlayPurchaseParam`.', + ); + } + + BillingResultWrapper billingResultWrapper = + await billingClient.launchBillingFlow( + sku: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName, + oldSku: purchaseParam + .changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: purchaseParam.changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: + purchaseParam.changeSubscriptionParam?.prorationMode); + return billingResultWrapper.responseCode == BillingResponse.ok; + } + + @override + Future buyConsumable( + {required PurchaseParam purchaseParam, bool autoConsume = true}) { + if (autoConsume) { + _productIdsToConsume.add(purchaseParam.productDetails.id); + } + return buyNonConsumable(purchaseParam: purchaseParam); + } + + @override + Future completePurchase( + PurchaseDetails purchase) async { + assert( + purchase is GooglePlayPurchaseDetails, + 'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.', + ); + + GooglePlayPurchaseDetails googlePurchase = + purchase as GooglePlayPurchaseDetails; + + if (googlePurchase.billingClientPurchase.isAcknowledged) { + return BillingResultWrapper(responseCode: BillingResponse.ok); + } + + if (googlePurchase.verificationData == null) { + throw ArgumentError( + 'completePurchase unsuccessful. The `purchase.verificationData` is not valid'); + } + + return await billingClient + .acknowledgePurchase(purchase.verificationData.serverVerificationData); + } + + /// Mark that the user has consumed a product. + /// + /// You are responsible for consuming all consumable purchases once they are + /// delivered. The user won't be able to buy the same product again until the + /// purchase of the product is consumed. + Future consumePurchase(PurchaseDetails purchase) { + if (purchase.verificationData == null) { + throw ArgumentError( + 'consumePurchase unsuccessful. The `purchase.verificationData` is not valid'); + } + return billingClient + .consumeAsync(purchase.verificationData.serverVerificationData); + } + + @override + Future restorePurchases({ + String? applicationUserName, + }) async { + List responses; + + responses = await Future.wait([ + billingClient.queryPurchases(SkuType.inapp), + billingClient.queryPurchases(SkuType.subs) + ]); + + Set errorCodeSet = responses + .where((PurchasesResultWrapper response) => + response.responseCode != BillingResponse.ok) + .map((PurchasesResultWrapper response) => + response.responseCode.toString()) + .toSet(); + + String errorMessage = + errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; + + List pastPurchases = + responses.expand((PurchasesResultWrapper response) { + return response.purchasesList; + }).map((PurchaseWrapper purchaseWrapper) { + return GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + }).toList(); + + if (errorMessage.isNotEmpty) { + throw InAppPurchaseException( + source: kIAPSource, + code: kRestoredPurchaseErrorCode, + message: errorMessage, + ); + } + + _purchaseUpdatedController.add(pastPurchases); + } + + /// Resets the connection instance. + /// + /// The next call to [instance] will create a new instance. Should only be + /// used in tests. + @visibleForTesting + static void reset() => _instance = null; + + static InAppPurchaseAndroidPlatform _getOrCreateInstance() { + if (_instance != null) { + return _instance!; + } + + _instance = InAppPurchaseAndroidPlatform._(); + return _instance!; + } + + Future _connect() => + billingClient.startConnection(onBillingServiceDisconnected: () {}); + + /// Query the product detail list. + /// + /// This method only returns [ProductDetailsResponse]. + /// To get detailed Google Play sku list, use [BillingClient.querySkuDetails] + /// to get the [SkuDetailsResponseWrapper]. + Future queryProductDetails( + Set identifiers) async { + List responses; + PlatformException? exception; + try { + responses = await Future.wait([ + billingClient.querySkuDetails( + skuType: SkuType.inapp, skusList: identifiers.toList()), + billingClient.querySkuDetails( + skuType: SkuType.subs, skusList: identifiers.toList()) + ]); + } on PlatformException catch (e) { + exception = e; + responses = [ + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []), + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []) + ]; + } + List productDetailsList = + responses.expand((SkuDetailsResponseWrapper response) { + return response.skuDetailsList; + }).map((SkuDetailsWrapper skuDetailWrapper) { + return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); + }).toList(); + + Set successIDS = productDetailsList + .map((ProductDetails productDetails) => productDetails.id) + .toSet(); + List notFoundIDS = identifiers.difference(successIDS).toList(); + return ProductDetailsResponse( + productDetails: productDetailsList, + notFoundIDs: notFoundIDS, + error: exception == null + ? null + : IAPError( + source: kIAPSource, + code: exception.code, + message: exception.message ?? '', + details: exception.details)); + } + + static Future> _getPurchaseDetailsFromResult( + PurchasesResultWrapper resultWrapper) async { + IAPError? error; + if (resultWrapper.responseCode != BillingResponse.ok) { + error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: resultWrapper.responseCode.toString(), + details: resultWrapper.billingResult.debugMessage, + ); + } + final List> purchases = + resultWrapper.purchasesList.map((PurchaseWrapper purchase) { + return _maybeAutoConsumePurchase( + GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error); + }).toList(); + if (purchases.isNotEmpty) { + return Future.wait(purchases); + } else { + return [ + PurchaseDetails( + purchaseID: '', + productID: '', + status: PurchaseStatus.error, + transactionDate: null, + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: kIAPSource)) + ..error = error + ]; + } + } + + static Future _maybeAutoConsumePurchase( + PurchaseDetails purchaseDetails) async { + if (!(purchaseDetails.status == PurchaseStatus.purchased && + _productIdsToConsume.contains(purchaseDetails.productID))) { + return purchaseDetails; + } + + final BillingResultWrapper billingResult = + await instance.consumePurchase(purchaseDetails); + final BillingResponse consumedResponse = billingResult.responseCode; + if (consumedResponse != BillingResponse.ok) { + purchaseDetails.status = PurchaseStatus.error; + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kConsumptionFailedErrorCode, + message: consumedResponse.toString(), + details: billingResult.debugMessage, + ); + } + _productIdsToConsume.remove(purchaseDetails.productID); + + return purchaseDetails; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart new file mode 100644 index 000000000000..270890366cd4 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart @@ -0,0 +1,21 @@ +import '../../billing_client_wrappers.dart'; +import 'types.dart'; + +/// This parameter object for upgrading or downgrading an existing subscription. +class ChangeSubscriptionParam { + /// Creates a new change subscription param object with given data + ChangeSubscriptionParam({ + required this.oldPurchaseDetails, + this.prorationMode, + }); + + /// The purchase object of the existing subscription that the user needs to + /// upgrade/downgrade from. + final GooglePlayPurchaseDetails oldPurchaseDetails; + + /// The proration mode. + /// + /// This is an optional parameter that indicates how to handle the existing + /// subscription when the new subscription comes into effect. + final ProrationMode? prorationMode; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart new file mode 100644 index 000000000000..bcf0ad62a245 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart @@ -0,0 +1,24 @@ +// 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:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../in_app_purchase_android.dart'; + +/// Google Play specific parameter object for generating a purchase. +class GooglePlayPurchaseParam extends PurchaseParam { + /// Creates a new [GooglePlayPurchaseParam] object with the given data. + GooglePlayPurchaseParam({ + required ProductDetails productDetails, + String? applicationUserName, + this.changeSubscriptionParam, + }) : super( + productDetails: productDetails, + applicationUserName: applicationUserName, + ); + + /// The 'changeSubscriptionParam' containing information for upgrading or + /// downgrading an existing subscription. + final ChangeSubscriptionParam? changeSubscriptionParam; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart new file mode 100644 index 000000000000..796f5eaf343f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart @@ -0,0 +1,23 @@ +/// Thrown to indicate that an action failed while interacting with the +/// in_app_purchase plugin. +class InAppPurchaseException implements Exception { + /// Creates a [InAppPurchaseException] with the specified source and error + /// [code] and optional [message]. + InAppPurchaseException({ + required this.source, + required this.code, + this.message, + }) : assert(code != null); + + /// An error code. + final String code; + + /// A human-readable error message, possibly null. + final String? message; + + /// Which source is the error on. + final String source; + + @override + String toString() => 'InAppPurchaseException($code, $message, $source)'; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart index a0aa9ea8c2a8..2982363c68ad 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart @@ -2,4 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'change_subscription_param.dart'; +export 'google_play_product_details.dart'; export 'google_play_purchase_details.dart'; +export 'google_play_purchase_param.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart new file mode 100644 index 000000000000..2329556c49a5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -0,0 +1,679 @@ +// 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 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' as widgets; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import 'billing_client_wrappers/purchase_wrapper_test.dart'; +import 'billing_client_wrappers/sku_details_wrapper_test.dart'; +import 'stub_in_app_purchase_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late InAppPurchaseAndroidPlatform iapAndroidPlatform; + const String startConnectionCall = + 'BillingClient#startConnection(BillingClientStateListener)'; + const String endConnectionCall = 'BillingClient#endConnection()'; + + setUpAll(() { + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler); + }); + + setUp(() { + widgets.WidgetsFlutterBinding.ensureInitialized(); + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: startConnectionCall, + value: buildBillingResultMap(expectedBillingResult)); + stubPlatform.addResponse(name: endConnectionCall, value: null); + InAppPurchaseAndroidPlatform.enablePendingPurchases(); + iapAndroidPlatform = InAppPurchaseAndroidPlatform.instance; + }); + + tearDown(() { + stubPlatform.reset(); + InAppPurchaseAndroidPlatform.reset(); + }); + + group('connection management', () { + test('connects on initialization', () { + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); + }); + }); + + group('isAvailable', () { + test('true', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: true); + expect(await iapAndroidPlatform.isAvailable(), isTrue); + }); + + test('false', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: false); + expect(await iapAndroidPlatform.isAvailable(), isFalse); + }); + }); + + group('querySkuDetails', () { + final String queryMethodName = + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + + test('handles empty skuDetails', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': [], + }); + + final ProductDetailsResponse response = + await iapAndroidPlatform.queryProductDetails([''].toSet()); + expect(response.productDetails, isEmpty); + }); + + test('should get correct product details', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['valid'].toSet()); + expect(response.productDetails.first.title, dummySkuDetails.title); + expect(response.productDetails.first.description, + dummySkuDetails.description); + expect(response.productDetails.first.price, dummySkuDetails.price); + }); + + test('should get the correct notFoundIDs', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['invalid'].toSet()); + expect(response.notFoundIDs.first, 'invalid'); + }); + + test( + 'should have error stored in the response when platform exception is thrown', + () async { + final BillingResponse responseCode = BillingResponse.ok; + stubPlatform.addResponse( + name: queryMethodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'skuDetailsList': >[ + buildSkuMap(dummySkuDetails) + ] + }, + additionalStepBeforeReturn: (_) { + throw PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}, + ); + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['invalid'].toSet()); + expect(response.notFoundIDs, ['invalid']); + expect(response.productDetails, isEmpty); + expect(response.error, isNotNull); + expect(response.error!.source, kIAPSource); + expect(response.error!.code, 'error_code'); + expect(response.error!.message, 'error_message'); + expect(response.error!.details, {'info': 'error_info'}); + }); + }); + + group('restorePurchases', () { + const String queryMethodName = 'BillingClient#queryPurchases(String)'; + test('handles error', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[] + }); + + expect( + iapAndroidPlatform.restorePurchases(), + throwsA( + isA() + .having((e) => e.source, 'source', kIAPSource) + .having((e) => e.code, 'code', kRestoredPurchaseErrorCode) + .having((e) => e.message, 'message', responseCode.toString()), + ), + ); + }); + + test('should store platform exception in the response', () async { + const String debugMessage = 'dummy message'; + + final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: queryMethodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchasesList': >[] + }, + additionalStepBeforeReturn: (_) { + throw PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}, + ); + }); + + expect( + iapAndroidPlatform.restorePurchases(), + throwsA( + isA() + .having((e) => e.code, 'code', 'error_code') + .having((e) => e.message, 'message', 'error_message') + .having((e) => e.details, 'details', {'info': 'error_info'}), + ), + ); + }); + + test('returns SkuDetailsResponseWrapper', () async { + Completer completer = Completer(); + Stream> stream = iapAndroidPlatform.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + } + }); + + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[ + buildPurchaseMap(dummyPurchase), + ] + }); + + // Since queryPastPurchases makes 2 platform method calls (one for each + // SkuType), the result will contain 2 dummyPurchase instances instead + // of 1. + await iapAndroidPlatform.restorePurchases(); + final List restoredPurchases = await completer.future; + + expect(restoredPurchases.length, 2); + restoredPurchases.forEach((element) { + GooglePlayPurchaseDetails purchase = + element as GooglePlayPurchaseDetails; + + expect(purchase.productID, dummyPurchase.sku); + expect(purchase.purchaseID, dummyPurchase.orderId); + expect(purchase.verificationData.localVerificationData, + dummyPurchase.originalJson); + expect(purchase.verificationData.serverVerificationData, + dummyPurchase.purchaseToken); + expect(purchase.verificationData.source, kIAPSource); + expect(purchase.transactionDate, dummyPurchase.purchaseTime.toString()); + expect(purchase.billingClientPurchase, dummyPurchase); + expect(purchase.status, PurchaseStatus.purchased); + }); + }); + }); + + group('make payment', () { + final String launchMethodName = + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)'; + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + + test('buy non consumable, throw argument exception', () { + final PurchaseParam purchaseParam = PurchaseParam( + productDetails: + GooglePlayProductDetails.fromSkuDetails(dummySkuDetails), + applicationUserName: "hashedAccountId"); + expect( + () => iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'On Android, the `purchaseParam` should always be of type `GooglePlayPurchaseParam`.', + ), + ), + ); + }); + + test('buy non consumable, serializes and deserializes data', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + final bool launchResult = await iapAndroidPlatform.buyNonConsumable( + purchaseParam: purchaseParam); + + PurchaseDetails result = await completer.future; + expect(launchResult, isTrue); + expect(result.purchaseID, 'orderID1'); + expect(result.status, PurchaseStatus.purchased); + expect(result.productID, dummySkuDetails.sku); + }); + + test('handles an error with an empty purchases list', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); + PurchaseDetails result = await completer.future; + + expect(result.error, isNotNull); + expect(result.error!.source, kIAPSource); + expect(result.status, PurchaseStatus.error); + expect(result.purchaseID, isEmpty); + }); + + test('buy consumable with auto consume, serializes and deserializes data', + () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete((purchaseToken)); + }); + + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + final bool launchResult = + await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); + + // Verify that the result has succeeded + GooglePlayPurchaseDetails result = await completer.future; + expect(launchResult, isTrue); + expect(result.billingClientPurchase, isNotNull); + expect(result.billingClientPurchase.purchaseToken, + await consumeCompleter.future); + expect(result.status, PurchaseStatus.purchased); + expect(result.error, isNull); + }); + + test('buyNonConsumable propagates failures to launch the billing flow', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final bool result = await iapAndroidPlatform.buyNonConsumable( + purchaseParam: GooglePlayPurchaseParam( + productDetails: + GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + + // Verify that the failure has been converted and returned + expect(result, isFalse); + }); + + test('buyConsumable propagates failures to launch the billing flow', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + + final bool result = await iapAndroidPlatform.buyConsumable( + purchaseParam: GooglePlayPurchaseParam( + productDetails: + GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + + // Verify that the failure has been converted and returned + expect(result, isFalse); + }); + + test('adds consumption failures to PurchaseDetails objects', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete(purchaseToken); + }); + + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); + + // Verify that the result has an error for the failed consumption + GooglePlayPurchaseDetails result = await completer.future; + expect(result.billingClientPurchase, isNotNull); + expect(result.billingClientPurchase.purchaseToken, + await consumeCompleter.future); + expect(result.status, PurchaseStatus.error); + expect(result.error, isNotNull); + expect(result.error!.code, kConsumptionFailedErrorCode); + }); + + test( + 'buy consumable without auto consume, consume api should not receive calls', + () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete((purchaseToken)); + }); + + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + consumeCompleter.complete(null); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyConsumable( + purchaseParam: purchaseParam, autoConsume: false); + expect(null, await consumeCompleter.future); + }); + }); + + group('consume purchases', () { + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + test('consume purchase async success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final BillingResultWrapper billingResultWrapper = + await iapAndroidPlatform.consumePurchase( + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase)); + + expect(billingResultWrapper, equals(expectedBillingResult)); + }); + }); + + group('complete purchase', () { + const String completeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('complete purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: completeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + PurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + Completer completer = Completer(); + purchaseDetails.status = PurchaseStatus.purchased; + if (purchaseDetails.pendingCompletePurchase) { + final BillingResultWrapper billingResultWrapper = + await iapAndroidPlatform.completePurchase(purchaseDetails); + expect(billingResultWrapper, equals(expectedBillingResult)); + completer.complete(billingResultWrapper); + } + expect(await completer.future, equals(expectedBillingResult)); + }); + }); +} From 20c7b0a399a597034d062a77fcfe38cd61693a6f Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 4 May 2021 18:13:08 +0200 Subject: [PATCH 03/14] Moved Android specific methods into addition class --- .../src/in_app_purchase_android_platform.dart | 215 ++++++++---------- ...pp_purchase_android_platform_addition.dart | 52 +++++ .../src/types/change_subscription_param.dart | 4 + .../src/types/in_app_purchase_exception.dart | 4 + ...rchase_android_platform_addition_test.dart | 64 ++++++ ...in_app_purchase_android_platform_test.dart | 25 +- 6 files changed, 222 insertions(+), 142 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index fba73669cf8f..30dbf1a47a43 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; @@ -18,7 +19,7 @@ const String kPurchaseErrorCode = 'purchase_error'; /// [IAPError.code] code used when a consuming a purchased item fails. const String kConsumptionFailedErrorCode = 'consume_purchase_failed'; -/// [IAPError.code] code used when a query for previouys transaction has failed. +/// [IAPError.code] code used when a query for previous transaction has failed. const String kRestoredPurchaseErrorCode = 'restore_transactions_failed'; /// Indicates store front is Google Play @@ -28,49 +29,32 @@ const String kIAPSource = 'google_play'; /// /// This translates various `BillingClient` calls and responses into the /// generic plugin API. -class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform - with WidgetsBindingObserver { - InAppPurchaseAndroidPlatform._() - : billingClient = - BillingClient((PurchasesResultWrapper resultWrapper) async { - _purchaseUpdatedController - .add(await _getPurchaseDetailsFromResult(resultWrapper)); - }) { - if (InAppPurchaseAndroidPlatform.enablePendingPurchase) { - billingClient.enablePendingPurchases(); - } +class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { + InAppPurchaseAndroidPlatform._() { + billingClient = BillingClient((PurchasesResultWrapper resultWrapper) async { + _purchaseUpdatedController + .add(await _getPurchaseDetailsFromResult(resultWrapper)); + }); + + androidPlatformAddition = InAppPurchaseAndroidPlatformAddition( + billingClient, + ); + InAppPurchasePlatformAddition.instance = androidPlatformAddition; _readyFuture = _connect(); - WidgetsBinding.instance!.addObserver(this); _purchaseUpdatedController = StreamController.broadcast(); - ; } /// Returns the singleton instance of the [InAppPurchaseAndroidPlatform]. static InAppPurchaseAndroidPlatform get instance => _getOrCreateInstance(); static InAppPurchaseAndroidPlatform? _instance; - /// Whether pending purchase is enabled. - /// - /// See also [enablePendingPurchases] for more on pending purchases. - static bool get enablePendingPurchase => _enablePendingPurchase; - static bool _enablePendingPurchase = false; - - /// Enable the [InAppPurchaseConnection] to handle pending purchases. - /// - /// This method is required to be called when initialize the application. - /// It is to acknowledge your application has been updated to support pending purchases. - /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) - /// for more details. - /// Failure to call this method before access [instance] will throw an exception. - static void enablePendingPurchases() { - _enablePendingPurchase = true; - } + static late StreamController> + _purchaseUpdatedController; + @override Stream> get purchaseStream => _purchaseUpdatedController.stream; - static late StreamController> - _purchaseUpdatedController; /// The [BillingClient] that's abstracted by [GooglePlayConnection]. /// @@ -78,6 +62,13 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform @visibleForTesting late final BillingClient billingClient; + /// The [InAppPurchaseAndroidPlatformAddition] containing Android specific + /// features which don't fit in the platform agnostic interface. + /// + /// This field should not be used out of test code. + @visibleForTesting + late final InAppPurchaseAndroidPlatformAddition androidPlatformAddition; + late Future _readyFuture; static Set _productIdsToConsume = Set(); @@ -87,6 +78,56 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform return billingClient.isReady(); } + @override + Future queryProductDetails( + Set identifiers) async { + List responses; + PlatformException? exception; + try { + responses = await Future.wait([ + billingClient.querySkuDetails( + skuType: SkuType.inapp, skusList: identifiers.toList()), + billingClient.querySkuDetails( + skuType: SkuType.subs, skusList: identifiers.toList()) + ]); + } on PlatformException catch (e) { + exception = e; + responses = [ + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []), + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []) + ]; + } + List productDetailsList = + responses.expand((SkuDetailsResponseWrapper response) { + return response.skuDetailsList; + }).map((SkuDetailsWrapper skuDetailWrapper) { + return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); + }).toList(); + + Set successIDS = productDetailsList + .map((ProductDetails productDetails) => productDetails.id) + .toSet(); + List notFoundIDS = identifiers.difference(successIDS).toList(); + return ProductDetailsResponse( + productDetails: productDetailsList, + notFoundIDs: notFoundIDS, + error: exception == null + ? null + : IAPError( + source: kIAPSource, + code: exception.code, + message: exception.message ?? '', + details: exception.details)); + } + @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { if (!(purchaseParam is GooglePlayPurchaseParam)) { @@ -141,20 +182,6 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform .acknowledgePurchase(purchase.verificationData.serverVerificationData); } - /// Mark that the user has consumed a product. - /// - /// You are responsible for consuming all consumable purchases once they are - /// delivered. The user won't be able to buy the same product again until the - /// purchase of the product is consumed. - Future consumePurchase(PurchaseDetails purchase) { - if (purchase.verificationData == null) { - throw ArgumentError( - 'consumePurchase unsuccessful. The `purchase.verificationData` is not valid'); - } - return billingClient - .consumeAsync(purchase.verificationData.serverVerificationData); - } - @override Future restorePurchases({ String? applicationUserName, @@ -213,61 +240,31 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform Future _connect() => billingClient.startConnection(onBillingServiceDisconnected: () {}); - /// Query the product detail list. - /// - /// This method only returns [ProductDetailsResponse]. - /// To get detailed Google Play sku list, use [BillingClient.querySkuDetails] - /// to get the [SkuDetailsResponseWrapper]. - Future queryProductDetails( - Set identifiers) async { - List responses; - PlatformException? exception; - try { - responses = await Future.wait([ - billingClient.querySkuDetails( - skuType: SkuType.inapp, skusList: identifiers.toList()), - billingClient.querySkuDetails( - skuType: SkuType.subs, skusList: identifiers.toList()) - ]); - } on PlatformException catch (e) { - exception = e; - responses = [ - // ignore: invalid_use_of_visible_for_testing_member - SkuDetailsResponseWrapper( - billingResult: BillingResultWrapper( - responseCode: BillingResponse.error, debugMessage: e.code), - skuDetailsList: []), - // ignore: invalid_use_of_visible_for_testing_member - SkuDetailsResponseWrapper( - billingResult: BillingResultWrapper( - responseCode: BillingResponse.error, debugMessage: e.code), - skuDetailsList: []) - ]; + Future _maybeAutoConsumePurchase( + PurchaseDetails purchaseDetails) async { + if (!(purchaseDetails.status == PurchaseStatus.purchased && + _productIdsToConsume.contains(purchaseDetails.productID))) { + return purchaseDetails; } - List productDetailsList = - responses.expand((SkuDetailsResponseWrapper response) { - return response.skuDetailsList; - }).map((SkuDetailsWrapper skuDetailWrapper) { - return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); - }).toList(); - Set successIDS = productDetailsList - .map((ProductDetails productDetails) => productDetails.id) - .toSet(); - List notFoundIDS = identifiers.difference(successIDS).toList(); - return ProductDetailsResponse( - productDetails: productDetailsList, - notFoundIDs: notFoundIDS, - error: exception == null - ? null - : IAPError( - source: kIAPSource, - code: exception.code, - message: exception.message ?? '', - details: exception.details)); + final BillingResultWrapper billingResult = + await androidPlatformAddition.consumePurchase(purchaseDetails); + final BillingResponse consumedResponse = billingResult.responseCode; + if (consumedResponse != BillingResponse.ok) { + purchaseDetails.status = PurchaseStatus.error; + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kConsumptionFailedErrorCode, + message: consumedResponse.toString(), + details: billingResult.debugMessage, + ); + } + _productIdsToConsume.remove(purchaseDetails.productID); + + return purchaseDetails; } - static Future> _getPurchaseDetailsFromResult( + Future> _getPurchaseDetailsFromResult( PurchasesResultWrapper resultWrapper) async { IAPError? error; if (resultWrapper.responseCode != BillingResponse.ok) { @@ -300,28 +297,4 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform ]; } } - - static Future _maybeAutoConsumePurchase( - PurchaseDetails purchaseDetails) async { - if (!(purchaseDetails.status == PurchaseStatus.purchased && - _productIdsToConsume.contains(purchaseDetails.productID))) { - return purchaseDetails; - } - - final BillingResultWrapper billingResult = - await instance.consumePurchase(purchaseDetails); - final BillingResponse consumedResponse = billingResult.responseCode; - if (consumedResponse != BillingResponse.ok) { - purchaseDetails.status = PurchaseStatus.error; - purchaseDetails.error = IAPError( - source: kIAPSource, - code: kConsumptionFailedErrorCode, - message: consumedResponse.toString(), - details: billingResult.debugMessage, - ); - } - _productIdsToConsume.remove(purchaseDetails.productID); - - return purchaseDetails; - } } 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 new file mode 100644 index 000000000000..069fbedb5077 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -0,0 +1,52 @@ +// 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:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../billing_client_wrappers.dart'; + +/// Contains InApp Purchase features that are only available on Android. +class InAppPurchaseAndroidPlatformAddition + extends InAppPurchasePlatformAddition { + /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied + /// `BillingClient` to provide Android specific features. + InAppPurchaseAndroidPlatformAddition(this._billingClient) { + if (InAppPurchaseAndroidPlatformAddition.enablePendingPurchase) { + _billingClient.enablePendingPurchases(); + } + } + + /// Whether pending purchase is enabled. + /// + /// See also [enablePendingPurchases] for more on pending purchases. + static bool get enablePendingPurchase => _enablePendingPurchase; + static bool _enablePendingPurchase = false; + + /// Enable the [InAppPurchaseConnection] to handle pending purchases. + /// + /// This method is required to be called when initialize the application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// Failure to call this method before access [instance] will throw an exception. + static void enablePendingPurchases() { + _enablePendingPurchase = true; + } + + final BillingClient _billingClient; + + /// Mark that the user has consumed a product. + /// + /// You are responsible for consuming all consumable purchases once they are + /// delivered. The user won't be able to buy the same product again until the + /// purchase of the product is consumed. + Future consumePurchase(PurchaseDetails purchase) { + if (purchase.verificationData == null) { + throw ArgumentError( + 'consumePurchase unsuccessful. The `purchase.verificationData` is not valid'); + } + return _billingClient + .consumeAsync(purchase.verificationData.serverVerificationData); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart index 270890366cd4..1099da3bf159 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart @@ -1,3 +1,7 @@ +// 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 '../../billing_client_wrappers.dart'; import 'types.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart index 796f5eaf343f..0a89a6e39a5e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart @@ -1,3 +1,7 @@ +// 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. + /// Thrown to indicate that an action failed while interacting with the /// in_app_purchase plugin. class InAppPurchaseException implements Exception { 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 new file mode 100644 index 000000000000..90b7154257f7 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -0,0 +1,64 @@ +// 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/widgets.dart' as widgets; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; + +import 'billing_client_wrappers/purchase_wrapper_test.dart'; +import 'stub_in_app_purchase_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late InAppPurchaseAndroidPlatformAddition iapAndroidPlatformAddition; + const String startConnectionCall = + 'BillingClient#startConnection(BillingClientStateListener)'; + const String endConnectionCall = 'BillingClient#endConnection()'; + + setUpAll(() { + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler); + }); + + setUp(() { + widgets.WidgetsFlutterBinding.ensureInitialized(); + + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: startConnectionCall, + value: buildBillingResultMap(expectedBillingResult)); + stubPlatform.addResponse(name: endConnectionCall, value: null); + iapAndroidPlatformAddition = + InAppPurchaseAndroidPlatformAddition(BillingClient((_) {})); + }); + + group('consume purchases', () { + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + test('consume purchase async success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final BillingResultWrapper billingResultWrapper = + await iapAndroidPlatformAddition.consumePurchase( + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase)); + + expect(billingResultWrapper, equals(expectedBillingResult)); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 2329556c49a5..634787da7dc1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -11,6 +11,7 @@ import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; @@ -33,6 +34,9 @@ void main() { setUp(() { widgets.WidgetsFlutterBinding.ensureInitialized(); + + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; final BillingResultWrapper expectedBillingResult = BillingResultWrapper( @@ -41,7 +45,6 @@ void main() { name: startConnectionCall, value: buildBillingResultMap(expectedBillingResult)); stubPlatform.addResponse(name: endConnectionCall, value: null); - InAppPurchaseAndroidPlatform.enablePendingPurchases(); iapAndroidPlatform = InAppPurchaseAndroidPlatform.instance; }); @@ -631,26 +634,6 @@ void main() { }); }); - group('consume purchases', () { - const String consumeMethodName = - 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; - test('consume purchase async success', () async { - final BillingResponse expectedCode = BillingResponse.ok; - const String debugMessage = 'dummy message'; - final BillingResultWrapper expectedBillingResult = BillingResultWrapper( - responseCode: expectedCode, debugMessage: debugMessage); - stubPlatform.addResponse( - name: consumeMethodName, - value: buildBillingResultMap(expectedBillingResult), - ); - final BillingResultWrapper billingResultWrapper = - await iapAndroidPlatform.consumePurchase( - GooglePlayPurchaseDetails.fromPurchase(dummyPurchase)); - - expect(billingResultWrapper, equals(expectedBillingResult)); - }); - }); - group('complete purchase', () { const String completeMethodName = 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; From ccbee7a0037a51dd113395d74080f8bbcfd4d227 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 4 May 2021 18:22:19 +0200 Subject: [PATCH 04/14] Added missing line end --- .../in_app_purchase_android/analysis_options.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 96ec04bdbae9..5aeb4e7c5e21 100644 --- a/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml @@ -1 +1 @@ -include: ../../../analysis_options_legacy.yaml \ No newline at end of file +include: ../../../analysis_options_legacy.yaml From 9b8db749131d7df3a136677f2f1a62fdcfb8172c Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 4 May 2021 18:32:47 +0200 Subject: [PATCH 05/14] Purchases status to restored after call restorePurchases --- .../lib/src/in_app_purchase_android_platform.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 30dbf1a47a43..b21cc1288a3d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -207,7 +207,12 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { responses.expand((PurchasesResultWrapper response) { return response.purchasesList; }).map((PurchaseWrapper purchaseWrapper) { - return GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + final GooglePlayPurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + + purchaseDetails.status = PurchaseStatus.restored; + + return purchaseDetails; }).toList(); if (errorMessage.isNotEmpty) { From 65fc01df6279109f064f1c666a2aaf0224a069a0 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 4 May 2021 18:38:32 +0200 Subject: [PATCH 06/14] Don't force GooglePlayPurchaseParam --- .../src/in_app_purchase_android_platform.dart | 16 +++++++------- ...in_app_purchase_android_platform_test.dart | 21 ++----------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index b21cc1288a3d..12c996e1a10b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -130,22 +130,20 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { - if (!(purchaseParam is GooglePlayPurchaseParam)) { - throw ArgumentError( - 'On Android, the `purchaseParam` should always be of type `GooglePlayPurchaseParam`.', - ); + ChangeSubscriptionParam? changeSubscriptionParam; + + if (purchaseParam is GooglePlayPurchaseParam) { + changeSubscriptionParam = purchaseParam.changeSubscriptionParam; } BillingResultWrapper billingResultWrapper = await billingClient.launchBillingFlow( sku: purchaseParam.productDetails.id, accountId: purchaseParam.applicationUserName, - oldSku: purchaseParam - .changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: purchaseParam.changeSubscriptionParam + oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam ?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: - purchaseParam.changeSubscriptionParam?.prorationMode); + prorationMode: changeSubscriptionParam?.prorationMode); return billingResultWrapper.responseCode == BillingResponse.ok; } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 634787da7dc1..1e0f07828f64 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -221,7 +221,7 @@ void main() { late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { - if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + if (purchaseDetailsList.first.status == PurchaseStatus.restored) { completer.complete(purchaseDetailsList); subscription.cancel(); } @@ -260,7 +260,7 @@ void main() { expect(purchase.verificationData.source, kIAPSource); expect(purchase.transactionDate, dummyPurchase.purchaseTime.toString()); expect(purchase.billingClientPurchase, dummyPurchase); - expect(purchase.status, PurchaseStatus.purchased); + expect(purchase.status, PurchaseStatus.restored); }); }); }); @@ -271,23 +271,6 @@ void main() { const String consumeMethodName = 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; - test('buy non consumable, throw argument exception', () { - final PurchaseParam purchaseParam = PurchaseParam( - productDetails: - GooglePlayProductDetails.fromSkuDetails(dummySkuDetails), - applicationUserName: "hashedAccountId"); - expect( - () => iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'On Android, the `purchaseParam` should always be of type `GooglePlayPurchaseParam`.', - ), - ), - ); - }); - test('buy non consumable, serializes and deserializes data', () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; From 68dac7f0aba2ae84f679cf4ad85048eeda9a6b89 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 4 May 2021 22:58:15 +0200 Subject: [PATCH 07/14] Implement registerPlatform method --- .../src/in_app_purchase_android_platform.dart | 43 ++++++------------- ...in_app_purchase_android_platform_test.dart | 10 +++-- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 12c996e1a10b..0b933900ee6a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -36,18 +36,20 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { .add(await _getPurchaseDetailsFromResult(resultWrapper)); }); - androidPlatformAddition = InAppPurchaseAndroidPlatformAddition( - billingClient, - ); - InAppPurchasePlatformAddition.instance = androidPlatformAddition; + // Register [InAppPurchaseAndroidPlatformAddition]. + InAppPurchasePlatformAddition.instance = + InAppPurchaseAndroidPlatformAddition(billingClient); _readyFuture = _connect(); _purchaseUpdatedController = StreamController.broadcast(); } - /// Returns the singleton instance of the [InAppPurchaseAndroidPlatform]. - static InAppPurchaseAndroidPlatform get instance => _getOrCreateInstance(); - static InAppPurchaseAndroidPlatform? _instance; + /// Registers this class as the default instance of [InAppPurchasePlatform]. + static void registerPlatform() { + // Register the platform instance with the plugin platform + // interface. + InAppPurchasePlatform.setInstance(InAppPurchaseAndroidPlatform._()); + } static late StreamController> _purchaseUpdatedController; @@ -62,13 +64,6 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { @visibleForTesting late final BillingClient billingClient; - /// The [InAppPurchaseAndroidPlatformAddition] containing Android specific - /// features which don't fit in the platform agnostic interface. - /// - /// This field should not be used out of test code. - @visibleForTesting - late final InAppPurchaseAndroidPlatformAddition androidPlatformAddition; - late Future _readyFuture; static Set _productIdsToConsume = Set(); @@ -224,22 +219,6 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { _purchaseUpdatedController.add(pastPurchases); } - /// Resets the connection instance. - /// - /// The next call to [instance] will create a new instance. Should only be - /// used in tests. - @visibleForTesting - static void reset() => _instance = null; - - static InAppPurchaseAndroidPlatform _getOrCreateInstance() { - if (_instance != null) { - return _instance!; - } - - _instance = InAppPurchaseAndroidPlatform._(); - return _instance!; - } - Future _connect() => billingClient.startConnection(onBillingServiceDisconnected: () {}); @@ -251,7 +230,9 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { } final BillingResultWrapper billingResult = - await androidPlatformAddition.consumePurchase(purchaseDetails); + await (InAppPurchasePlatformAddition.instance + as InAppPurchaseAndroidPlatformAddition) + .consumePurchase(purchaseDetails); final BillingResponse consumedResponse = billingResult.responseCode; if (consumedResponse != BillingResponse.ok) { purchaseDetails.status = PurchaseStatus.error; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 1e0f07828f64..fe7913c390cd 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -35,8 +35,6 @@ void main() { setUp(() { widgets.WidgetsFlutterBinding.ensureInitialized(); - InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); - const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; final BillingResultWrapper expectedBillingResult = BillingResultWrapper( @@ -45,16 +43,20 @@ void main() { name: startConnectionCall, value: buildBillingResultMap(expectedBillingResult)); stubPlatform.addResponse(name: endConnectionCall, value: null); - iapAndroidPlatform = InAppPurchaseAndroidPlatform.instance; + + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + InAppPurchaseAndroidPlatform.registerPlatform(); + iapAndroidPlatform = + InAppPurchasePlatform.instance as InAppPurchaseAndroidPlatform; }); tearDown(() { stubPlatform.reset(); - InAppPurchaseAndroidPlatform.reset(); }); group('connection management', () { test('connects on initialization', () { + //await iapAndroidPlatform.isAvailable(); expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); }); }); From 77bdca3528b0e398d3fc7e4372ec4421c9fe358c Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 5 May 2021 12:44:45 +0200 Subject: [PATCH 08/14] Added TODO comment to add example --- packages/in_app_purchase/in_app_purchase_android/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md index e6b089e6a243..41618fa15d7b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/README.md +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -31,4 +31,8 @@ dependencies: ... ``` +## 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 From a1503173102b53b5ac76ee07a9764604d9ec04dc Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 5 May 2021 12:51:31 +0200 Subject: [PATCH 09/14] Fixed mistake in API documentation --- .../lib/src/in_app_purchase_android_platform_addition.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 069fbedb5077..00e6eee94a03 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 @@ -6,7 +6,7 @@ import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_inte import '../billing_client_wrappers.dart'; -/// Contains InApp Purchase features that are only available on Android. +/// Contains InApp Purchase features that are only available on PlayStore. class InAppPurchaseAndroidPlatformAddition extends InAppPurchasePlatformAddition { /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied From 4418ec856c04ad9c5101075a16925be18db2dbb6 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 5 May 2021 19:25:40 +0200 Subject: [PATCH 10/14] Added additional assert to test enablePendingPurchase --- .../src/in_app_purchase_android_platform_addition.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 00e6eee94a03..e109c4e32ade 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 @@ -12,9 +12,12 @@ class InAppPurchaseAndroidPlatformAddition /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied /// `BillingClient` to provide Android specific features. InAppPurchaseAndroidPlatformAddition(this._billingClient) { - if (InAppPurchaseAndroidPlatformAddition.enablePendingPurchase) { - _billingClient.enablePendingPurchases(); - } + assert( + _enablePendingPurchase, + 'enablePendingPurchases() must be called when initializing the application and before you access the [InAppPurchase.instance].', + ); + + _billingClient.enablePendingPurchases(); } /// Whether pending purchase is enabled. From b65ed6908a605dca0e7484aaaf60c5695a730374 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 11 May 2021 09:25:59 +0200 Subject: [PATCH 11/14] Update Android implementation with latest platform_interface --- .../src/in_app_purchase_android_platform.dart | 3 +-- .../src/types/in_app_purchase_exception.dart | 27 ------------------- ...in_app_purchase_android_platform_test.dart | 1 - 3 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 0b933900ee6a..f71132a77ef3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -8,7 +8,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; -import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import '../billing_client_wrappers.dart'; @@ -48,7 +47,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { static void registerPlatform() { // Register the platform instance with the plugin platform // interface. - InAppPurchasePlatform.setInstance(InAppPurchaseAndroidPlatform._()); + InAppPurchasePlatform.instance = InAppPurchaseAndroidPlatform._(); } static late StreamController> diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart deleted file mode 100644 index 0a89a6e39a5e..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/in_app_purchase_exception.dart +++ /dev/null @@ -1,27 +0,0 @@ -// 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. - -/// Thrown to indicate that an action failed while interacting with the -/// in_app_purchase plugin. -class InAppPurchaseException implements Exception { - /// Creates a [InAppPurchaseException] with the specified source and error - /// [code] and optional [message]. - InAppPurchaseException({ - required this.source, - required this.code, - this.message, - }) : assert(code != null); - - /// An error code. - final String code; - - /// A human-readable error message, possibly null. - final String? message; - - /// Which source is the error on. - final String source; - - @override - String toString() => 'InAppPurchaseException($code, $message, $source)'; -} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index fe7913c390cd..01c73d6ed43e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -12,7 +12,6 @@ import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; import 'package:in_app_purchase_android/src/channel.dart'; import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; -import 'package:in_app_purchase_android/src/types/in_app_purchase_exception.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'billing_client_wrappers/purchase_wrapper_test.dart'; From 2f220ba44027255e1f189e1ab5dd9de6a61a5f4d Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Fri, 7 May 2021 16:00:38 +0200 Subject: [PATCH 12/14] Added working Android example --- .../in_app_purchase/example/README.md | 2 +- .../in_app_purchase_android/example/README.md | 58 +++ .../example/android/app/build.gradle | 115 +++++ .../gradle/wrapper/gradle-wrapper.properties | 5 + .../android/app/src/main/AndroidManifest.xml | 48 ++ .../EmbeddingV1Activity.java | 24 + .../EmbeddingV1ActivityTest.java | 18 + .../FlutterActivityTest.java | 17 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values/styles.xml | 8 + .../org.mockito.plugins.MockMaker | 1 + .../example/android/build.gradle | 29 ++ .../example/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + .../android/keystore.example.properties | 7 + .../example/android/settings.gradle | 15 + .../in_app_purchase_test.dart | 21 + .../example/lib/consumable_store.dart | 51 ++ .../example/lib/main.dart | 436 ++++++++++++++++++ .../example/pubspec.yaml | 33 ++ .../test_driver/test/integration_test.dart | 18 + .../lib/in_app_purchase_android.dart | 1 + .../in_app_purchase_android/pubspec.yaml | 2 + 28 files changed, 930 insertions(+), 1 deletion(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/README.md create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/values/styles.xml create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/gradle.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/settings.gradle create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/lib/consumable_store.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/test_driver/test/integration_test.dart diff --git a/packages/in_app_purchase/in_app_purchase/example/README.md b/packages/in_app_purchase/in_app_purchase/example/README.md index dc59c6e92ba7..65b5dad6214a 100644 --- a/packages/in_app_purchase/in_app_purchase/example/README.md +++ b/packages/in_app_purchase/in_app_purchase/example/README.md @@ -32,7 +32,7 @@ below. - `subscription_silver`: A lower level subscription. - `subscription_gold`: A higher level subscription. - Make sure that all of the products are set to `ACTIVE`. + Make sure that all the products are set to `ACTIVE`. 4. Update `APP_ID` in `example/android/app/build.gradle` to match your package ID in the PDC. diff --git a/packages/in_app_purchase/in_app_purchase_android/example/README.md b/packages/in_app_purchase/in_app_purchase_android/example/README.md new file mode 100644 index 000000000000..255e838e5b93 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/README.md @@ -0,0 +1,58 @@ +# In App Purchase Example + +Demonstrates how to use the In App Purchase Android (IAP) Plugin. + +## Getting Started + +### Preparation + +There's a significant amount of setup required for testing in-app purchases +successfully, including registering new app IDs and store entries to use for +testing in the Play Developer Console. Google Play requires developers to +configure an app with in-app items for purchase to call their in-app-purchase +APIs. The Google Play Store has extensive documentation on how to do this, and +we've also included a high level guide below. + +* [Google Play Billing Overview](https://developer.android.com/google/play/billing/billing_overview) + +### Android + +1. Create a new app in the [Play Developer + Console](https://play.google.com/apps/publish/) (PDC). + +2. Sign up for a merchant's account in the PDC. + +3. Create IAPs in the PDC available for purchase in the app. The example assumes + the following SKU IDs exist: + + - `consumable`: A managed product. + - `upgrade`: A managed product. + - `subscription_silver`: A lower level subscription. + - `subscription_gold`: A higher level subscription. + + Make sure that all of the products are set to `ACTIVE`. + +4. Update `APP_ID` in `example/android/app/build.gradle` to match your package + ID in the PDC. + +5. Create an `example/android/keystore.properties` file with all your signing + information. `keystore.example.properties` exists as an example to follow. + It's impossible to use any of the `BillingClient` APIs from an unsigned APK. + See + [here](https://developer.android.com/studio/publish/app-signing#secure-shared-keystore) + and [here](https://developer.android.com/studio/publish/app-signing#sign-apk) + for more information. + +6. Build a signed apk. `flutter build apk` will work for this, the gradle files + in this project have been configured to sign even debug builds. + +7. Upload the signed APK from step 6 to the PDC, and publish that to the alpha + test channel. Add your test account as an approved tester. The + `BillingClient` APIs won't work unless the app has been fully published to + the alpha channel and is being used by an authorized test account. See + [here](https://support.google.com/googleplay/android-developer/answer/3131213) + for more info. + +8. Sign in to the test device with the test account from step #7. Then use + `flutter run` to install the app to the device and test like normal. + \ No newline at end of file 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 new file mode 100644 index 000000000000..373d4a87c6e6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/build.gradle @@ -0,0 +1,115 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +// Load the build signing secrets from a local `keystore.properties` file. +// TODO(YOU): Create release keys and a `keystore.properties` file. See +// `example/README.md` for more info and `keystore.example.properties` for an +// example. +def keystorePropertiesFile = rootProject.file("keystore.properties") +def keystoreProperties = new Properties() +def configured = true +try { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} catch (IOException e) { + configured = false + logger.error('Release signing information not found.') +} + +project.ext { + // TODO(YOU): Create release keys and a `keystore.properties` file. See + // `example/README.md` for more info and `keystore.example.properties` for an + // example. + APP_ID = configured ? keystoreProperties['appId'] : "io.flutter.plugins.inapppurchaseexample.DEFAULT_DO_NOT_USE" + KEYSTORE_STORE_FILE = configured ? rootProject.file(keystoreProperties['storeFile']) : null + KEYSTORE_STORE_PASSWORD = keystoreProperties['storePassword'] + KEYSTORE_KEY_ALIAS = keystoreProperties['keyAlias'] + KEYSTORE_KEY_PASSWORD = keystoreProperties['keyPassword'] + VERSION_CODE = configured ? keystoreProperties['versionCode'].toInteger() : 1 + VERSION_NAME = configured ? keystoreProperties['versionName'] : "0.0.1" +} + +if (project.APP_ID == "io.flutter.plugins.inapppurchaseexample.DEFAULT_DO_NOT_USE") { + configured = false + logger.error('Unique package name not set, defaulting to "io.flutter.plugins.inapppurchaseexample.DEFAULT_DO_NOT_USE".') +} + +// Log a final error message if we're unable to create a release key signed +// build for an app configured in the Play Developer Console. Apks built in this +// condition won't be able to call any of the BillingClient APIs. +if (!configured) { + logger.error('The app could not be configured for release signing. In app purchases will not be testable. See `example/README.md` for more info and instructions.') +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + signingConfigs { + release { + storeFile project.KEYSTORE_STORE_FILE + storePassword project.KEYSTORE_STORE_PASSWORD + keyAlias project.KEYSTORE_KEY_ALIAS + keyPassword project.KEYSTORE_KEY_PASSWORD + } + } + + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + applicationId project.APP_ID + minSdkVersion 16 + targetSdkVersion 29 + versionCode project.VERSION_CODE + versionName project.VERSION_NAME + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // Google Play Billing APIs only work with apps signed for production. + debug { + if (configured) { + signingConfig signingConfigs.release + } else { + signingConfig signingConfigs.debug + } + } + release { + if (configured) { + signingConfig signingConfigs.release + } else { + signingConfig signingConfigs.debug + } + } + } + + testOptions { + unitTests.returnDefaultValues = true + } +} + +flutter { + source '../..' +} + +dependencies { + implementation 'com.android.billingclient:billing:3.0.2' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-core:3.6.0' + testImplementation 'org.json:json:20180813' + 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/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/example/android/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..9a4163a4f5ee --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists 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 new file mode 100644 index 000000000000..a17382b97d83 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java new file mode 100644 index 000000000000..c74ad9447e81 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java @@ -0,0 +1,24 @@ +// 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.inapppurchaseexample; + +import android.os.Bundle; +import dev.flutter.plugins.integration_test.IntegrationTestPlugin; +import io.flutter.plugins.inapppurchase.InAppPurchasePlugin; +import io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin; + +@SuppressWarnings("deprecation") +public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + IntegrationTestPlugin.registerWith( + registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); + SharedPreferencesPlugin.registerWith( + registrarFor("io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")); + InAppPurchasePlugin.registerWith( + registrarFor("io.flutter.plugins.inapppurchase.InAppPurchasePlugin")); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java new file mode 100644 index 000000000000..55d97a658ec0 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java @@ -0,0 +1,18 @@ +// 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.inapppurchaseexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@RunWith(FlutterTestRunner.class) +@SuppressWarnings("deprecation") +public class EmbeddingV1ActivityTest { + @Rule + public ActivityTestRule rule = + new ActivityTestRule<>(EmbeddingV1Activity.class); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java new file mode 100644 index 000000000000..a60599573d57 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java @@ -0,0 +1,17 @@ +// 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.inapppurchaseexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@RunWith(FlutterTestRunner.class) +public class FlutterActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/drawable/launch_background.xml b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/values/styles.xml b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..00fa4417cfbe --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000000..1f0955d450f0 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle new file mode 100644 index 000000000000..541636cc492a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/gradle.properties b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle.properties new file mode 100644 index 000000000000..38c8d4544ff1 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2819f022f1fd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 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 new file mode 100644 index 000000000000..ccbbb3653569 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/keystore.example.properties @@ -0,0 +1,7 @@ +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 diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/settings.gradle b/packages/in_app_purchase/in_app_purchase_android/example/android/settings.gradle new file mode 100644 index 000000000000..5a2f14fb18f6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart new file mode 100644 index 000000000000..2d5ead3af1be --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart @@ -0,0 +1,21 @@ +// 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. + +// @dart = 2.9 +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Can create InAppPurchaseAndroid instance', + (WidgetTester tester) async { + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + InAppPurchaseAndroidPlatform.registerPlatform(); + final InAppPurchasePlatform androidPlatform = InAppPurchasePlatform.instance; + expect(androidPlatform, isNotNull); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/consumable_store.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/consumable_store.dart new file mode 100644 index 000000000000..4d10a50e1ee8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/consumable_store.dart @@ -0,0 +1,51 @@ +// 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 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A store of consumable items. +/// +/// This is a development prototype tha stores consumables in the shared +/// preferences. Do not use this in real world apps. +class ConsumableStore { + static const String _kPrefKey = 'consumables'; + static Future _writes = Future.value(); + + /// Adds a consumable with ID `id` to the store. + /// + /// The consumable is only added after the returned Future is complete. + static Future save(String id) { + _writes = _writes.then((void _) => _doSave(id)); + return _writes; + } + + /// Consumes a consumable with ID `id` from the store. + /// + /// The consumable was only consumed after the returned Future is complete. + static Future consume(String id) { + _writes = _writes.then((void _) => _doConsume(id)); + return _writes; + } + + /// Returns the list of consumables from the store. + static Future> load() async { + return (await SharedPreferences.getInstance()).getStringList(_kPrefKey) ?? + []; + } + + static Future _doSave(String id) async { + List cached = await load(); + SharedPreferences prefs = await SharedPreferences.getInstance(); + cached.add(id); + await prefs.setStringList(_kPrefKey, cached); + } + + static Future _doConsume(String id) async { + List cached = await load(); + SharedPreferences prefs = await SharedPreferences.getInstance(); + cached.remove(id); + await prefs.setStringList(_kPrefKey, cached); + } +} 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 new file mode 100644 index 000000000000..abbbe9b43ede --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -0,0 +1,436 @@ +// 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 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import 'consumable_store.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + // For play billing library 2.0 on Android, it is mandatory to call + // [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases) + // as part of initializing the app. + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + + // When using the Android plugin directly it is mandatory to register + // the plugin as default instance as part of initializing the app. + InAppPurchaseAndroidPlatform.registerPlatform(); + + runApp(_MyApp()); +} + +const bool _kAutoConsume = true; + +const String _kConsumableId = 'consumable'; +const String _kUpgradeId = 'upgrade'; +const String _kSilverSubscriptionId = 'subscription_silver'; +const String _kGoldSubscriptionId = 'subscription_gold'; +const List _kProductIds = [ + _kConsumableId, + _kUpgradeId, + _kSilverSubscriptionId, + _kGoldSubscriptionId, +]; + +class _MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State<_MyApp> { + final InAppPurchasePlatform _inAppPurchasePlatform = + InAppPurchasePlatform.instance!; + late StreamSubscription> _subscription; + List _notFoundIds = []; + List _products = []; + List _purchases = []; + List _consumables = []; + bool _isAvailable = false; + bool _purchasePending = false; + bool _loading = true; + String? _queryProductError; + + @override + void initState() { + final Stream> purchaseUpdated = + _inAppPurchasePlatform.purchaseStream; + _subscription = purchaseUpdated.listen((purchaseDetailsList) { + _listenToPurchaseUpdated(purchaseDetailsList); + }, onDone: () { + _subscription.cancel(); + }, onError: (error) { + // handle error here. + }); + initStoreInfo(); + super.initState(); + } + + Future initStoreInfo() async { + final bool isAvailable = await _inAppPurchasePlatform.isAvailable(); + if (!isAvailable) { + setState(() { + _isAvailable = isAvailable; + _products = []; + _purchases = []; + _notFoundIds = []; + _consumables = []; + _purchasePending = false; + _loading = false; + }); + return; + } + + ProductDetailsResponse productDetailResponse = + await _inAppPurchasePlatform.queryProductDetails(_kProductIds.toSet()); + if (productDetailResponse.error != null) { + setState(() { + _queryProductError = productDetailResponse.error!.message; + _isAvailable = isAvailable; + _products = productDetailResponse.productDetails; + _purchases = []; + _notFoundIds = productDetailResponse.notFoundIDs; + _consumables = []; + _purchasePending = false; + _loading = false; + }); + return; + } + + if (productDetailResponse.productDetails.isEmpty) { + setState(() { + _queryProductError = null; + _isAvailable = isAvailable; + _products = productDetailResponse.productDetails; + _purchases = []; + _notFoundIds = productDetailResponse.notFoundIDs; + _consumables = []; + _purchasePending = false; + _loading = false; + }); + return; + } + + await _inAppPurchasePlatform.restorePurchases(); + + List consumables = await ConsumableStore.load(); + setState(() { + _isAvailable = isAvailable; + _products = productDetailResponse.productDetails; + _notFoundIds = productDetailResponse.notFoundIDs; + _consumables = consumables; + _purchasePending = false; + _loading = false; + }); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + List stack = []; + if (_queryProductError == null) { + stack.add( + ListView( + children: [ + _buildConnectionCheckTile(), + _buildProductList(), + _buildConsumableBox(), + ], + ), + ); + } else { + stack.add(Center( + child: Text(_queryProductError!), + )); + } + if (_purchasePending) { + stack.add( + Stack( + children: [ + Opacity( + opacity: 0.3, + child: const ModalBarrier(dismissible: false, color: Colors.grey), + ), + Center( + child: CircularProgressIndicator(), + ), + ], + ), + ); + } + + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('IAP Example'), + ), + body: Stack( + children: stack, + ), + ), + ); + } + + Card _buildConnectionCheckTile() { + if (_loading) { + return Card(child: ListTile(title: const Text('Trying to connect...'))); + } + final Widget storeHeader = ListTile( + leading: Icon(_isAvailable ? Icons.check : Icons.block, + color: _isAvailable ? Colors.green : ThemeData.light().errorColor), + title: Text( + 'The store is ' + (_isAvailable ? 'available' : 'unavailable') + '.'), + ); + final List children = [storeHeader]; + + if (!_isAvailable) { + children.addAll([ + Divider(), + ListTile( + title: Text('Not connected', + style: TextStyle(color: ThemeData.light().errorColor)), + subtitle: const Text( + 'Unable to connect to the payments processor. Has this app been configured correctly? See the example README for instructions.'), + ), + ]); + } + return Card(child: Column(children: children)); + } + + Card _buildProductList() { + if (_loading) { + return Card( + child: (ListTile( + leading: CircularProgressIndicator(), + title: Text('Fetching products...')))); + } + if (!_isAvailable) { + return Card(); + } + final ListTile productHeader = ListTile(title: Text('Products for Sale')); + List productList = []; + if (_notFoundIds.isNotEmpty) { + productList.add(ListTile( + title: Text('[${_notFoundIds.join(", ")}] not found', + style: TextStyle(color: ThemeData.light().errorColor)), + subtitle: Text( + 'This app needs special configuration to run. Please see example/README.md for instructions.'))); + } + + // 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. + Map purchases = + Map.fromEntries(_purchases.map((PurchaseDetails purchase) { + if (purchase.pendingCompletePurchase) { + _inAppPurchasePlatform.completePurchase(purchase); + } + return MapEntry(purchase.productID, purchase); + })); + productList.addAll(_products.map( + (ProductDetails productDetails) { + PurchaseDetails? previousPurchase = purchases[productDetails.id]; + return ListTile( + title: Text( + productDetails.title, + ), + subtitle: Text( + productDetails.description, + ), + trailing: previousPurchase != null + ? Icon(Icons.check) + : TextButton( + child: Text(productDetails.price), + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + primary: Colors.white, + ), + onPressed: () { + // NOTE: If you are making a subscription purchase/upgrade/downgrade, we recommend you to + // verify the latest status of you your subscription by using server side receipt validation + // and update the UI accordingly. The subscription purchase status shown + // inside the app may not be accurate. + final oldSubscription = _getOldSubscription( + productDetails as GooglePlayProductDetails, + purchases); + GooglePlayPurchaseParam purchaseParam = + GooglePlayPurchaseParam( + productDetails: productDetails, + applicationUserName: null, + changeSubscriptionParam: oldSubscription != null + ? ChangeSubscriptionParam( + oldPurchaseDetails: oldSubscription, + prorationMode: ProrationMode + .immediateWithTimeProration) + : null); + if (productDetails.id == _kConsumableId) { + _inAppPurchasePlatform.buyConsumable( + purchaseParam: purchaseParam, + autoConsume: _kAutoConsume || Platform.isIOS); + } else { + _inAppPurchasePlatform.buyNonConsumable( + purchaseParam: purchaseParam); + } + }, + )); + }, + )); + + return Card( + child: + Column(children: [productHeader, Divider()] + productList)); + } + + Card _buildConsumableBox() { + if (_loading) { + return Card( + child: (ListTile( + leading: CircularProgressIndicator(), + title: Text('Fetching consumables...')))); + } + if (!_isAvailable || _notFoundIds.contains(_kConsumableId)) { + return Card(); + } + final ListTile consumableHeader = + ListTile(title: Text('Purchased consumables')); + final List tokens = _consumables.map((String id) { + return GridTile( + child: IconButton( + icon: Icon( + Icons.stars, + size: 42.0, + color: Colors.orange, + ), + splashColor: Colors.yellowAccent, + onPressed: () => consume(id), + ), + ); + }).toList(); + return Card( + child: Column(children: [ + consumableHeader, + Divider(), + GridView.count( + crossAxisCount: 5, + children: tokens, + shrinkWrap: true, + padding: EdgeInsets.all(16.0), + ) + ])); + } + + Future consume(String id) async { + await ConsumableStore.consume(id); + final List consumables = await ConsumableStore.load(); + setState(() { + _consumables = consumables; + }); + } + + void showPendingUI() { + setState(() { + _purchasePending = true; + }); + } + + void deliverProduct(PurchaseDetails purchaseDetails) async { + // IMPORTANT!! Always verify purchase details before delivering the product. + if (purchaseDetails.productID == _kConsumableId) { + await ConsumableStore.save(purchaseDetails.purchaseID!); + List consumables = await ConsumableStore.load(); + setState(() { + _purchasePending = false; + _consumables = consumables; + }); + } else { + setState(() { + _purchases.add(purchaseDetails); + _purchasePending = false; + }); + } + } + + void handleError(IAPError error) { + setState(() { + _purchasePending = false; + }); + } + + Future _verifyPurchase(PurchaseDetails purchaseDetails) { + // IMPORTANT!! Always verify a purchase before delivering the product. + // For the purpose of an example, we directly return true. + return Future.value(true); + } + + void _handleInvalidPurchase(PurchaseDetails purchaseDetails) { + // handle invalid purchase here if _verifyPurchase` failed. + } + + void _listenToPurchaseUpdated(List purchaseDetailsList) { + purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async { + if (purchaseDetails.status == PurchaseStatus.pending) { + showPendingUI(); + } else { + if (purchaseDetails.status == PurchaseStatus.error) { + handleError(purchaseDetails.error!); + } else if (purchaseDetails.status == PurchaseStatus.purchased || + purchaseDetails.status == PurchaseStatus.restored) { + bool valid = await _verifyPurchase(purchaseDetails); + if (valid) { + deliverProduct(purchaseDetails); + } else { + _handleInvalidPurchase(purchaseDetails); + return; + } + } + + if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance + as InAppPurchaseAndroidPlatformAddition; + + await addition.consumePurchase(purchaseDetails); + } + + if (purchaseDetails.pendingCompletePurchase) { + await _inAppPurchasePlatform.completePurchase(purchaseDetails); + } + } + }); + } + + GooglePlayPurchaseDetails? _getOldSubscription( + GooglePlayProductDetails productDetails, + Map purchases) { + // This is just to demonstrate a subscription upgrade or downgrade. + // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'. + // The 'subscription_silver' subscription can be upgraded to 'subscription_gold' and + // the 'subscription_gold' subscription can be downgraded to 'subscription_silver'. + // Please remember to replace the logic of finding the old subscription Id as per your app. + // The old subscription is only required on Android since Apple handles this internally + // by using the subscription group feature in iTunesConnect. + GooglePlayPurchaseDetails? oldSubscription; + if (productDetails.id == _kSilverSubscriptionId && + purchases[_kGoldSubscriptionId] != null) { + oldSubscription = + purchases[_kGoldSubscriptionId] as GooglePlayPurchaseDetails; + } else if (productDetails.id == _kGoldSubscriptionId && + purchases[_kSilverSubscriptionId] != null) { + oldSubscription = + purchases[_kSilverSubscriptionId] as GooglePlayPurchaseDetails; + } + return oldSubscription; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml new file mode 100644 index 000000000000..74c4fb506724 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: in_app_purchase_android_example +description: Demonstrates how to use the in_app_purchase_android plugin. +publish_to: none + +dependencies: + flutter: + sdk: flutter + shared_preferences: ^2.0.0-nullsafety.1 + in_app_purchase_android: + # When depending on this package from a real application you should use: + # in_app_purchase_android: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published + in_app_purchase_platform_interface: + path: ../../in_app_purchase_platform_interface + +dev_dependencies: + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.9.1+hotfix.2" diff --git a/packages/in_app_purchase/in_app_purchase_android/example/test_driver/test/integration_test.dart b/packages/in_app_purchase/in_app_purchase_android/example/test_driver/test/integration_test.dart new file mode 100644 index 000000000000..4c4c006068b8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/test_driver/test/integration_test.dart @@ -0,0 +1,18 @@ +// 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. + +// @dart = 2.9 +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_driver/flutter_driver.dart'; + +Future main() async { + final FlutterDriver driver = await FlutterDriver.connect(); + final String data = + await driver.requestData(null, timeout: const Duration(minutes: 1)); + await driver.close(); + final Map result = jsonDecode(data); + exit(result['result'] == 'true' ? 0 : 1); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart index 9d74a562b272..71e4e7a698fb 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart @@ -3,4 +3,5 @@ // found in the LICENSE file. export 'src/in_app_purchase_android_platform.dart'; +export 'src/in_app_purchase_android_platform_addition.dart'; export 'src/types/types.dart'; 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 41cb3e87e185..4306d465eec5 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: flutter: sdk: flutter + json_annotation: ^4.0.0 + collection: ^1.15.0 meta: ^1.3.0 test: ^1.16.0 From 1a8d47db16b4a4687990bc1e3b894e40857afba8 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Fri, 7 May 2021 18:10:52 +0200 Subject: [PATCH 13/14] Fix formatting --- .../example/integration_test/in_app_purchase_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart index 2d5ead3af1be..b6fdf1d7fabc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart @@ -15,7 +15,8 @@ void main() { (WidgetTester tester) async { InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); InAppPurchaseAndroidPlatform.registerPlatform(); - final InAppPurchasePlatform androidPlatform = InAppPurchasePlatform.instance; + final InAppPurchasePlatform androidPlatform = + InAppPurchasePlatform.instance; expect(androidPlatform, isNotNull); }); } From 7cf2b2ed888a22f307678a77036bbae5eeae42bc Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 12 May 2021 07:38:04 +0200 Subject: [PATCH 14/14] Update example dependencies --- .../in_app_purchase_android/example/pubspec.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml index 74c4fb506724..29da00ef31ec 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml @@ -5,7 +5,7 @@ publish_to: none dependencies: flutter: sdk: flutter - shared_preferences: ^2.0.0-nullsafety.1 + shared_preferences: ^2.0.0 in_app_purchase_android: # When depending on this package from a real application you should use: # in_app_purchase_android: ^x.y.z @@ -14,9 +14,7 @@ dependencies: # the parent directory to use the current plugin's version. path: ../ - # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published - in_app_purchase_platform_interface: - path: ../../in_app_purchase_platform_interface + in_app_purchase_platform_interface: ^1.0.0 dev_dependencies: flutter_driver: