[in_app_purchase] Android example using in_app_purchase_android package (#3861)
Adds the example app for the in_app_purchase_android platform implementation.
Adds the Android implementation for issue flutter/flutter#81695
NOTE: this PR builds on top of the "[in_app_purchase] Federated Android implementation" pull request. If have split in into a separate PR so it would be easier to review.
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 dc59c6e..65b5dad 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 @@
- `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 0000000..255e838
--- /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 0000000..373d4a8
--- /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 0000000..9a4163a
--- /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 0000000..a17382b
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,48 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="io.flutter.plugins.inapppurchaseexample">
+
+ <!-- The INTERNET permission is required for development. Specifically,
+ flutter needs it to communicate with the running application
+ to allow setting breakpoints, to provide hot reload, etc.
+ -->
+ <uses-permission android:name="android.permission.INTERNET"/>
+
+ <!-- io.flutter.app.FlutterApplication is an android.app.Application that
+ calls FlutterMain.startInitialization(this); in its onCreate method.
+ In most cases you can leave this as-is, but you if you want to provide
+ additional functionality it is fine to subclass or reimplement
+ FlutterApplication and put your custom class here. -->
+ <application
+ android:name="io.flutter.app.FlutterApplication"
+ android:label="in_app_purchase_example"
+ android:icon="@mipmap/ic_launcher">
+ <activity
+ android:name=".EmbeddingV1Activity"
+ android:launchMode="singleTop"
+ android:theme="@style/LaunchTheme"
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density"
+ android:hardwareAccelerated="true"
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
+ <!-- This keeps the window background of the activity showing
+ until Flutter renders its first frame. It can be removed if
+ there is no splash screen (such as the default splash screen
+ defined in @style/LaunchTheme). -->
+ <meta-data
+ android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
+ android:value="true" />
+ </activity>
+ <activity
+ android:name="io.flutter.embedding.android.FlutterActivity"
+ android:theme="@style/LaunchTheme"
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density"
+ android:hardwareAccelerated="true"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ <meta-data android:name="flutterEmbedding" android:value="2"/>
+ </application>
+</manifest>
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 0000000..c74ad94
--- /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 0000000..55d97a6
--- /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<EmbeddingV1Activity> 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 0000000..a605995
--- /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<FlutterActivity> 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 0000000..304732f
--- /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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@android:color/white" />
+
+ <!-- You can insert your own image assets here -->
+ <!-- <item>
+ <bitmap
+ android:gravity="center"
+ android:src="@mipmap/launch_image" />
+ </item> -->
+</layer-list>
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 0000000..db77bb4
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
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 0000000..17987b7
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
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 0000000..d5f1c8d
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
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 0000000..4d6372e
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
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 0000000..00fa441
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+ <!-- Show a splash screen on the activity. Automatically removed when
+ Flutter draws its first frame -->
+ <item name="android:windowBackground">@drawable/launch_background</item>
+ </style>
+</resources>
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 0000000..1f0955d
--- /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 0000000..541636c
--- /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 0000000..38c8d45
--- /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 0000000..2819f02
--- /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 0000000..ccbbb36
--- /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 0000000..5a2f14f
--- /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 0000000..b6fdf1d
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart
@@ -0,0 +1,22 @@
+// 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 0000000..4d10a50
--- /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<void> _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<void> 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<void> consume(String id) {
+ _writes = _writes.then((void _) => _doConsume(id));
+ return _writes;
+ }
+
+ /// Returns the list of consumables from the store.
+ static Future<List<String>> load() async {
+ return (await SharedPreferences.getInstance()).getStringList(_kPrefKey) ??
+ [];
+ }
+
+ static Future<void> _doSave(String id) async {
+ List<String> cached = await load();
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ cached.add(id);
+ await prefs.setStringList(_kPrefKey, cached);
+ }
+
+ static Future<void> _doConsume(String id) async {
+ List<String> 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 0000000..c5726c4
--- /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<String> _kProductIds = <String>[
+ _kConsumableId,
+ _kUpgradeId,
+ _kSilverSubscriptionId,
+ _kGoldSubscriptionId,
+];
+
+class _MyApp extends StatefulWidget {
+ @override
+ _MyAppState createState() => _MyAppState();
+}
+
+class _MyAppState extends State<_MyApp> {
+ final InAppPurchasePlatform _inAppPurchasePlatform =
+ InAppPurchasePlatform.instance;
+ late StreamSubscription<List<PurchaseDetails>> _subscription;
+ List<String> _notFoundIds = [];
+ List<ProductDetails> _products = [];
+ List<PurchaseDetails> _purchases = [];
+ List<String> _consumables = [];
+ bool _isAvailable = false;
+ bool _purchasePending = false;
+ bool _loading = true;
+ String? _queryProductError;
+
+ @override
+ void initState() {
+ final Stream<List<PurchaseDetails>> purchaseUpdated =
+ _inAppPurchasePlatform.purchaseStream;
+ _subscription = purchaseUpdated.listen((purchaseDetailsList) {
+ _listenToPurchaseUpdated(purchaseDetailsList);
+ }, onDone: () {
+ _subscription.cancel();
+ }, onError: (error) {
+ // handle error here.
+ });
+ initStoreInfo();
+ super.initState();
+ }
+
+ Future<void> 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<String> 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<Widget> 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<Widget> children = <Widget>[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<ListTile> productList = <ListTile>[];
+ 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<String, PurchaseDetails> purchases =
+ Map.fromEntries(_purchases.map((PurchaseDetails purchase) {
+ if (purchase.pendingCompletePurchase) {
+ _inAppPurchasePlatform.completePurchase(purchase);
+ }
+ return MapEntry<String, PurchaseDetails>(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: <Widget>[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<Widget> 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: <Widget>[
+ consumableHeader,
+ Divider(),
+ GridView.count(
+ crossAxisCount: 5,
+ children: tokens,
+ shrinkWrap: true,
+ padding: EdgeInsets.all(16.0),
+ )
+ ]));
+ }
+
+ Future<void> consume(String id) async {
+ await ConsumableStore.consume(id);
+ final List<String> 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<String> consumables = await ConsumableStore.load();
+ setState(() {
+ _purchasePending = false;
+ _consumables = consumables;
+ });
+ } else {
+ setState(() {
+ _purchases.add(purchaseDetails);
+ _purchasePending = false;
+ });
+ }
+ }
+
+ void handleError(IAPError error) {
+ setState(() {
+ _purchasePending = false;
+ });
+ }
+
+ Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
+ // IMPORTANT!! Always verify a purchase before delivering the product.
+ // For the purpose of an example, we directly return true.
+ return Future<bool>.value(true);
+ }
+
+ void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
+ // handle invalid purchase here if _verifyPurchase` failed.
+ }
+
+ void _listenToPurchaseUpdated(List<PurchaseDetails> 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<String, PurchaseDetails> 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 0000000..29da00e
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_android/example/pubspec.yaml
@@ -0,0 +1,31 @@
+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
+ 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: ../
+
+ in_app_purchase_platform_interface: ^1.0.0
+
+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 0000000..4c4c006
--- /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<void> 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<String, dynamic> 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 9d74a56..71e4e7a 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 b492c7c..19c5723 100644
--- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml
+++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml
@@ -29,4 +29,4 @@
environment:
sdk: ">=2.12.0 <3.0.0"
- flutter: ">=1.20.0"
\ No newline at end of file
+ flutter: ">=1.20.0"