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 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 000000000000..17987b79bb8a Binary files /dev/null and b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 000000000000..09d4391482be Binary files /dev/null and b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 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..b6fdf1d7fabc --- /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 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..c5726c4ade76 --- /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..29da00ef31ec --- /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 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 b492c7cca877..19c5723d12ce 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 @@ dev_dependencies: environment: sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.20.0" \ No newline at end of file + flutter: ">=1.20.0"