From 0a24384e74dbc79d8c3c6560cbf48f855753cae2 Mon Sep 17 00:00:00 2001
From: tomasz-keepsafe <40766236+tomasz-keepsafe@users.noreply.github.com>
Date: Thu, 29 Aug 2019 21:32:41 +0200
Subject: [PATCH] Google Play Billing implementation (#32)
Google Play Billing implementation
---
.travis.yml | 4 +-
CHANGELOG.md | 11 +
README.md | 76 ++-
build.gradle | 2 +-
.../.gitignore | 0
.../build.gradle | 41 ++
.../src/main/AndroidManifest.xml | 13 +
.../debug/FakeGooglePlayBillingApi.java | 242 +++++++++
.../debug/FakeGooglePlayCheckoutActivity.java | 98 ++++
.../cashier/billing/debug/FakeSkuDetails.java | 56 ++
.../res/layout/activity_fake_checkout.xml | 135 +++++
.../src/main/res/values/colors.xml | 4 +
.../src/main/res/values/style.xml | 9 +
.../billing/debug/ExampleUnitTest.java | 17 +
cashier-google-play-billing/.gitignore | 1 +
cashier-google-play-billing/build.gradle | 42 ++
.../proguard-rules.pro | 2 +
.../src/main/AndroidManifest.xml | 6 +
.../billing/AbstractGooglePlayBillingApi.java | 80 +++
.../cashier/billing/GooglePlayBillingApi.java | 276 ++++++++++
.../billing/GooglePlayBillingConstants.java | 33 ++
.../billing/GooglePlayBillingProduct.java | 21 +
.../billing/GooglePlayBillingPurchase.java | 85 +++
.../billing/GooglePlayBillingSecurity.java | 109 ++++
.../billing/GooglePlayBillingVendor.java | 512 ++++++++++++++++++
.../cashier/billing/InventoryQuery.java | 249 +++++++++
.../cashier/billing/Threading.java | 23 +
.../GooglePlayBillingPurchaseTest.java | 65 +++
.../GooglePlayBillingSecurityTest.java | 73 +++
.../billing/GooglePlayBillingVendorTest.java | 439 +++++++++++++++
.../cashier/billing/InventoryQueryTest.java | 190 +++++++
.../getkeepsafe/cashier/billing/TestData.java | 118 ++++
.../cashier/billing/TestHelper.java | 115 ++++
.../cashier/billing/TestPurchase.java | 45 ++
.../cashier/billing/TestSkuDetails.java | 56 ++
.../cashier/iab/InAppBillingV3Vendor.java | 5 +
cashier-sample-google-play-billing/.gitignore | 1 +
.../build.gradle | 29 +
.../proguard-rules.pro | 21 +
.../src/main/AndroidManifest.xml | 21 +
.../sample/googleplaybilling/Item.java | 29 +
.../googleplaybilling/ItemsAdapter.java | 129 +++++
.../googleplaybilling/MainActivity.java | 321 +++++++++++
.../main/res/drawable-mdpi/ic_launcher.png | Bin
.../main/res/drawable-xhdpi/ic_launcher.png | Bin
.../main/res/drawable-xxhdpi/ic_launcher.png | Bin
.../main/res/drawable-xxxhdpi/ic_launcher.png | Bin
.../src/main/res/layout/activity_main.xml | 58 ++
.../src/main/res/layout/view_item.xml | 47 ++
.../src/main/res/values/colors.xml | 0
.../src/main/res/values/strings.xml | 10 +
.../src/main/res/values/styles.xml | 0
cashier-sample-iab/.gitignore | 1 +
.../build.gradle | 2 +-
.../proguard-rules.pro | 0
.../src/main/AndroidManifest.xml | 0
.../cashier/sample/MainActivity.java | 1 -
.../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 2302 bytes
.../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 6182 bytes
.../main/res/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 11971 bytes
.../main/res/drawable-xxxhdpi/ic_launcher.png | Bin 0 -> 18493 bytes
.../src/main/res/layout/activity_main.xml | 0
.../src/main/res/values/colors.xml | 6 +
.../src/main/res/values/strings.xml | 0
.../src/main/res/values/styles.xml | 11 +
.../getkeepsafe/cashier/Preconditions.java | 2 +-
gradle/wrapper/gradle-wrapper.properties | 2 +-
settings.gradle | 2 +-
versions.gradle | 37 +-
69 files changed, 3946 insertions(+), 37 deletions(-)
create mode 100644 CHANGELOG.md
rename {cashier-sample => cashier-google-play-billing-debug}/.gitignore (100%)
create mode 100644 cashier-google-play-billing-debug/build.gradle
create mode 100644 cashier-google-play-billing-debug/src/main/AndroidManifest.xml
create mode 100644 cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayBillingApi.java
create mode 100644 cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayCheckoutActivity.java
create mode 100644 cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeSkuDetails.java
create mode 100644 cashier-google-play-billing-debug/src/main/res/layout/activity_fake_checkout.xml
create mode 100644 cashier-google-play-billing-debug/src/main/res/values/colors.xml
create mode 100644 cashier-google-play-billing-debug/src/main/res/values/style.xml
create mode 100644 cashier-google-play-billing-debug/src/test/java/com/getkeepsafe/cashier/billing/debug/ExampleUnitTest.java
create mode 100644 cashier-google-play-billing/.gitignore
create mode 100644 cashier-google-play-billing/build.gradle
create mode 100644 cashier-google-play-billing/proguard-rules.pro
create mode 100644 cashier-google-play-billing/src/main/AndroidManifest.xml
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/AbstractGooglePlayBillingApi.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingApi.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingConstants.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingProduct.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchase.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurity.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendor.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/InventoryQuery.java
create mode 100644 cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/Threading.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchaseTest.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurityTest.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendorTest.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/InventoryQueryTest.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestData.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestHelper.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestPurchase.java
create mode 100644 cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestSkuDetails.java
create mode 100644 cashier-sample-google-play-billing/.gitignore
create mode 100644 cashier-sample-google-play-billing/build.gradle
create mode 100644 cashier-sample-google-play-billing/proguard-rules.pro
create mode 100644 cashier-sample-google-play-billing/src/main/AndroidManifest.xml
create mode 100644 cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/Item.java
create mode 100644 cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/ItemsAdapter.java
create mode 100644 cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/MainActivity.java
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/drawable-mdpi/ic_launcher.png (100%)
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/drawable-xhdpi/ic_launcher.png (100%)
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/drawable-xxhdpi/ic_launcher.png (100%)
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/drawable-xxxhdpi/ic_launcher.png (100%)
create mode 100644 cashier-sample-google-play-billing/src/main/res/layout/activity_main.xml
create mode 100644 cashier-sample-google-play-billing/src/main/res/layout/view_item.xml
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/values/colors.xml (100%)
create mode 100644 cashier-sample-google-play-billing/src/main/res/values/strings.xml
rename {cashier-sample => cashier-sample-google-play-billing}/src/main/res/values/styles.xml (100%)
create mode 100644 cashier-sample-iab/.gitignore
rename {cashier-sample => cashier-sample-iab}/build.gradle (96%)
rename {cashier-sample => cashier-sample-iab}/proguard-rules.pro (100%)
rename {cashier-sample => cashier-sample-iab}/src/main/AndroidManifest.xml (100%)
rename {cashier-sample => cashier-sample-iab}/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java (99%)
create mode 100644 cashier-sample-iab/src/main/res/drawable-mdpi/ic_launcher.png
create mode 100644 cashier-sample-iab/src/main/res/drawable-xhdpi/ic_launcher.png
create mode 100644 cashier-sample-iab/src/main/res/drawable-xxhdpi/ic_launcher.png
create mode 100644 cashier-sample-iab/src/main/res/drawable-xxxhdpi/ic_launcher.png
rename {cashier-sample => cashier-sample-iab}/src/main/res/layout/activity_main.xml (100%)
create mode 100644 cashier-sample-iab/src/main/res/values/colors.xml
rename {cashier-sample => cashier-sample-iab}/src/main/res/values/strings.xml (100%)
create mode 100644 cashier-sample-iab/src/main/res/values/styles.xml
diff --git a/.travis.yml b/.travis.yml
index 07e2a12..a32b67b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,8 +16,8 @@ android:
components:
- platform-tools
- tools
- - build-tools-27.0.3
- - android-27
+ - build-tools-28.0.3
+ - android-28
- extra-google-m2repository
- extra-android-m2repository
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a2548fc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+## 0.3.5
+ - Fixed inventory query uncaught exception
+
+## 0.3.0
+ - Google Play Billing lib implementation - GooglePlayBillingVendor
+ - Google Play Billing implementation unit tests
+ - Fake purchase flow for GooglePlayBillingVendor
+ - New sample app using GooglePlayBillingVendor
+ - Added changelog
\ No newline at end of file
diff --git a/README.md b/README.md
index 4a41081..8e2bd77 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
- Cashier
+ Cashier
[![Build Status](https://travis-ci.org/KeepSafe/Cashier.svg?branch=master)](https://travis-ci.org/KeepSafe/Cashier)
@@ -19,28 +19,34 @@ Cashier also aims to bridge the gap between development testing and production t
### Features
- - Google Play's In-App-Billing (IAB)
+ - Google Play's In-App-Billing (IAB) - Deprecated
- Purchasing for products and subscriptions, consuming for consumable products
- Fake checkout, facilitating faster development
- Local receipt verification
- Inventory querying
+ - Google Play Billing
+ - Purchasing for products and subscriptions, consuming for consumable products
+ - Fake checkout, facilitating faster development
+ - Local receipt verification
+ - Inventory querying
+ - For now, developer payload is not supported (will be added in GPB v2)
+
## Installation
Cashier is distributed using [jcenter](https://bintray.com/keepsafesoftware/Android/Cashier/view).
```groovy
-repositories {
+repositories {
jcenter()
}
-
+
dependencies {
compile 'com.getkeepsafe.cashier:cashier:0.x.x' // Core library, required
-
- // Google Play
- compile 'com.getkeepsafe.cashier:cashier-iab:0.x.x'
- debugCompile 'com.getkeepsafe.cashier:cashier-iab-debug:0.x.x' // For fake checkout and testing
- releaseCompile 'com.getkeepsafe.cashier:cashier-iab-debug-no-op:0.x.x'
+
+ // Google Play Billing
+ compile 'com.getkeepsafe.cashier:cashier-google-play-billing:0.x.x'
+ debugCompile 'com.getkeepsafe.cashier:cashier-google-play-billing-debug:0.x.x' // For fake checkout and testing
}
```
@@ -50,7 +56,7 @@ General usage is as follows:
```java
// First choose a vendor
-final Vendor vendor = new InAppBillingV3Vendor();
+final Vendor vendor = new GooglePlayBillingVendor();
// Get a product to buy
final Product product = Product.create(
@@ -82,9 +88,57 @@ final Cashier cashier = Cashier.forVendor(activity, vendor);
cashier.purchase(activity, product, "my custom dev payload", listener);
```
+To test app in debug mode with fake purchase flow:
+```java
+// Create vendor with fake API implementation
+vendor = new GooglePlayBillingVendor(
+ new FakeGooglePlayBillingApi(MainActivity.this,
+ FakeGooglePlayBillingApi.TEST_PUBLIC_KEY));
+
+// Add products definitions
+final Product product = Product.create(
+ vendor.id(), // The vendor that produces this product
+ "my.sku", // The SKU of the product
+ "$0.99", // The display price of the product
+ "USD", // The currency of the display price
+ "My Awesome Product", // The product's title
+ "Provides awesomeness!", // The product's description
+ false, // Whether the product is a subscription or not (consumable)
+ 990_000L); // The product price in micros
+
+FakeGooglePlayBillingApi.addTestProduct(product)
+```
+
+```FakeGooglePlayBillingApi``` uses predefined private key to sign purchase receipt.
+If you want to verify purchase signature in your code, use corresponding public key defined in
+```FakeGooglePlayBillingApi.TEST_PUBLIC_KEY```.
+
+## Migrating from In App Billing to Google Play Billing
+
+All you need to do is change vendor implementation from depracated `InAppBillingV3Vendor` to `GooglePlayBillingVendor`.
+Since both implementations are just different ways to connect to Google Play Store, all your products and purchase
+flows remain the same.
+
+1. In your dependencies replace
+```compile 'com.getkeepsafe.cashier:cashier-iab:0.x.x'
+ debugCompile 'com.getkeepsafe.cashier:cashier-iab-debug:0.x.x' // For fake checkout and testing
+ releaseCompile 'com.getkeepsafe.cashier:cashier-iab-debug-no-op:0.x.x'```
+
+with
+```
+compile 'com.getkeepsafe.cashier:cashier-google-play-billing:0.x.x'
+debugCompile 'com.getkeepsafe.cashier:cashier-google-play-billing-debug:0.x.x' // For fake checkout and testing
+```
+
+2. Replace `InAppBillingV3Vendor` with `GooglePlayBillingVendor`. To test the app in debug mode use `FakeGooglePlayBillingApi` in place of `FakeAppBillingV3Api`.
+Definition of products remains the same, but now you need to add them by calling
+```FakeGooglePlayBillingApi.addTestProduct(product)```
+
+3. That's it! Now your app will use new Google Play Billing API!!
+
## Sample App
-For a buildable / workable sample app, please see the `cashier-sample` project under `cashier-sample/`.
+For a buildable / workable sample app, please see the `cashier-sample-google-play-billing` project.
## Acknowledgements
diff --git a/build.gradle b/build.gradle
index b6b7e79..d99d4fd 100755
--- a/build.gradle
+++ b/build.gradle
@@ -5,7 +5,7 @@ buildscript {
jcenter()
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.1.3'
+ classpath 'com.android.tools.build:gradle:3.3.1'
classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5'
}
}
diff --git a/cashier-sample/.gitignore b/cashier-google-play-billing-debug/.gitignore
similarity index 100%
rename from cashier-sample/.gitignore
rename to cashier-google-play-billing-debug/.gitignore
diff --git a/cashier-google-play-billing-debug/build.gradle b/cashier-google-play-billing-debug/build.gradle
new file mode 100644
index 0000000..073c9ea
--- /dev/null
+++ b/cashier-google-play-billing-debug/build.gradle
@@ -0,0 +1,41 @@
+apply plugin: 'com.android.library'
+apply plugin: 'com.github.dcendents.android-maven'
+
+android {
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+ testOptions {
+ unitTests.all {
+ testLogging {
+ exceptionFormat 'full'
+ showStackTraces true
+ showCauses true
+ events "passed", "skipped", "failed", "standardError"
+ }
+ }
+ }
+}
+
+dependencies {
+ api project(':cashier-google-play-billing')
+
+ compileOnly deps.autoValue
+ compileOnly deps.supportAnnotations
+ annotationProcessor deps.autoValue
+ annotationProcessor deps.autoParcel
+
+ testImplementation deps.robolectric
+ testImplementation deps.junit
+ testImplementation deps.mockito
+ testImplementation deps.truth
+}
diff --git a/cashier-google-play-billing-debug/src/main/AndroidManifest.xml b/cashier-google-play-billing-debug/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b6ee83b
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayBillingApi.java b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayBillingApi.java
new file mode 100644
index 0000000..798bafc
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayBillingApi.java
@@ -0,0 +1,242 @@
+package com.getkeepsafe.cashier.billing.debug;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.billing.AbstractGooglePlayBillingApi;
+import com.getkeepsafe.cashier.billing.GooglePlayBillingVendor;
+import com.getkeepsafe.cashier.logging.Logger;
+
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class FakeGooglePlayBillingApi extends AbstractGooglePlayBillingApi {
+
+ @VisibleForTesting
+ static final String TEST_PRIVATE_KEY =
+ "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALXolIcA1LIcYDnO\n" +
+ "2nfalbkOD2UAQ3KfqsdEGLddG2rW8Cyl2LIyiWVvQ6bp2q5qBoYCds9lBQT21uo1\n" +
+ "VHTcv4mnaLfdBjMlzecrK8y1FzRLKFXyoMqiau8wunFeqFsdzHQ774PbYyNgMGdr\n" +
+ "zUDXqIdQONL8Eq/0pgddk07uNxwbAgMBAAECgYAJInvK57zGkOw4Gu4XlK9uEomt\n" +
+ "Xb0FVYVC6mV/V7qXu+FlrJJcKHOD13mDOT0VAxf+xMLomT8OR8L1EeaC087+aeza\n" +
+ "twYUVx4d+J0cQ8xo3ILwY5Bg4/Y4R0gIbdKupHbhPKaLSAiMxilNKqNfY8upT2X/\n" +
+ "S4OFDDbm7aK8SlGPEQJBAN+YlMb4PS54aBpWgeAP8fzgtOL0Q157bmoQyCokiWv3\n" +
+ "OGa89LraifCtlsqmmAxyFbPzO2cFHYvzzEeU86XZVFkCQQDQRWQ0QJKJsfqxEeYG\n" +
+ "rq9e3TkY8uQeHz8BmgxRcYC0v43bl9ggAJAzh9h9o0X9da1YzkoQ0/cWUp5NK95F\n" +
+ "93WTAkEAxqm1/rcO/RwEOuqDyIXCVxF8Bm5K8UawCtNQVYlTBDeKyFW5B9AmYU6K\n" +
+ "vRGZ5Oz0dYd2TwlPgEqkRTGF7eSUOQJAfyK85oC8cz2oMMsiRdYAy8Hzht1Oj2y3\n" +
+ "g3zMJDNLRArix7fLgM2XOT2l1BwFL5HUPa+/2sHpxUCtzaIHz2Id7QJATyF+fzUR\n" +
+ "eVw04ogIsOIdG0ECrN5/3g9pQnAjxcReQ/4KVCpIE8lQFYjAzQYUkK9VOjX9LYp9\n" +
+ "DGEnpooCco1ZjA==";
+
+ /**
+ * {@link com.getkeepsafe.cashier.billing.debug.FakeGooglePlayBillingApi} is using predefined
+ * private key to sign purchase receipt. Use this matching public key if you want to verify
+ * signature in your code.
+ */
+ public static final String TEST_PUBLIC_KEY =
+ "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC16JSHANSyHGA5ztp32pW5Dg9l\n" +
+ "AENyn6rHRBi3XRtq1vAspdiyMollb0Om6dquagaGAnbPZQUE9tbqNVR03L+Jp2i3\n" +
+ "3QYzJc3nKyvMtRc0SyhV8qDKomrvMLpxXqhbHcx0O++D22MjYDBna81A16iHUDjS\n" +
+ "/BKv9KYHXZNO7jccGwIDAQAB";
+
+ private static final Set testProducts = new HashSet<>();
+ private static final Set testInappPurchases = new HashSet<>();
+ private static final Set testSubPurchases = new HashSet<>();
+
+ private static final Map pendingPurchases = new HashMap<>();
+
+ private GooglePlayBillingVendor vendor;
+
+ private Handler mainHandler = new Handler(Looper.getMainLooper());
+
+ public FakeGooglePlayBillingApi(Context context) {
+ this(context, TEST_PRIVATE_KEY);
+ }
+
+ public FakeGooglePlayBillingApi(Context context, String privateKey64) {
+ }
+
+ public static void addTestProduct(Product product) {
+ testProducts.add(product);
+ }
+
+ /**
+ * Notifies pending purchase listeners of successful transaction
+ * @param sku Sku of purchased product
+ * @param purchase Purchase object representing successful transaction
+ */
+ static void notifyPurchaseSuccess(String sku, Purchase purchase) {
+ FakePurchaseListener listener = pendingPurchases.get(sku);
+ if (listener != null) {
+ listener.onFakePurchaseSuccess(purchase);
+ }
+ }
+
+ /**
+ * Notifies pending purchase listeners of transation error
+ * @param sku Sku of purchased product
+ * @param responseCode Error code
+ */
+ static void notifyPurchaseError(String sku, int responseCode) {
+ FakePurchaseListener listener = pendingPurchases.get(sku);
+ if (listener != null) {
+ listener.onFakePurchaseError(responseCode);
+ }
+ }
+
+ @Override
+ public boolean initialize(@NonNull Context context, @NonNull GooglePlayBillingVendor vendor, LifecycleListener listener, Logger logger) {
+ super.initialize(context, vendor, listener, logger);
+ this.vendor = vendor;
+ listener.initialized(true);
+ return true;
+ }
+
+ @Override
+ public boolean available() {
+ return true;
+ }
+
+ @Override
+ public void dispose() {
+ }
+
+ @Override
+ public int isBillingSupported(String itemType) {
+ return BillingClient.BillingResponse.OK;
+ }
+
+ @Override
+ public void launchBillingFlow(@NonNull Activity activity, @NonNull final String sku, final String itemType) {
+ for (Product product : testProducts) {
+ if (product.sku().equals(sku)) {
+ activity.startActivity(FakeGooglePlayCheckoutActivity.intent(activity, product, TEST_PRIVATE_KEY));
+
+ // Put listener to pendingPurchases map and wait until either
+ // notifyPurchaseSuccess or notifyPurchaseError is called from FakeGooglePlayCheckoutActivity
+ pendingPurchases.put(sku, new FakePurchaseListener() {
+ @Override
+ public void onFakePurchaseSuccess(Purchase purchase) {
+ pendingPurchases.remove(sku);
+ if (itemType.equals(BillingClient.SkuType.SUBS)) {
+ testSubPurchases.add(purchase);
+ } else {
+ testInappPurchases.add(purchase);
+ }
+ vendor.onPurchasesUpdated(BillingClient.BillingResponse.OK, Collections.singletonList(purchase));
+ }
+
+ @Override
+ public void onFakePurchaseError(int responseCode) {
+ pendingPurchases.remove(sku);
+ vendor.onPurchasesUpdated(responseCode, null);
+ }
+ });
+ return;
+ }
+ }
+ }
+
+ @Nullable
+ @Override
+ public List getPurchases() {
+ ArrayList purchases = new ArrayList<>();
+ purchases.addAll(testInappPurchases);
+ purchases.addAll(testSubPurchases);
+ return purchases;
+ }
+
+ @Nullable
+ @Override
+ public List getPurchases(String itemType) {
+ if (itemType.equals(BillingClient.SkuType.SUBS)) {
+ return new ArrayList<>(testSubPurchases);
+ } else {
+ return new ArrayList<>(testInappPurchases);
+ }
+ }
+
+ @Override
+ public void consumePurchase(final @NonNull String purchaseToken, final @NonNull ConsumeResponseListener listener) {
+ // Use new thread to simulate network operation
+ new Thread() {
+ public void run() {
+ // Wait 1 second to simulate network operation
+ try { sleep(1000L); } catch (InterruptedException e) {}
+
+ for (Iterator it = testInappPurchases.iterator(); it.hasNext();) {
+ if (it.next().getPurchaseToken().equals(purchaseToken)) {
+ it.remove();
+ }
+ }
+ for (Iterator it = testSubPurchases.iterator(); it.hasNext();) {
+ if (it.next().getPurchaseToken().equals(purchaseToken)) {
+ it.remove();
+ }
+ }
+
+ // Return result on main thread
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ listener.onConsumeResponse(BillingClient.BillingResponse.OK, purchaseToken);
+ }
+ });
+ }
+ }.start();
+ }
+
+ @Override
+ public void getSkuDetails(final String itemType, final @NonNull List skus, final @NonNull SkuDetailsResponseListener listener) {
+ // Use new thread to simulate network operation
+ new Thread() {
+ public void run() {
+ // Wait 1 second to simulate network operation
+ try { sleep(1000L); } catch (InterruptedException e) {}
+
+ final List details = new ArrayList<>();
+ for (Product product : testProducts) {
+ if (skus.contains(product.sku())) {
+ try {
+ details.add(new FakeSkuDetails(product));
+ } catch (JSONException e) {
+ }
+ }
+ }
+
+ // Return result on main thread
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ listener.onSkuDetailsResponse(BillingClient.BillingResponse.OK, details);
+ }
+ });
+ }
+ }.start();
+ }
+
+ public static interface FakePurchaseListener {
+ void onFakePurchaseSuccess(Purchase purchase);
+ void onFakePurchaseError(int responseCode);
+ }
+}
diff --git a/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayCheckoutActivity.java b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayCheckoutActivity.java
new file mode 100644
index 0000000..3032aeb
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeGooglePlayCheckoutActivity.java
@@ -0,0 +1,98 @@
+package com.getkeepsafe.cashier.billing.debug;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.text.SpannableString;
+import android.text.style.StyleSpan;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.Purchase;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.billing.GooglePlayBillingSecurity;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class FakeGooglePlayCheckoutActivity extends Activity {
+
+ private static final String ARGUMENT_PRODUCT = "product";
+
+ private Product product;
+
+ public static Intent intent(Context context, Product product, String privateKey64) {
+ Intent intent = new Intent(context, FakeGooglePlayCheckoutActivity.class);
+ intent.putExtra(ARGUMENT_PRODUCT, product);
+ return intent;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_fake_checkout);
+
+ final Intent intent = getIntent();
+ product = intent.getParcelableExtra(ARGUMENT_PRODUCT);
+
+ final TextView productName = bind(R.id.product_name);
+ final TextView productDescription = bind(R.id.product_description);
+ final TextView productPrice = bind(R.id.product_price);
+ final TextView productMetadata = bind(R.id.product_metadata);
+ final Button buyButton = bind(R.id.buy);
+
+ productName.setText(product.name());
+ productDescription.setText(product.description());
+ productPrice.setText(product.price());
+
+ productMetadata.setText(String.valueOf(
+ metadataField("Vendor", product.vendorId())) +
+ metadataField("SKU", product.sku()) +
+ metadataField("Subscription", product.isSubscription()) +
+ metadataField("Micro-price", product.microsPrice()) +
+ metadataField("Currency", product.currency()));
+
+ buyButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ try {
+ JSONObject purchaseJson = new JSONObject();
+ purchaseJson.put("orderId", String.valueOf(System.currentTimeMillis()));
+ purchaseJson.put("purchaseToken", product.sku() + "_" + System.currentTimeMillis());
+ purchaseJson.put("purchaseState", 0);
+ purchaseJson.put("productId", product.sku());
+ String json = purchaseJson.toString();
+ String signature = GooglePlayBillingSecurity.sign(FakeGooglePlayBillingApi.TEST_PRIVATE_KEY, json);
+ Purchase purchase = new Purchase(json, signature);
+
+ FakeGooglePlayBillingApi.notifyPurchaseSuccess(product.sku(), purchase);
+
+ } catch (JSONException e) {
+ FakeGooglePlayBillingApi.notifyPurchaseError(product.sku(), BillingClient.BillingResponse.SERVICE_UNAVAILABLE);
+ }
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ FakeGooglePlayBillingApi.notifyPurchaseError(product.sku(), BillingClient.BillingResponse.USER_CANCELED);
+ }
+
+ private SpannableString metadataField(String name, Object value) {
+ final SpannableString string = new SpannableString(name + ": " + value.toString() + "\n");
+ string.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length() + 1, 0);
+ return string;
+ }
+
+ @SuppressWarnings("unchecked")
+ private T bind(int id) {
+ return (T) findViewById(id);
+ }
+}
diff --git a/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeSkuDetails.java b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeSkuDetails.java
new file mode 100644
index 0000000..182ed53
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/java/com/getkeepsafe/cashier/billing/debug/FakeSkuDetails.java
@@ -0,0 +1,56 @@
+package com.getkeepsafe.cashier.billing.debug;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.SkuDetails;
+import com.getkeepsafe.cashier.Product;
+
+import org.json.JSONException;
+
+/**
+ * Fake sku details query result. SkuDetails requires json to be constructed,
+ * this class overrides mostly used fields to read values from Product directly.
+ */
+public class FakeSkuDetails extends SkuDetails {
+
+ private Product product;
+
+ public FakeSkuDetails(Product product) throws JSONException {
+ super("{}");
+ this.product = product;
+ }
+
+ @Override
+ public String getTitle() {
+ return product.name();
+ }
+
+ @Override
+ public String getDescription() {
+ return product.description();
+ }
+
+ @Override
+ public String getSku() {
+ return product.sku();
+ }
+
+ @Override
+ public String getType() {
+ return product.isSubscription() ? BillingClient.SkuType.SUBS : BillingClient.SkuType.INAPP;
+ }
+
+ @Override
+ public String getPrice() {
+ return product.price();
+ }
+
+ @Override
+ public long getPriceAmountMicros() {
+ return product.microsPrice();
+ }
+
+ @Override
+ public String getPriceCurrencyCode() {
+ return product.currency();
+ }
+}
diff --git a/cashier-google-play-billing-debug/src/main/res/layout/activity_fake_checkout.xml b/cashier-google-play-billing-debug/src/main/res/layout/activity_fake_checkout.xml
new file mode 100644
index 0000000..f23ac78
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/res/layout/activity_fake_checkout.xml
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cashier-google-play-billing-debug/src/main/res/values/colors.xml b/cashier-google-play-billing-debug/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f4a777a
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #33000000
+
diff --git a/cashier-google-play-billing-debug/src/main/res/values/style.xml b/cashier-google-play-billing-debug/src/main/res/values/style.xml
new file mode 100644
index 0000000..c57a4c3
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/main/res/values/style.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cashier-google-play-billing-debug/src/test/java/com/getkeepsafe/cashier/billing/debug/ExampleUnitTest.java b/cashier-google-play-billing-debug/src/test/java/com/getkeepsafe/cashier/billing/debug/ExampleUnitTest.java
new file mode 100644
index 0000000..7205da8
--- /dev/null
+++ b/cashier-google-play-billing-debug/src/test/java/com/getkeepsafe/cashier/billing/debug/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.getkeepsafe.cashier.billing.debug;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/cashier-google-play-billing/.gitignore b/cashier-google-play-billing/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/cashier-google-play-billing/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/cashier-google-play-billing/build.gradle b/cashier-google-play-billing/build.gradle
new file mode 100644
index 0000000..dcf5a2d
--- /dev/null
+++ b/cashier-google-play-billing/build.gradle
@@ -0,0 +1,42 @@
+apply plugin: 'com.android.library'
+apply plugin: 'com.github.dcendents.android-maven'
+
+android {
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ consumerProguardFiles 'proguard-rules.pro'
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+ testOptions {
+ unitTests.all {
+ testLogging {
+ exceptionFormat 'full'
+ showStackTraces true
+ showCauses true
+ events "passed", "skipped", "failed", "standardError"
+ }
+ }
+ }
+}
+
+dependencies {
+ api project(':cashier')
+
+ api deps.billingClient
+ implementation deps.supportAnnotations
+ compileOnly deps.autoValue
+ annotationProcessor deps.autoValue
+ annotationProcessor deps.autoParcel
+
+ testImplementation deps.robolectric
+ testImplementation deps.junit
+ testImplementation deps.mockito
+ testImplementation deps.truth
+}
diff --git a/cashier-google-play-billing/proguard-rules.pro b/cashier-google-play-billing/proguard-rules.pro
new file mode 100644
index 0000000..743ac77
--- /dev/null
+++ b/cashier-google-play-billing/proguard-rules.pro
@@ -0,0 +1,2 @@
+# https://developer.android.com/google/play/billing/billing_library_overview
+-keep class com.android.vending.billing.**
\ No newline at end of file
diff --git a/cashier-google-play-billing/src/main/AndroidManifest.xml b/cashier-google-play-billing/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e29391a
--- /dev/null
+++ b/cashier-google-play-billing/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/AbstractGooglePlayBillingApi.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/AbstractGooglePlayBillingApi.java
new file mode 100644
index 0000000..2672996
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/AbstractGooglePlayBillingApi.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.billing;
+
+import android.app.Activity;
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import com.android.billingclient.api.BillingClient.SkuType;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.Preconditions;
+import com.getkeepsafe.cashier.logging.Logger;
+
+import java.util.List;
+
+public abstract class AbstractGooglePlayBillingApi {
+
+ private String packageName;
+ GooglePlayBillingVendor vendor;
+ Logger logger;
+
+ public interface LifecycleListener {
+ void initialized(boolean success);
+
+ void disconnected();
+ }
+
+ public boolean initialize(@NonNull Context context, @NonNull GooglePlayBillingVendor vendor,
+ LifecycleListener listener, Logger logger) {
+ Preconditions.checkNotNull(context, "Context is null");
+ Preconditions.checkNotNull(vendor, "Vendor is null");
+
+ this.packageName = context.getPackageName();
+ this.vendor = vendor;
+ this.logger = logger;
+ return true;
+ }
+
+ public abstract boolean available();
+
+ public abstract void dispose();
+
+ public abstract int isBillingSupported(@SkuType String itemType);
+
+ public abstract void getSkuDetails(@SkuType String itemType, @NonNull List skus,
+ @NonNull SkuDetailsResponseListener listener);
+
+ public abstract void launchBillingFlow(@NonNull Activity activity, @NonNull String sku, @SkuType String itemType);
+
+ @Nullable
+ public abstract List getPurchases();
+
+ @Nullable
+ public abstract List getPurchases(@SkuType String itemType);
+
+ public abstract void consumePurchase(@NonNull String purchaseToken, @NonNull ConsumeResponseListener listener);
+
+ protected void throwIfUnavailable() {
+ if (packageName == null) {
+ throw new IllegalStateException("You did not specify the package name");
+ }
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingApi.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingApi.java
new file mode 100644
index 0000000..a0ff421
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingApi.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2019 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.billing;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+import android.text.TextUtils;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingClient.BillingResponse;
+import com.android.billingclient.api.BillingClient.FeatureType;
+import com.android.billingclient.api.BillingClient.SkuType;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.logging.Logger;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class GooglePlayBillingApi extends AbstractGooglePlayBillingApi implements BillingClientStateListener {
+
+ /**
+ * Internal log tag
+ **/
+ private static final String LOG_TAG = "GoogleBillingApi";
+
+ /**
+ * Google Play Billing client
+ **/
+ private BillingClient billing;
+
+ /**
+ * Google Play Billing service life cycle listener
+ **/
+ private LifecycleListener listener;
+
+ /**
+ * Google Play Billing connection state
+ **/
+ private boolean isServiceConnected = false;
+
+ @Override
+ public boolean initialize(final @NonNull Context context, final @NonNull GooglePlayBillingVendor vendor,
+ LifecycleListener listener, Logger logger) {
+ final boolean initialized = super.initialize(context, vendor, listener, logger);
+ this.listener = listener;
+
+ if (available() && listener != null) {
+ listener.initialized(true);
+ return true;
+ }
+
+ // Google Billing require client creation to be performed on main thread.
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ createClient(context, vendor).run();
+ } else {
+ new Handler(Looper.getMainLooper()).post(
+ createClient(context, vendor)
+ );
+ }
+
+ return initialized;
+ }
+
+ @UiThread
+ private Runnable createClient(@NonNull final Context context, @NonNull final GooglePlayBillingVendor vendor) {
+ return new Runnable() {
+ @Override
+ public void run() {
+ logSafely("Creating Google Play Billing client...");
+ billing = BillingClient.newBuilder(context)
+ .setListener(vendor)
+ .build();
+
+ logSafely("Attempting to connect to billing service...");
+ billing.startConnection(GooglePlayBillingApi.this);
+ }
+ };
+ }
+
+ @Override
+ public boolean available() {
+ // Billing API is available if we are connected to the service and
+ // the billing client is ready (See: BillingClient#isReady)
+ return billing != null && isServiceConnected && billing.isReady();
+ }
+
+ @Override
+ public void dispose() {
+ logSafely("Disposing billing client.");
+
+ if (available()) {
+ billing.endConnection();
+ billing = null;
+ }
+ }
+
+ @Override
+ public int isBillingSupported(@SkuType String itemType) {
+ throwIfUnavailable();
+
+ if (SkuType.INAPP.equalsIgnoreCase(itemType) && billing.isReady()) {
+ return BillingResponse.OK;
+ } else if (SkuType.SUBS.equalsIgnoreCase(itemType)) {
+ return billing.isFeatureSupported(FeatureType.SUBSCRIPTIONS);
+ }
+
+ return BillingResponse.FEATURE_NOT_SUPPORTED;
+ }
+
+ @Override
+ public void getSkuDetails(@SkuType String itemType, @NonNull List skus,
+ @NonNull SkuDetailsResponseListener listener) {
+ throwIfUnavailable();
+
+ logSafely("Query for SKU details with type: " + itemType + " SKUs: " + TextUtils.join(",", skus));
+
+ SkuDetailsParams query = SkuDetailsParams.newBuilder()
+ .setSkusList(skus)
+ .setType(itemType)
+ .build();
+ billing.querySkuDetailsAsync(query, listener);
+ }
+
+ @Override
+ public void launchBillingFlow(@NonNull final Activity activity, @NonNull String sku, @SkuType String itemType) {
+ throwIfUnavailable();
+ logSafely("Launching billing flow for " + sku + " with type " + itemType);
+
+ getSkuDetails(
+ itemType,
+ Collections.singletonList(sku),
+ new SkuDetailsResponseListener() {
+ @Override
+ public void onSkuDetailsResponse(int responseCode, List skuDetailsList) {
+ if (skuDetailsList.size() > 0) {
+ BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
+ .setSkuDetails(skuDetailsList.get(0))
+ .build();
+
+ // This will call the {@link PurchasesUpdatedListener} specified in {@link #initialize}
+ billing.launchBillingFlow(activity, billingFlowParams);
+ }
+ }
+ }
+ );
+ }
+
+ @Nullable
+ @Override
+ public List getPurchases() {
+ throwIfUnavailable();
+
+ List allPurchases = new ArrayList<>();
+
+ logSafely("Querying in-app purchases...");
+ Purchase.PurchasesResult inAppPurchasesResult = billing.queryPurchases(SkuType.INAPP);
+
+ if (inAppPurchasesResult.getResponseCode() == BillingResponse.OK) {
+ List inAppPurchases = inAppPurchasesResult.getPurchasesList();
+ logSafely("In-app purchases: " + TextUtils.join(", ", inAppPurchases));
+ allPurchases.addAll(inAppPurchases);
+ // Check if we support subscriptions and query those purchases as well
+ boolean isSubscriptionSupported =
+ billing.isFeatureSupported(FeatureType.SUBSCRIPTIONS) == BillingResponse.OK;
+ if (isSubscriptionSupported) {
+ logSafely("Querying subscription purchases...");
+ Purchase.PurchasesResult subscriptionPurchasesResult = billing.queryPurchases(SkuType.SUBS);
+
+ if (subscriptionPurchasesResult.getResponseCode() == BillingResponse.OK) {
+ List subscriptionPurchases = subscriptionPurchasesResult.getPurchasesList();
+ logSafely("Subscription purchases: " + TextUtils.join(", ", subscriptionPurchases));
+ allPurchases.addAll(subscriptionPurchases);
+ return allPurchases;
+ } else {
+ logSafely("Error in querying subscription purchases with code: "
+ + subscriptionPurchasesResult.getResponseCode());
+ return allPurchases;
+ }
+ } else {
+ logSafely("Subscriptions are not supported...");
+ return allPurchases;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ @Nullable
+ @Override
+ public List getPurchases(String itemType) {
+ throwIfUnavailable();
+
+ Purchase.PurchasesResult purchasesResult = billing.queryPurchases(itemType);
+ if (purchasesResult.getResponseCode() == BillingResponse.OK) {
+ List purchases = purchasesResult.getPurchasesList();
+ logSafely(itemType + " purchases: " + TextUtils.join(", ", purchases));
+ return purchases;
+ }
+
+ return null;
+ }
+
+ @Override
+ public void consumePurchase(@NonNull String purchaseToken, @NonNull ConsumeResponseListener listener) {
+ throwIfUnavailable();
+
+ logSafely("Consuming product with purchase token: " + purchaseToken);
+ billing.consumeAsync(purchaseToken, listener);
+ }
+
+ @Override
+ public void onBillingSetupFinished(@BillingResponse int billingResponseCode) {
+ logSafely("Service setup finished and connected. Response: " + billingResponseCode);
+
+ if (billingResponseCode == BillingResponse.OK) {
+ isServiceConnected = true;
+
+ if (listener != null) {
+ listener.initialized(available());
+ }
+ }
+ }
+
+ @Override
+ public void onBillingServiceDisconnected() {
+ logSafely("Service disconnected");
+
+ isServiceConnected = false;
+
+ if (listener != null) {
+ listener.disconnected();
+ }
+ }
+
+ @Override
+ protected void throwIfUnavailable() {
+ super.throwIfUnavailable();
+ if (!available()) {
+ throw new IllegalStateException("Billing client is not available");
+ }
+ }
+
+ private void logSafely(String message) {
+ if (logger == null || message == null) {
+ return;
+ }
+
+ logger.i(LOG_TAG, message);
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingConstants.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingConstants.java
new file mode 100644
index 0000000..f356201
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingConstants.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.billing;
+
+public final class GooglePlayBillingConstants {
+ public static final String VENDOR_PACKAGE = "com.android.billingclient.api";
+
+ private GooglePlayBillingConstants() {}
+
+ public static class PurchaseConstants {
+ public static final String PURCHASE_STATE = "purchaseState";
+
+ public static final int PURCHASE_STATE_PURCHASED = 0;
+ public static final int PURCHASE_STATE_CANCELED = 1;
+ public static final int PURCHASE_STATE_REFUNDED = 2;
+
+ private PurchaseConstants() {}
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingProduct.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingProduct.java
new file mode 100644
index 0000000..cfc1628
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingProduct.java
@@ -0,0 +1,21 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.BillingClient.SkuType;
+import com.android.billingclient.api.SkuDetails;
+import com.getkeepsafe.cashier.Product;
+
+public class GooglePlayBillingProduct {
+
+ public static Product create(SkuDetails details, @SkuType String type) {
+ return Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ details.getSku(),
+ details.getPrice(),
+ details.getPriceCurrencyCode(),
+ details.getTitle(),
+ details.getDescription(),
+ type.equals(SkuType.SUBS),
+ details.getPriceAmountMicros()
+ );
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchase.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchase.java
new file mode 100644
index 0000000..fe6a80b
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchase.java
@@ -0,0 +1,85 @@
+package com.getkeepsafe.cashier.billing;
+
+import android.os.Parcelable;
+
+import com.getkeepsafe.cashier.CashierPurchase;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.Purchase;
+import com.google.auto.value.AutoValue;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import static com.getkeepsafe.cashier.billing.GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE;
+import static com.getkeepsafe.cashier.billing.GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_CANCELED;
+import static com.getkeepsafe.cashier.billing.GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_PURCHASED;
+import static com.getkeepsafe.cashier.billing.GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_REFUNDED;
+
+@AutoValue
+public abstract class GooglePlayBillingPurchase implements Parcelable, Purchase {
+
+ public static GooglePlayBillingPurchase create(Product product,
+ com.android.billingclient.api.Purchase googlePlayPurchase)
+ throws JSONException {
+ final String receipt = googlePlayPurchase.getOriginalJson();
+ final JSONObject jsonPurchase = new JSONObject(receipt);
+ final int purchaseState = jsonPurchase.getInt(PURCHASE_STATE);
+ final CashierPurchase cashierPurchase = CashierPurchase.create(product,
+ googlePlayPurchase.getOrderId(),
+ googlePlayPurchase.getPurchaseToken(),
+ googlePlayPurchase.getOriginalJson(),
+ // NOTE: Developer payload is not supported with Google Play Billing
+ // https://issuetracker.google.com/issues/63381481
+ "");
+
+ return new AutoValue_GooglePlayBillingPurchase(cashierPurchase, receipt, googlePlayPurchase.getPurchaseToken(), googlePlayPurchase.getOrderId(), purchaseState);
+ }
+
+ public abstract Purchase purchase();
+
+ /**
+ * The original purchase data receipt from Google Play. This is useful for data signature
+ * validation
+ */
+ public abstract String receipt();
+
+ public abstract String token();
+
+ public abstract String orderId();
+
+ /**
+ * The purchase state of the order.
+ * Possible values are:
+ *
+ * - {@code 0} - Purchased
+ * - {@code 1} - Canceled
+ * - {@code 2} - Refunded
+ *
+ */
+ public abstract int purchaseState();
+
+ public Product product() {
+ return purchase().product();
+ }
+
+ public String developerPayload() {
+ throw new RuntimeException("Developer payload is not supported in Google Play Billing!");
+ }
+
+ public boolean purchased() {
+ return purchaseState() == PURCHASE_STATE_PURCHASED;
+ }
+
+ public boolean canceled() {
+ return purchaseState() == PURCHASE_STATE_CANCELED;
+ }
+
+ public boolean refunded() {
+ return purchaseState() == PURCHASE_STATE_REFUNDED;
+ }
+
+ @Override
+ public JSONObject toJson() throws JSONException {
+ return new JSONObject(receipt());
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurity.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurity.java
new file mode 100644
index 0000000..80533cc
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurity.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2019 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.billing;
+
+import android.text.TextUtils;
+import android.util.Base64;
+
+import java.security.InvalidKeyException;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+
+public final class GooglePlayBillingSecurity {
+ private static final String KEY_TYPE = "RSA";
+ private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
+
+ public static PublicKey createPublicKey(String publicKey64) {
+ try {
+ final byte[] decodedKey = Base64.decode(publicKey64, Base64.DEFAULT);
+ final KeyFactory keyFactory = KeyFactory.getInstance(KEY_TYPE);
+ return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
+ } catch (InvalidKeySpecException e) {
+ throw new IllegalStateException(e);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static PrivateKey createPrivateKey(String privateKey64) {
+ try {
+ final byte[] decodedKey = Base64.decode(privateKey64, Base64.DEFAULT);
+ final KeyFactory keyFactory = KeyFactory.getInstance(KEY_TYPE);
+ return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(decodedKey));
+ } catch (InvalidKeySpecException e) {
+ throw new IllegalStateException(e);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static String sign(String privateKey64, String data) {
+ if (TextUtils.isEmpty(privateKey64) || TextUtils.isEmpty(data)) {
+ throw new IllegalArgumentException("Given null data to sign");
+ }
+
+ final PrivateKey privateKey = createPrivateKey(privateKey64);
+ return sign(privateKey, data);
+ }
+
+ public static String sign(PrivateKey privateKey, String data) {
+ if (privateKey == null || TextUtils.isEmpty(data)) {
+ throw new IllegalArgumentException("Given null data to sign");
+ }
+
+ try {
+ final Signature instance = Signature.getInstance(SIGNATURE_ALGORITHM);
+ instance.initSign(privateKey);
+ instance.update(data.getBytes());
+ return Base64.encodeToString(instance.sign(), Base64.DEFAULT);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (SignatureException | InvalidKeyException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ public static boolean verifySignature(String publicKey64, String signedData, String signature64) {
+ if (TextUtils.isEmpty(publicKey64) || TextUtils.isEmpty(signedData) || TextUtils.isEmpty(signature64)) {
+ return false;
+ }
+
+ final PublicKey publicKey = createPublicKey(publicKey64);
+ return verifySignature(publicKey, signedData, signature64);
+ }
+
+ public static boolean verifySignature(PublicKey publicKey, String signedData, String signature64) {
+ try {
+ final byte[] signature = Base64.decode(signature64, Base64.DEFAULT);
+ final Signature instance = Signature.getInstance(SIGNATURE_ALGORITHM);
+ instance.initVerify(publicKey);
+ instance.update(signedData.getBytes());
+ return instance.verify(signature);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (SignatureException | InvalidKeyException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendor.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendor.java
new file mode 100644
index 0000000..2d51cf7
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendor.java
@@ -0,0 +1,512 @@
+/*
+ * Copyright 2019 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.billing;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.billingclient.api.BillingClient.BillingResponse;
+import com.android.billingclient.api.BillingClient.SkuType;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.PurchasesUpdatedListener;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.ConsumeListener;
+import com.getkeepsafe.cashier.InventoryListener;
+import com.getkeepsafe.cashier.Preconditions;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.ProductDetailsListener;
+import com.getkeepsafe.cashier.Purchase;
+import com.getkeepsafe.cashier.PurchaseListener;
+import com.getkeepsafe.cashier.Vendor;
+import com.getkeepsafe.cashier.VendorConstants;
+import com.getkeepsafe.cashier.logging.Logger;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static com.getkeepsafe.cashier.VendorConstants.CONSUME_CANCELED;
+import static com.getkeepsafe.cashier.VendorConstants.CONSUME_FAILURE;
+import static com.getkeepsafe.cashier.VendorConstants.CONSUME_NOT_OWNED;
+import static com.getkeepsafe.cashier.VendorConstants.CONSUME_UNAVAILABLE;
+import static com.getkeepsafe.cashier.VendorConstants.PRODUCT_DETAILS_QUERY_FAILURE;
+import static com.getkeepsafe.cashier.VendorConstants.PRODUCT_DETAILS_UNAVAILABLE;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_ALREADY_OWNED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_CANCELED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_FAILURE;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_NOT_OWNED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_SUCCESS_RESULT_MALFORMED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_UNAVAILABLE;
+import static com.getkeepsafe.cashier.billing.GooglePlayBillingConstants.VENDOR_PACKAGE;
+
+public final class GooglePlayBillingVendor implements Vendor, PurchasesUpdatedListener,
+ AbstractGooglePlayBillingApi.LifecycleListener {
+
+ /**
+ * Internal log tag
+ **/
+ private static final String LOG_TAG = "GoogleBillingVendor";
+
+ /**
+ * Google Play Billing API wrapper
+ **/
+ private final AbstractGooglePlayBillingApi api;
+
+ /**
+ * Google Play Billing API key
+ **/
+ private final String publicKey64;
+
+ private Logger logger;
+
+ /**
+ * Product being purchased. If not null, purchase is in progress.
+ */
+ private Product pendingProduct;
+
+ /**
+ * Pending purchase listener.
+ */
+ private PurchaseListener purchaseListener;
+
+ /**
+ * Initialization listeners. Initialization may be called from more than one thread simultaneously.
+ */
+ private List initializationListeners = new ArrayList<>();
+
+ private boolean available = false;
+ private boolean initializing = false;
+ private boolean canSubscribe = false;
+ private boolean canPurchaseItems = false;
+
+ /**
+ * Tokens to be consumed. Contains token being currently consumed or already consumed.
+ */
+ private Set tokensToBeConsumed = new HashSet<>();
+
+ public GooglePlayBillingVendor() {
+ this(new GooglePlayBillingApi(), null);
+ }
+
+ /**
+ * @param publicKey64 should be YOUR APPLICATION'S PUBLIC KEY
+ * (that you got from the Google Play developer console). This is not your
+ * developer public key, it's the *app-specific* public key.
+ */
+ public GooglePlayBillingVendor(String publicKey64) {
+ this(new GooglePlayBillingApi(), publicKey64);
+ }
+
+ public GooglePlayBillingVendor(AbstractGooglePlayBillingApi api) {
+ this(api, null);
+ }
+
+ public GooglePlayBillingVendor(AbstractGooglePlayBillingApi api, @Nullable String publicKey64) {
+ Preconditions.checkNotNull(api, "Cannot initialize will null api...");
+
+ this.api = api;
+ this.publicKey64 = publicKey64;
+ this.available = false;
+ }
+
+ @Override
+ public String id() {
+ return VENDOR_PACKAGE;
+ }
+
+ @Override
+ public synchronized void initialize(Context context, InitializationListener listener) {
+ Preconditions.checkNotNull(context, "Cannot initialize with null context");
+ Preconditions.checkNotNull(listener, "Cannot initialize with null initialization listener");
+
+ if (available()) {
+ listener.initialized();
+ return;
+ }
+
+ initializationListeners.add(listener);
+
+ if (!initializing) {
+ initializing = true;
+ logSafely("Initializing Google Play Billing API...");
+ available = api.initialize(context, this, this, logger);
+ }
+
+ if (!available) {
+ initializationListeners.remove(listener);
+ listener.unavailable();
+ }
+ }
+
+ @Override
+ public synchronized void initialized(boolean success) {
+ logSafely("Initialized: success = " + success);
+ if (!success) {
+ logAndDisable("Could not create Google Play Billing instance");
+ return;
+ }
+
+ try {
+ canPurchaseItems =
+ api.isBillingSupported(SkuType.INAPP) == BillingResponse.OK;
+
+ canSubscribe =
+ api.isBillingSupported(SkuType.SUBS) == BillingResponse.OK;
+
+ available = canPurchaseItems || canSubscribe;
+ logSafely("Connected to service and it is " + (available ? "available" : "not available"));
+ initializing = false;
+
+ for (InitializationListener listener : initializationListeners) {
+ listener.initialized();
+ }
+ initializationListeners.clear();
+ } catch (Exception error) {
+ logAndDisable(Log.getStackTraceString(error));
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ logAndDisable("Disconnected from Google Play Billing service.");
+ for (InitializationListener listener : initializationListeners) {
+ listener.unavailable();
+ }
+ initializationListeners.clear();
+ }
+
+ @Override
+ public void dispose(Context context) {
+ logSafely("Disposing Google Play Billing vendor...");
+ api.dispose();
+ available = false;
+ initializationListeners.clear();
+ }
+
+ @Override
+ public synchronized void purchase(Activity activity, Product product, String developerPayload, PurchaseListener listener) {
+ Preconditions.checkNotNull(activity, "Activity is null.");
+ Preconditions.checkNotNull(product, "Product is null.");
+ Preconditions.checkNotNull(listener, "Purchase listener is null.");
+ throwIfUninitialized();
+
+ if (pendingProduct != null) {
+ throw new RuntimeException("Cannot purchase product while another purchase is in progress!");
+ }
+
+ // NOTE: Developer payload is not supported with Google Play Billing
+ // https://issuetracker.google.com/issues/63381481
+ if (developerPayload != null && developerPayload.length() > 0) {
+ throw new RuntimeException("Developer payload is not supported in Google Play Billing!");
+ }
+
+ this.purchaseListener = listener;
+ this.pendingProduct = product;
+ logSafely("Launching Google Play Billing flow for " + product.sku());
+ api.launchBillingFlow(activity, product.sku(), product.isSubscription() ? SkuType.SUBS : SkuType.INAPP);
+ }
+
+ @Override
+ public void onPurchasesUpdated(@BillingResponse int responseCode,
+ @Nullable List purchases) {
+ if (purchaseListener == null) {
+ pendingProduct = null;
+ logSafely("#onPurchasesUpdated called but no purchase listener attached.");
+ return;
+ }
+
+ switch (responseCode) {
+ case BillingResponse.OK:
+ if (purchases == null || purchases.isEmpty()) {
+ purchaseListener.failure(pendingProduct, new Error(PURCHASE_FAILURE, responseCode));
+ clearPendingPurchase();
+ return;
+ }
+
+ for (com.android.billingclient.api.Purchase purchase : purchases) {
+ handlePurchase(purchase, responseCode);
+ }
+ return;
+ case BillingResponse.USER_CANCELED:
+ logSafely("User canceled the purchase code: " + responseCode);
+ purchaseListener.failure(pendingProduct, getPurchaseError(responseCode));
+ clearPendingPurchase();
+ return;
+ default:
+ logSafely("Error purchasing item with code: " + responseCode);
+ purchaseListener.failure(pendingProduct, getPurchaseError(responseCode));
+ clearPendingPurchase();
+ }
+ }
+
+ private void handlePurchase(com.android.billingclient.api.Purchase purchase, int responseCode) {
+ // Convert Billing Client purchase model to internal Cashier purchase model
+ try {
+ Purchase cashierPurchase = GooglePlayBillingPurchase.create(pendingProduct, purchase);
+
+ // Check data signature matched with specified public key
+ if (!TextUtils.isEmpty(publicKey64)
+ && !GooglePlayBillingSecurity.verifySignature(publicKey64,
+ purchase.getOriginalJson(), purchase.getSignature())) {
+ logSafely("Local signature check failed!");
+ purchaseListener.failure(pendingProduct, new Error(PURCHASE_SUCCESS_RESULT_MALFORMED, responseCode));
+ clearPendingPurchase();
+ return;
+ }
+
+ logSafely("Successful purchase of " + purchase.getSku() + "!");
+ purchaseListener.success(cashierPurchase);
+ clearPendingPurchase();
+ } catch (JSONException error) {
+ logSafely("Error in parsing purchase response: " + purchase.getSku());
+ purchaseListener.failure(pendingProduct, new Error(PURCHASE_SUCCESS_RESULT_MALFORMED, responseCode));
+ clearPendingPurchase();
+ }
+ }
+
+ private void clearPendingPurchase() {
+ pendingProduct = null;
+ purchaseListener = null;
+ }
+
+ @Override
+ public synchronized void consume(@NonNull final Context context, @NonNull final Purchase purchase, @NonNull final ConsumeListener listener) {
+ Preconditions.checkNotNull(context, "Purchase is null");
+ Preconditions.checkNotNull(listener, "Consume listener is null");
+ throwIfUninitialized();
+
+ final Product product = purchase.product();
+ if (product.isSubscription()) {
+ throw new IllegalStateException("Cannot consume a subscription");
+ }
+
+ if (tokensToBeConsumed.contains(purchase.token())) {
+ // Purchase currently being consumed or already successfully consumed.
+ logSafely("Token was already scheduled to be consumed - skipping...");
+ listener.failure(purchase, new Error(VendorConstants.CONSUME_UNAVAILABLE, -1));
+ return;
+ }
+
+ logSafely("Consuming " + product.sku());
+ tokensToBeConsumed.add(purchase.token());
+
+ api.consumePurchase(purchase.token(), new ConsumeResponseListener() {
+ @Override
+ public void onConsumeResponse(int responseCode, String purchaseToken) {
+ if (responseCode == BillingResponse.OK) {
+ logSafely("Successfully consumed " + purchase.product().sku() + "!");
+ listener.success(purchase);
+ } else {
+ // Failure in consuming token, remove from the list so retry is possible
+ logSafely("Error consuming " + purchase.product().sku() + " with code "+responseCode);
+ tokensToBeConsumed.remove(purchaseToken);
+ listener.failure(purchase, getConsumeError(responseCode));
+ }
+ }
+ });
+ }
+
+ @Override
+ public void getInventory(@NonNull Context context, @Nullable Collection itemSkus, @Nullable Collection subSkus,
+ @NonNull InventoryListener listener) {
+ throwIfUninitialized();
+
+ logSafely("Getting inventory ...");
+ InventoryQuery.execute(api, listener, itemSkus, subSkus);
+ }
+
+ @Override
+ public void getProductDetails(@NonNull Context context, @NonNull final String sku, final boolean isSubscription,
+ @NonNull final ProductDetailsListener listener) {
+ throwIfUninitialized();
+
+ api.getSkuDetails(
+ isSubscription ? SkuType.SUBS : SkuType.INAPP,
+ Collections.singletonList(sku),
+ new SkuDetailsResponseListener() {
+ @Override
+ public void onSkuDetailsResponse(int responseCode, List skuDetailsList) {
+ if (responseCode == BillingResponse.OK && skuDetailsList.size() == 1) {
+ logSafely("Successfully got sku details for " + sku + "!");
+ listener.success(
+ GooglePlayBillingProduct.create(skuDetailsList.get(0), isSubscription ? SkuType.SUBS : SkuType.INAPP)
+ );
+ } else {
+ logSafely("Error getting sku details for " + sku + " with code "+responseCode);
+ listener.failure(getDetailsError(responseCode));
+ }
+ }
+ }
+ );
+ }
+
+ @Override
+ public void setLogger(Logger logger) {
+ this.logger = logger;
+ }
+
+ @Override
+ public boolean available() {
+ return available && api.available() && canPurchaseAnything();
+ }
+
+ @Override
+ public boolean canPurchase(Product product) {
+ if (!canPurchaseAnything()) {
+ return false;
+ }
+
+ if (product.isSubscription() && !canSubscribe) {
+ return false;
+ }
+
+ if (!product.isSubscription() && !canPurchaseItems) {
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
+ // Do nothing, {@link #onPurchasesUpdated} will be called for billing flow.
+ return false;
+ }
+
+ @Override
+ public Product getProductFrom(JSONObject json) throws JSONException {
+ // NOTE: This is not needed for the Google Play Billing Vendor
+ throw new UnsupportedOperationException("This is not supported with Google Play Billing Vendor.");
+ }
+
+ @Override
+ public Purchase getPurchaseFrom(JSONObject json) throws JSONException {
+ // NOTE: This is not needed for the Google Play Billing Vendor
+ throw new UnsupportedOperationException("This is not supported with Google Play Billing Vendor.");
+ }
+
+ private boolean canPurchaseAnything() {
+ return canPurchaseItems || canSubscribe;
+ }
+
+ private void logSafely(String message) {
+ if (logger == null || message == null) {
+ return;
+ }
+
+ logger.i(LOG_TAG, message);
+ }
+
+ private void logAndDisable(String message) {
+ logSafely(message);
+ available = false;
+ }
+
+ private Error getPurchaseError(int responseCode) {
+ final int code;
+ switch (responseCode) {
+ case BillingResponse.FEATURE_NOT_SUPPORTED:
+ case BillingResponse.SERVICE_DISCONNECTED:
+ case BillingResponse.SERVICE_UNAVAILABLE:
+ case BillingResponse.BILLING_UNAVAILABLE:
+ case BillingResponse.ITEM_UNAVAILABLE:
+ code = PURCHASE_UNAVAILABLE;
+ break;
+ case BillingResponse.USER_CANCELED:
+ code = PURCHASE_CANCELED;
+ break;
+ case BillingResponse.ITEM_ALREADY_OWNED:
+ code = PURCHASE_ALREADY_OWNED;
+ break;
+ case BillingResponse.ITEM_NOT_OWNED:
+ code = PURCHASE_NOT_OWNED;
+ break;
+ case BillingResponse.DEVELOPER_ERROR:
+ case BillingResponse.ERROR:
+ default:
+ code = PURCHASE_FAILURE;
+ break;
+ }
+
+ return new Error(code, responseCode);
+ }
+
+ private Error getConsumeError(int responseCode) {
+ final int code;
+ switch (responseCode) {
+ case BillingResponse.FEATURE_NOT_SUPPORTED:
+ case BillingResponse.SERVICE_DISCONNECTED:
+ case BillingResponse.BILLING_UNAVAILABLE:
+ case BillingResponse.ITEM_UNAVAILABLE:
+ code = CONSUME_UNAVAILABLE;
+ break;
+ case BillingResponse.USER_CANCELED:
+ code = CONSUME_CANCELED;
+ break;
+ case BillingResponse.ITEM_NOT_OWNED:
+ code = CONSUME_NOT_OWNED;
+ break;
+ case BillingResponse.DEVELOPER_ERROR:
+ case BillingResponse.ERROR:
+ default:
+ code = CONSUME_FAILURE;
+ break;
+ }
+
+ return new Error(code, responseCode);
+ }
+
+ private Error getDetailsError(int responseCode) {
+ final int code;
+ switch (responseCode) {
+ case BillingResponse.FEATURE_NOT_SUPPORTED:
+ case BillingResponse.SERVICE_DISCONNECTED:
+ case BillingResponse.SERVICE_UNAVAILABLE:
+ case BillingResponse.BILLING_UNAVAILABLE:
+ case BillingResponse.ITEM_UNAVAILABLE:
+ code = PRODUCT_DETAILS_UNAVAILABLE;
+ break;
+ case BillingResponse.USER_CANCELED:
+ case BillingResponse.ITEM_NOT_OWNED:
+ case BillingResponse.DEVELOPER_ERROR:
+ case BillingResponse.ERROR:
+ default:
+ code = PRODUCT_DETAILS_QUERY_FAILURE;
+ break;
+ }
+
+ return new Error(code, responseCode);
+ }
+
+ private void throwIfUninitialized() {
+ if (!api.available()) {
+ throw new IllegalStateException("Trying to do operation without initialized billing API");
+ }
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/InventoryQuery.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/InventoryQuery.java
new file mode 100644
index 0000000..be0ccca
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/InventoryQuery.java
@@ -0,0 +1,249 @@
+package com.getkeepsafe.cashier.billing;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.Inventory;
+import com.getkeepsafe.cashier.InventoryListener;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.Purchase;
+import com.getkeepsafe.cashier.Vendor;
+import com.getkeepsafe.cashier.VendorConstants;
+
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Inventory query helper class. Performs api calls to get requested products info and purchases.
+ * Since cashier purchase contains full product info, but Google Billing only returns order id and
+ * receipt, getSkuDetails call must be performed for all purchased skus.
+ */
+class InventoryQuery {
+
+ private Threading threading;
+
+ private InventoryListener listener;
+
+ private AbstractGooglePlayBillingApi api;
+
+ /**
+ * Inapp product details returned from async getSkuDetails call
+ * Non null value indicates that sku details query finished
+ */
+ private List inappSkuDetails = null;
+
+ /**
+ * Subscription product details returned from async getSkuDetails call
+ * Non null value indicates that sku details query finished
+ * */
+ private List subsSkuDetails = null;
+
+ /**
+ * List of purchases of both inapp and subscription types
+ * Non null value indicates that purchases query finished
+ */
+ private List purchases = null;
+
+ private Collection inappSkus;
+
+ private Collection subSkus;
+
+ private int inappResponseCode = 0;
+
+ private int subsResponseCode = 0;
+
+ private boolean notified = false;
+
+ /**
+ * Query inventory.
+ * @param api Google Play Billing API instance.
+ * @param listener Listener to deliver success / error.
+ * @param inappSkus List of product skus of item type to query. May be null.
+ * @param subSkus List of product skus of subscription type to query. May be null.
+ */
+ static void execute(@NonNull AbstractGooglePlayBillingApi api, @NonNull InventoryListener listener, @Nullable Collection inappSkus, @Nullable Collection subSkus) {
+ execute(new Threading(), api, listener, inappSkus, subSkus);
+ }
+
+ static void execute(@NonNull Threading threading, @NonNull AbstractGooglePlayBillingApi api, @NonNull InventoryListener listener, @Nullable Collection inappSkus, @Nullable Collection subSkus) {
+ new InventoryQuery(threading, api, listener, inappSkus, subSkus).execute();
+ }
+
+ private InventoryQuery(@NonNull Threading threading, @NonNull AbstractGooglePlayBillingApi api, @NonNull InventoryListener listener, @Nullable Collection inappSkus, @Nullable Collection subSkus) {
+ this.threading = threading;
+ this.api = api;
+ this.listener = listener;
+ this.inappSkus = inappSkus;
+ this.subSkus = subSkus;
+ }
+
+ private void execute() {
+ // Execute on new thread to avoid blocking UI thread
+ threading.runInBackground(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (!api.available()) {
+ listener.failure(new Vendor.Error(VendorConstants.INVENTORY_QUERY_UNAVAILABLE, -1));
+ return;
+ }
+
+ inappSkuDetails = null;
+ subsSkuDetails = null;
+ Set inappSkusToQuery = new HashSet<>();
+ Set subSkusToQuery = new HashSet<>();
+ boolean subscriptionsSupported = api.isBillingSupported(BillingClient.SkuType.SUBS) == BillingClient.BillingResponse.OK;
+
+ if (inappSkus != null) {
+ inappSkusToQuery.addAll(inappSkus);
+ }
+ if (subSkus != null) {
+ subSkusToQuery.addAll(subSkus);
+ }
+
+ // Get purchases of both types
+ List inappPurchases = api.getPurchases(BillingClient.SkuType.INAPP);
+ List subPurchases = subscriptionsSupported ? api.getPurchases(BillingClient.SkuType.SUBS)
+ : new ArrayList();
+
+ if (inappPurchases == null || subPurchases == null) {
+ // If any of two getPurchases call didn't return result, return error
+ listener.failure(new Vendor.Error(VendorConstants.INVENTORY_QUERY_FAILURE, -1));
+ return;
+ }
+
+ purchases = new ArrayList<>();
+ purchases.addAll(inappPurchases);
+ purchases.addAll(subPurchases);
+
+ // Add all inapp purchases skus to skus to be queried list
+ for (com.android.billingclient.api.Purchase inappPurchase : inappPurchases) {
+ inappSkusToQuery.add(inappPurchase.getSku());
+ }
+ // Add all subscription purchases skus to skus to be queried list
+ for (com.android.billingclient.api.Purchase subPurchase : subPurchases) {
+ subSkusToQuery.add(subPurchase.getSku());
+ }
+
+ if (inappSkusToQuery.size() > 0) {
+ // Perform async sku details query
+ api.getSkuDetails(BillingClient.SkuType.INAPP, new ArrayList(inappSkusToQuery), new SkuDetailsResponseListener() {
+ @Override
+ public void onSkuDetailsResponse(int responseCode, List skuDetailsList) {
+ inappSkuDetails = skuDetailsList != null ? skuDetailsList : new ArrayList();
+ inappResponseCode = responseCode;
+ // Check if other async operation finished
+ notifyIfReady();
+ }
+ });
+ } else {
+ inappSkuDetails = Collections.emptyList();
+ }
+
+ if (subSkusToQuery.size() > 0 && subscriptionsSupported) {
+ // Perform async sku details query
+ api.getSkuDetails(BillingClient.SkuType.SUBS, new ArrayList(subSkusToQuery), new SkuDetailsResponseListener() {
+ @Override
+ public void onSkuDetailsResponse(int responseCode, List skuDetailsList) {
+ subsSkuDetails = skuDetailsList != null ? skuDetailsList : new ArrayList();
+ subsResponseCode = responseCode;
+ // Check if other async operation finished
+ notifyIfReady();
+ }
+ });
+ } else {
+ subsSkuDetails = Collections.emptyList();
+ }
+
+ // Check if result may be delivered.
+ // Covers case with empty skus and purchase lists
+ notifyIfReady();
+
+ } catch (Exception e) {
+ listener.failure(new Vendor.Error(VendorConstants.INVENTORY_QUERY_UNAVAILABLE, -1));
+ }
+ }
+ });
+ }
+
+ private synchronized void notifyIfReady() {
+ // When all three variables are not null, all async operations are finished
+ // and result may be delivered to listener
+ if (purchases != null && inappSkuDetails != null && subsSkuDetails != null && !notified) {
+
+ if (inappResponseCode != BillingClient.BillingResponse.OK || subsResponseCode != BillingClient.BillingResponse.OK) {
+ // Deliver result on main thread
+ threading.runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ listener.failure(new Vendor.Error(VendorConstants.INVENTORY_QUERY_FAILURE, Math.max(inappResponseCode, subsResponseCode)));
+ }
+ });
+ notified = true;
+ return;
+ }
+
+ final Inventory inventory = new Inventory();
+
+ // Map of sku -> details
+ Map details = new HashMap<>();
+
+ for (SkuDetails itemDetails : inappSkuDetails) {
+ details.put(itemDetails.getSku(), itemDetails);
+ if (inappSkus != null && inappSkus.contains(itemDetails.getSku())) {
+ // Return product details only when requested in inappSkus param
+ inventory.addProduct(GooglePlayBillingProduct.create(itemDetails, BillingClient.SkuType.INAPP));
+ }
+ }
+
+ for (SkuDetails subDetail : subsSkuDetails) {
+ details.put(subDetail.getSku(), subDetail);
+ if (subSkus != null && subSkus.contains(subDetail.getSku())) {
+ // Return product details only when requested in subSkus param
+ inventory.addProduct(GooglePlayBillingProduct.create(subDetail, BillingClient.SkuType.SUBS));
+ }
+ }
+
+ for (com.android.billingclient.api.Purchase billingPurchase : purchases) {
+ SkuDetails skuDetails = details.get(billingPurchase.getSku());
+ if (skuDetails != null) {
+ Product product = GooglePlayBillingProduct.create(skuDetails, skuDetails.getType());
+ try {
+ Purchase purchase = GooglePlayBillingPurchase.create(product, billingPurchase);
+ inventory.addPurchase(purchase);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ // Deliver result on main thread
+ threading.runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ listener.failure(new Vendor.Error(VendorConstants.INVENTORY_QUERY_MALFORMED_RESPONSE, -1));
+ }
+ });
+ return;
+ }
+ }
+ }
+
+ // Deliver result on main thread
+ threading.runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ listener.success(inventory);
+ }
+ });
+ notified = true;
+ }
+ }
+}
diff --git a/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/Threading.java b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/Threading.java
new file mode 100644
index 0000000..1d7d1ff
--- /dev/null
+++ b/cashier-google-play-billing/src/main/java/com/getkeepsafe/cashier/billing/Threading.java
@@ -0,0 +1,23 @@
+package com.getkeepsafe.cashier.billing;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+class Threading {
+
+ private ExecutorService threadExecutor = Executors.newCachedThreadPool();
+
+ private Handler mainHandler = new Handler(Looper.getMainLooper());
+
+ void runInBackground(Runnable runnable) {
+ threadExecutor.execute(runnable);
+ }
+
+ void runOnMainThread(Runnable runnable) {
+ mainHandler.post(runnable);
+ }
+
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchaseTest.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchaseTest.java
new file mode 100644
index 0000000..dda40a6
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingPurchaseTest.java
@@ -0,0 +1,65 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.Purchase;
+
+import org.json.JSONException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class GooglePlayBillingPurchaseTest {
+
+ private static final String JSON_PURCHASED = "{ \""
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE+"\": "
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_PURCHASED+" }";
+
+ private static final String JSON_CANCELED = "{ \""
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE+"\": "
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_CANCELED+" }";
+
+ private static final String JSON_REFUNDED = "{ \""
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE+"\": "
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_REFUNDED+" }";
+
+ private static final String SIGNATURE = "1234567890";
+
+ @Test
+ public void create_purchased() throws JSONException {
+ Purchase billingPurchase = new TestPurchase(TestData.productInappA, JSON_PURCHASED, SIGNATURE);
+ GooglePlayBillingPurchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, billingPurchase);
+
+ assertEquals(billingPurchase.getOrderId(), purchase.orderId());
+ assertEquals(billingPurchase.getPurchaseToken(), purchase.token());
+ assertEquals(billingPurchase.getSku(), purchase.product().sku());
+ assertEquals(JSON_PURCHASED, purchase.receipt());
+ assertTrue(purchase.purchased());
+ }
+
+ @Test
+ public void create_canceled() throws JSONException {
+ Purchase billingPurchase = new TestPurchase(TestData.productInappA, JSON_CANCELED, SIGNATURE);
+ GooglePlayBillingPurchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, billingPurchase);
+
+ assertEquals(billingPurchase.getOrderId(), purchase.orderId());
+ assertEquals(billingPurchase.getPurchaseToken(), purchase.token());
+ assertEquals(billingPurchase.getSku(), purchase.product().sku());
+ assertEquals(JSON_CANCELED, purchase.receipt());
+ assertTrue(purchase.canceled());
+ }
+
+ @Test
+ public void create_refunded() throws JSONException {
+ Purchase billingPurchase = new TestPurchase(TestData.productInappA, JSON_REFUNDED, SIGNATURE);
+ GooglePlayBillingPurchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, billingPurchase);
+
+ assertEquals(billingPurchase.getOrderId(), purchase.orderId());
+ assertEquals(billingPurchase.getPurchaseToken(), purchase.token());
+ assertEquals(billingPurchase.getSku(), purchase.product().sku());
+ assertEquals(JSON_REFUNDED, purchase.receipt());
+ assertTrue(purchase.refunded());
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurityTest.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurityTest.java
new file mode 100644
index 0000000..c28a5c8
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingSecurityTest.java
@@ -0,0 +1,73 @@
+package com.getkeepsafe.cashier.billing;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.security.PrivateKey;
+
+import static com.getkeepsafe.cashier.billing.TestData.TEST_PRIVATE_KEY;
+import static com.getkeepsafe.cashier.billing.TestData.TEST_PUBLIC_KEY;
+import static com.google.common.truth.Truth.assertThat;
+
+@RunWith(RobolectricTestRunner.class)
+public class GooglePlayBillingSecurityTest {
+
+ final String purchaseData = "{\"autoRenewing\":false," +
+ "\"orderId\":\"7429c5e9-f8e7-4332-b39d-60ce2c215fef\"," +
+ "\"packageName\":\"com.getkeepsafe.cashier.sample\"," +
+ "\"productId\":\"android.test.purchased\"," +
+ "\"purchaseTime\":1476077957823," +
+ "\"purchaseState\":0," +
+ "\"developerPayload\":\"hello-cashier!\"," +
+ "\"purchaseToken\":\"15d12f9b-82fc-4977-b49c-aef730a10463\"}";
+
+ final String purchaseSignature =
+ "kqxUG9i+Omsm73jYjNBVppC9wpjQxLecl6jF8so0PLhwDnTElHuCFLXGlmCwT1pL70M3ZTkgGRxR\n" +
+ "vUqzn4utYbtWlfg4ASzLLahQbH3tZSQhD2KKvoy2BOTWTyi2XoqcftHS3qL+HgiSTEkxoxLyCyly\n" +
+ "lNCSpPICv1DZEayAjLU=\n";
+
+ @Test
+ public void testPrivateKeyInValidFormat() {
+ GooglePlayBillingSecurity.createPrivateKey(TEST_PRIVATE_KEY);
+ }
+
+ @Test
+ public void testPublicKeyInValidFormat() {
+ GooglePlayBillingSecurity.createPublicKey(TEST_PUBLIC_KEY);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void signDataWithNoKeyOrDataThrows() {
+ GooglePlayBillingSecurity.sign("", "");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void signDataWithNoDataThrows() {
+ GooglePlayBillingSecurity.sign(GooglePlayBillingSecurity.createPrivateKey(TEST_PRIVATE_KEY), "");
+ }
+
+ @Test
+ public void signsData() {
+ final PrivateKey privateKey = GooglePlayBillingSecurity.createPrivateKey(TEST_PRIVATE_KEY);
+ assertThat(GooglePlayBillingSecurity.sign(privateKey, "test")).isEqualTo(
+ "kUQ84k0Xr04JfpbNggZFmKHLgm2TLj3kCteV5N4OFCO2iFj6o+JSB/fufNjtAIiA8UglX3D1Bl9S\n" +
+ "tDgmqaS1pAU5HKRFF+ZPldPZve6QghHfQ9mm1eGZfdDTD2U2TDDMB3FFb4lEQbnCDa6d25cE8qJi\n" +
+ "LaclWepyd6tm4i500JM=\n");
+ }
+
+ @Test
+ public void signsPurchaseData() {
+ assertThat(GooglePlayBillingSecurity.sign(TEST_PRIVATE_KEY, purchaseData)).isEqualTo(purchaseSignature);
+ }
+
+ @Test
+ public void verifySignatureWithNoDataReturnsFalse() {
+ assertThat(GooglePlayBillingSecurity.verifySignature("", null, "")).isFalse();
+ }
+
+ @Test
+ public void verifiesSignatures() {
+ assertThat(GooglePlayBillingSecurity.verifySignature(TEST_PUBLIC_KEY, purchaseData, purchaseSignature)).isTrue();
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendorTest.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendorTest.java
new file mode 100644
index 0000000..e699a50
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/GooglePlayBillingVendorTest.java
@@ -0,0 +1,439 @@
+package com.getkeepsafe.cashier.billing;
+
+import android.app.Activity;
+import android.content.Context;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.ConsumeResponseListener;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.ConsumeListener;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.ProductDetailsListener;
+import com.getkeepsafe.cashier.Purchase;
+import com.getkeepsafe.cashier.PurchaseListener;
+import com.getkeepsafe.cashier.Vendor;
+import com.getkeepsafe.cashier.VendorConstants;
+import com.getkeepsafe.cashier.billing.AbstractGooglePlayBillingApi.LifecycleListener;
+import com.getkeepsafe.cashier.logging.Logger;
+
+import org.json.JSONException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.List;
+
+import edu.emory.mathcs.backport.java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.eq;
+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;
+
+@RunWith(RobolectricTestRunner.class)
+public class GooglePlayBillingVendorTest {
+
+ @Mock
+ AbstractGooglePlayBillingApi api;
+
+ @Mock
+ Context context;
+
+ @Mock
+ Activity activity;
+
+ @Mock
+ Logger logger;
+
+ @Before
+ public void setup() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ @Test
+ public void initialize_successfully() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ mockSuccessfulInitialization(vendor);
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+
+ verify(api).initialize(eq(context), eq(vendor), eq(vendor), eq(logger));
+ verify(initializationListener).initialized();
+ assertTrue(vendor.available());
+ assertTrue(vendor.canPurchase(TestData.productInappA));
+ assertTrue(vendor.canPurchase(TestData.productSubA));
+ }
+
+ @Test
+ public void initialize_failure() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ when(api.initialize(eq(context), eq(vendor), eq(vendor), any(Logger.class))).thenAnswer(new Answer() {
+ @Override
+ public Boolean answer(InvocationOnMock invocation) {
+ LifecycleListener listener = invocation.getArgument(2);
+ listener.initialized(false);
+ return false;
+ }
+ });
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+
+ verify(api).initialize(eq(context), eq(vendor), eq(vendor), eq(logger));
+ verify(initializationListener, never()).initialized();
+ verify(initializationListener).unavailable();
+ assertFalse(vendor.available());
+ assertFalse(vendor.canPurchase(TestData.productInappA));
+ assertFalse(vendor.canPurchase(TestData.productSubA));
+ }
+
+ @Test
+ public void initialize_successfully_when_subs_not_available() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ mockSuccessfulInitialization(vendor);
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+
+ verify(api).initialize(eq(context), eq(vendor), eq(vendor), eq(logger));
+ verify(initializationListener).initialized();
+ assertTrue(vendor.available());
+ assertTrue(vendor.canPurchase(TestData.productInappA));
+ assertFalse(vendor.canPurchase(TestData.productSubA));
+ }
+
+ @Test
+ public void initialize_when_cannot_buy_anything() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ mockSuccessfulInitialization(vendor);
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+
+ verify(api).initialize(eq(context), eq(vendor), eq(vendor), eq(logger));
+ verify(initializationListener).initialized();
+ assertFalse(vendor.available());
+ assertFalse(vendor.canPurchase(TestData.productInappA));
+ assertFalse(vendor.canPurchase(TestData.productSubA));
+ }
+
+ @Test
+ public void do_not_reinitialize() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ mockSuccessfulInitialization(vendor);
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+ vendor.initialize(context, initializationListener);
+ vendor.initialize(context, initializationListener);
+
+ verify(api, times(1)).initialize(eq(context), eq(vendor), eq(vendor), eq(logger));
+ }
+
+
+ @Test
+ public void purchase_successfully() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+ mockApiPurchaseSuccess(vendor, TestData.productInappA, true);
+
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+
+ ArgumentCaptor argument = ArgumentCaptor.forClass(Purchase.class);
+ verify(listener).success(argument.capture());
+
+ assertEquals( TestData.productInappA.sku(), argument.getValue().product().sku() );
+
+ // Should be able to make another purchase now
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+ }
+
+ @Test
+ public void purchase_with_api_error() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+ mockApiPurchaseFailure(vendor, TestData.productInappA, BillingClient.BillingResponse.SERVICE_UNAVAILABLE);
+
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ ArgumentCaptor argumentProduct = ArgumentCaptor.forClass(Product.class);
+ verify(listener).failure(argumentProduct.capture(), argumentError.capture());
+
+ assertEquals( TestData.productInappA.sku(), argumentProduct.getValue().sku() );
+ assertEquals(VendorConstants.PURCHASE_UNAVAILABLE, argumentError.getValue().code);
+
+ // Should be able to make another purchase now
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+ }
+
+ @Test
+ public void purchase_user_canceled() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+ mockApiPurchaseFailure(vendor, TestData.productInappA, BillingClient.BillingResponse.USER_CANCELED);
+
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ ArgumentCaptor argumentProduct = ArgumentCaptor.forClass(Product.class);
+ verify(listener).failure(argumentProduct.capture(), argumentError.capture());
+
+ assertEquals( TestData.productInappA.sku(), argumentProduct.getValue().sku() );
+ assertEquals(VendorConstants.PURCHASE_CANCELED, argumentError.getValue().code);
+
+ // Should be able to make another purchase now
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+ }
+
+ @Test
+ public void purchase_with_signature_error() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+ mockApiPurchaseSuccess(vendor, TestData.productInappA, false);
+
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ ArgumentCaptor argumentProduct = ArgumentCaptor.forClass(Product.class);
+ verify(listener).failure(argumentProduct.capture(), argumentError.capture());
+
+ assertEquals( TestData.productInappA.sku(), argumentProduct.getValue().sku() );
+ assertEquals(VendorConstants.PURCHASE_SUCCESS_RESULT_MALFORMED, argumentError.getValue().code);
+
+ // Should be able to make another purchase now
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void purchase_while_another_purchase_in_progress() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+
+ vendor.purchase(activity, TestData.productInappA, null, listener);
+
+ vendor.purchase(activity, TestData.productInappB, null, listener);
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void purchase_developer_payload_not_supported() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ PurchaseListener listener = mock(PurchaseListener.class);
+ mockApiPurchaseSuccess(vendor, TestData.productInappA, true);
+
+ vendor.purchase(activity, TestData.productInappA, "DEV PAYLOAD", listener);
+ }
+
+ @Test
+ public void consume() throws JSONException {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ ConsumeListener listener = mock(ConsumeListener.class);
+ Purchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, new TestPurchase(TestData.productInappA));
+ mockConsume(vendor, BillingClient.BillingResponse.OK);
+
+ vendor.consume(context, purchase, listener);
+
+ verify(listener).success(purchase);
+ }
+
+ @Test()
+ public void cannot_consume_twice() throws JSONException {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ ConsumeListener listener = mock(ConsumeListener.class);
+ Purchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, new TestPurchase(TestData.productInappA));
+ mockConsume(vendor, BillingClient.BillingResponse.OK);
+
+ vendor.consume(context, purchase, listener);
+ verify(listener).success(purchase);
+
+ vendor.consume(context, purchase, listener);
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ verify(listener).failure(eq(purchase), argumentError.capture());
+ assertEquals(VendorConstants.CONSUME_UNAVAILABLE, argumentError.getValue().code);
+ }
+
+ @Test
+ public void consume_error() throws JSONException {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ ConsumeListener listener = mock(ConsumeListener.class);
+ Purchase purchase = GooglePlayBillingPurchase.create(TestData.productInappA, new TestPurchase(TestData.productInappA));
+ mockConsume(vendor, BillingClient.BillingResponse.ITEM_NOT_OWNED);
+
+ vendor.consume(context, purchase, listener);
+
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ verify(listener).failure(eq(purchase), argumentError.capture());
+ assertEquals(VendorConstants.CONSUME_NOT_OWNED, argumentError.getValue().code);
+ }
+
+ @Test
+ public void get_product_details() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ ProductDetailsListener listener = mock(ProductDetailsListener.class);
+ doAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ List skus = invocation.getArgument(1);
+ SkuDetailsResponseListener responseListener = invocation.getArgument(2);
+
+ assertEquals(1, skus.size());
+
+ responseListener.onSkuDetailsResponse(BillingClient.BillingResponse.OK,
+ Collections.singletonList( TestData.getSkuDetail(skus.get(0)) ));
+ return null;
+ }
+ }).when(api).getSkuDetails(eq(BillingClient.SkuType.INAPP), ArgumentMatchers.anyList(), any(SkuDetailsResponseListener.class));
+
+ vendor.getProductDetails(context, TestData.productInappA.sku(), false, listener);
+
+ ArgumentCaptor> argumentSkus = ArgumentCaptor.forClass(List.class);
+ verify(api).getSkuDetails(eq(BillingClient.SkuType.INAPP), argumentSkus.capture(), any(SkuDetailsResponseListener.class));
+ assertEquals(1, argumentSkus.getValue().size());
+ assertEquals(TestData.productInappA.sku(), argumentSkus.getValue().get(0));
+
+ ArgumentCaptor argumentProduct = ArgumentCaptor.forClass(Product.class);
+ verify(listener).success(argumentProduct.capture());
+ assertEquals(TestData.productInappA, argumentProduct.getValue());
+ }
+
+ @Test
+ public void get_product_details_failure() {
+ GooglePlayBillingVendor vendor = successfullyInitializedVendor();
+ ProductDetailsListener listener = mock(ProductDetailsListener.class);
+ doAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ List skus = invocation.getArgument(1);
+ SkuDetailsResponseListener responseListener = invocation.getArgument(2);
+
+ assertEquals(1, skus.size());
+
+ responseListener.onSkuDetailsResponse(BillingClient.BillingResponse.SERVICE_UNAVAILABLE, null);
+ return null;
+ }
+ }).when(api).getSkuDetails(eq(BillingClient.SkuType.INAPP), ArgumentMatchers.anyList(), any(SkuDetailsResponseListener.class));
+
+ vendor.getProductDetails(context, TestData.productInappA.sku(), false, listener);
+
+ ArgumentCaptor argumentError = ArgumentCaptor.forClass(Vendor.Error.class);
+ verify(listener).failure(argumentError.capture());
+ assertEquals(VendorConstants.PRODUCT_DETAILS_UNAVAILABLE, argumentError.getValue().code);
+ }
+
+
+ private void mockSuccessfulInitialization(GooglePlayBillingVendor vendor) {
+ when(api.initialize(eq(context), eq(vendor), eq(vendor), any(Logger.class))).thenAnswer(new Answer() {
+ @Override
+ public Boolean answer(InvocationOnMock invocation) {
+ LifecycleListener listener = invocation.getArgument(2);
+ listener.initialized(true);
+ return true;
+ }
+ });
+ }
+
+ private void mockApiPurchaseSuccess(final GooglePlayBillingVendor vendor, final Product product, final boolean validSignature) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ vendor.onPurchasesUpdated(BillingClient.BillingResponse.OK,
+ Collections.singletonList(
+ validSignature ? new TestPurchase(product) : new TestPurchase(product, "INVALID")
+ )
+ );
+ return null;
+ }
+ }
+ ).when(api).launchBillingFlow(activity, product.sku(), BillingClient.SkuType.INAPP);
+ }
+
+ private void mockApiPurchaseFailure(final GooglePlayBillingVendor vendor, final Product product, final int responseCode) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ vendor.onPurchasesUpdated(responseCode, null);
+ return null;
+ }
+ }
+ ).when(api).launchBillingFlow(activity, product.sku(), BillingClient.SkuType.INAPP);
+ }
+
+ private void mockConsume(final GooglePlayBillingVendor vendor, final int responseCode) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ String token = invocation.getArgument(0);
+ ConsumeResponseListener listener = invocation.getArgument(1);
+ listener.onConsumeResponse(responseCode, token);
+ return null;
+ }
+ }
+ ).when(api).consumePurchase(anyString(), any(ConsumeResponseListener.class));
+ }
+
+ GooglePlayBillingVendor successfullyInitializedVendor() {
+ GooglePlayBillingVendor vendor = new GooglePlayBillingVendor(api, TestData.TEST_PUBLIC_KEY);
+ vendor.setLogger(logger);
+ Vendor.InitializationListener initializationListener = mock(Vendor.InitializationListener.class);
+
+ when(api.initialize(eq(context), eq(vendor), eq(vendor), any(Logger.class))).thenAnswer(new Answer() {
+ @Override
+ public Boolean answer(InvocationOnMock invocation) {
+ LifecycleListener listener = invocation.getArgument(2);
+ listener.initialized(true);
+ return true;
+ }
+ });
+ when(api.isBillingSupported(BillingClient.SkuType.INAPP)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.OK);
+ when(api.available()).thenReturn(true);
+
+ vendor.initialize(context, initializationListener);
+
+ return vendor;
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/InventoryQueryTest.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/InventoryQueryTest.java
new file mode 100644
index 0000000..18d9503
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/InventoryQueryTest.java
@@ -0,0 +1,190 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.Inventory;
+import com.getkeepsafe.cashier.InventoryListener;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.Vendor;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.ArrayList;
+
+import edu.emory.mathcs.backport.java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+public class InventoryQueryTest {
+
+ @Mock
+ AbstractGooglePlayBillingApi api;
+
+ @Before
+ public void setup() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ when(api.available()).thenReturn(true);
+ when(api.getPurchases()).thenReturn(new ArrayList());
+ when(api.getPurchases(anyString())).thenReturn(new ArrayList());
+
+ TestHelper.mockSkuDetails(api, BillingClient.SkuType.INAPP, TestData.getSkuDetailsMap(TestData.allInAppSkus));
+ TestHelper.mockSkuDetails(api, BillingClient.SkuType.SUBS, TestData.getSkuDetailsMap(TestData.allSubSkus));
+ TestHelper.mockPurchases(api, Collections.singletonList(TestData.productInappA));
+ }
+
+ @Test
+ public void returns_inventory() {
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+ ArgumentCaptor argument = ArgumentCaptor.forClass(Inventory.class);
+
+ // Check if API gets called correctly
+ verify(api).getPurchases(BillingClient.SkuType.SUBS);
+ verify(api).getPurchases(BillingClient.SkuType.INAPP);
+ verify(api).getSkuDetails(eq(BillingClient.SkuType.SUBS), eq(TestData.allSubSkus), any(SkuDetailsResponseListener.class));
+ verify(api).getSkuDetails(eq(BillingClient.SkuType.INAPP), eq(TestData.allInAppSkus), any(SkuDetailsResponseListener.class));
+
+ // Check if result is delivered
+ verify(listener).success(argument.capture());
+ assertEquals(TestData.allProducts.size(), argument.getValue().products().size());
+ assertEquals(1, argument.getValue().purchases().size());
+ for (Product product : argument.getValue().products()) {
+ assertTrue(TestData.allProducts.contains(product));
+ }
+ }
+
+ @Test
+ public void returns_error_when_inapp_purchases_call_fails() {
+ when(api.getPurchases(BillingClient.SkuType.INAPP)).thenReturn(null);
+ when(api.getPurchases(BillingClient.SkuType.SUBS)).thenReturn(new ArrayList());
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+
+ verify(listener).failure(any(Vendor.Error.class));
+ }
+
+ @Test
+ public void returns_error_when_sub_purchases_call_fails() {
+ when(api.getPurchases(BillingClient.SkuType.SUBS)).thenReturn(null);
+ when(api.getPurchases(BillingClient.SkuType.INAPP)).thenReturn(new ArrayList());
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+
+ verify(listener).failure(any(Vendor.Error.class));
+ }
+
+ @Test
+ public void returns_error_when_inapp_sku_details_call_fails() {
+ TestHelper.mockSkuDetailsError(api, BillingClient.SkuType.INAPP);
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+
+ verify(listener).failure(any(Vendor.Error.class));
+ }
+
+ @Test
+ public void returns_error_when_subs_sku_details_call_fails() {
+ TestHelper.mockSkuDetailsError(api, BillingClient.SkuType.SUBS);
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+
+ verify(listener).failure(any(Vendor.Error.class));
+ }
+
+ @Test
+ public void returns_error_when_billing_not_available() {
+ TestHelper.mockApiUnavailable(api);
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+
+ verify(listener).failure(any(Vendor.Error.class));
+ }
+
+ @Test
+ public void returns_inventory_when_subs_feature_not_available() {
+ when(api.isBillingSupported(BillingClient.SkuType.SUBS)).thenReturn(BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED);
+
+ InventoryListener listener = mock(InventoryListener.class);
+ InventoryQuery.execute(
+ TestHelper.mockThreading(),
+ api,
+ listener,
+ TestData.allInAppSkus,
+ TestData.allSubSkus
+ );
+ ArgumentCaptor argument = ArgumentCaptor.forClass(Inventory.class);
+
+ // Check if API gets called correctly
+ verify(api).getPurchases(BillingClient.SkuType.INAPP);
+ verify(api, never()).getPurchases(BillingClient.SkuType.SUBS);
+ verify(api).getSkuDetails(eq(BillingClient.SkuType.INAPP), eq(TestData.allInAppSkus), any(SkuDetailsResponseListener.class));
+ verify(api, Mockito.never()).getSkuDetails(eq(BillingClient.SkuType.SUBS), eq(TestData.allSubSkus), any(SkuDetailsResponseListener.class));
+
+ // Check if result is delivered
+ verify(listener).success(argument.capture());
+ assertEquals(TestData.allInAppProducts.size(), argument.getValue().products().size());
+ assertEquals(1, argument.getValue().purchases().size());
+ for (Product product : argument.getValue().products()) {
+ assertTrue(TestData.allProducts.contains(product));
+ }
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestData.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestData.java
new file mode 100644
index 0000000..362f086
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestData.java
@@ -0,0 +1,118 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.SkuDetails;
+import com.getkeepsafe.cashier.Product;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class TestData {
+
+ static final String TEST_PRIVATE_KEY =
+ "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALXolIcA1LIcYDnO\n" +
+ "2nfalbkOD2UAQ3KfqsdEGLddG2rW8Cyl2LIyiWVvQ6bp2q5qBoYCds9lBQT21uo1\n" +
+ "VHTcv4mnaLfdBjMlzecrK8y1FzRLKFXyoMqiau8wunFeqFsdzHQ774PbYyNgMGdr\n" +
+ "zUDXqIdQONL8Eq/0pgddk07uNxwbAgMBAAECgYAJInvK57zGkOw4Gu4XlK9uEomt\n" +
+ "Xb0FVYVC6mV/V7qXu+FlrJJcKHOD13mDOT0VAxf+xMLomT8OR8L1EeaC087+aeza\n" +
+ "twYUVx4d+J0cQ8xo3ILwY5Bg4/Y4R0gIbdKupHbhPKaLSAiMxilNKqNfY8upT2X/\n" +
+ "S4OFDDbm7aK8SlGPEQJBAN+YlMb4PS54aBpWgeAP8fzgtOL0Q157bmoQyCokiWv3\n" +
+ "OGa89LraifCtlsqmmAxyFbPzO2cFHYvzzEeU86XZVFkCQQDQRWQ0QJKJsfqxEeYG\n" +
+ "rq9e3TkY8uQeHz8BmgxRcYC0v43bl9ggAJAzh9h9o0X9da1YzkoQ0/cWUp5NK95F\n" +
+ "93WTAkEAxqm1/rcO/RwEOuqDyIXCVxF8Bm5K8UawCtNQVYlTBDeKyFW5B9AmYU6K\n" +
+ "vRGZ5Oz0dYd2TwlPgEqkRTGF7eSUOQJAfyK85oC8cz2oMMsiRdYAy8Hzht1Oj2y3\n" +
+ "g3zMJDNLRArix7fLgM2XOT2l1BwFL5HUPa+/2sHpxUCtzaIHz2Id7QJATyF+fzUR\n" +
+ "eVw04ogIsOIdG0ECrN5/3g9pQnAjxcReQ/4KVCpIE8lQFYjAzQYUkK9VOjX9LYp9\n" +
+ "DGEnpooCco1ZjA==";
+
+ static final String TEST_PUBLIC_KEY =
+ "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC16JSHANSyHGA5ztp32pW5Dg9l\n" +
+ "AENyn6rHRBi3XRtq1vAspdiyMollb0Om6dquagaGAnbPZQUE9tbqNVR03L+Jp2i3\n" +
+ "3QYzJc3nKyvMtRc0SyhV8qDKomrvMLpxXqhbHcx0O++D22MjYDBna81A16iHUDjS\n" +
+ "/BKv9KYHXZNO7jccGwIDAQAB";
+
+ public static Product productInappA = Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ "com.test.product.inapp.A",
+ "$0.99",
+ "USD",
+ "Test product A",
+ "This is a test product",
+ false,
+ 990_000L);
+
+ public static Product productInappB = Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ "com.test.product.inapp.N",
+ "$123.99",
+ "USD",
+ "Test product B",
+ "This is another test product",
+ false,
+ 123990_000L);
+
+ public static Product productSubA = Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ "com.test.product.sub.A",
+ "$123.99",
+ "USD",
+ "Test subscription A",
+ "This is test subscription",
+ true,
+ 123990_000L);
+
+ public static List allInAppProducts = new ArrayList<>();
+
+ public static List allSubProducts = new ArrayList<>();
+
+ public static List allProducts = new ArrayList<>();
+
+ public static List allInAppSkus = new ArrayList<>();
+
+ public static List allSubSkus = new ArrayList<>();
+
+ public static List allSkus = new ArrayList<>();
+
+ public static Map productsMap = new HashMap<>();
+
+ static {
+ allInAppProducts.add(productInappA);
+ allInAppProducts.add(productInappB);
+
+ allSubProducts.add(productSubA);
+
+ allInAppSkus.add(productInappA.sku());
+ allInAppSkus.add(productInappB.sku());
+
+ allSubSkus.add(productSubA.sku());
+
+ allProducts.addAll(allInAppProducts);
+ allProducts.addAll(allSubProducts);
+
+ allSkus.addAll(allInAppSkus);
+ allSkus.addAll(allSubSkus);
+
+ for (Product product : allProducts) {
+ productsMap.put(product.sku(), product);
+ }
+ }
+
+ static SkuDetails getSkuDetail(String sku) {
+ try {
+ return new TestSkuDetails(productsMap.get(sku));
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ static Map getSkuDetailsMap(List skus) {
+ Map map = new HashMap<>();
+ for (String sku : skus) {
+ try {
+ map.put(sku, new TestSkuDetails(productsMap.get(sku)));
+ } catch (Exception e) {}
+ }
+ return map;
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestHelper.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestHelper.java
new file mode 100644
index 0000000..dd21c37
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestHelper.java
@@ -0,0 +1,115 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsResponseListener;
+import com.getkeepsafe.cashier.Product;
+
+import org.json.JSONException;
+import org.mockito.Mockito;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class TestHelper {
+
+ static Threading mockThreading() {
+ Threading mock = mock(Threading.class);
+ Answer executeAnswer = new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ Runnable runnable = invocation.getArgument(0);
+ runnable.run();
+ return null;
+ }
+ };
+ doAnswer(executeAnswer).when(mock).runOnMainThread(any(Runnable.class));
+ doAnswer(executeAnswer).when(mock).runInBackground(any(Runnable.class));
+ return mock;
+ }
+
+ static void mockSkuDetails(AbstractGooglePlayBillingApi api, String type, final Map skuDetailsMap) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ List skus = invocation.getArgument(1);
+ SkuDetailsResponseListener listener = invocation.getArgument(2);
+ List result = new ArrayList<>();
+ for (String sku : skus) {
+ result.add(skuDetailsMap.get(sku));
+ }
+ listener.onSkuDetailsResponse(BillingClient.BillingResponse.OK, result);
+ return null;
+ }
+ }
+ ).when(api).getSkuDetails(
+ eq(type),
+ Mockito.anyList(),
+ any(SkuDetailsResponseListener.class));
+ }
+
+ static void mockSkuDetailsError(AbstractGooglePlayBillingApi api, String type) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ SkuDetailsResponseListener listener = invocation.getArgument(2);
+ listener.onSkuDetailsResponse(BillingClient.BillingResponse.SERVICE_UNAVAILABLE, null);
+ return null;
+ }
+ }
+ ).when(api).getSkuDetails(
+ eq(type),
+ Mockito.anyList(),
+ any(SkuDetailsResponseListener.class));
+ }
+
+ static void mockApiUnavailable(AbstractGooglePlayBillingApi api) {
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ throw new IllegalStateException("Billing client is not available");
+ }
+ }
+ ).when(api).getSkuDetails(
+ anyString(),
+ Mockito.anyList(),
+ any(SkuDetailsResponseListener.class));
+
+ when(api.available()).thenReturn(false);
+ when(api.getPurchases(anyString())).thenThrow(new IllegalStateException("Billing client is not available"));
+ }
+
+ static void mockPurchases(AbstractGooglePlayBillingApi api, final List products) {
+ List purchases = new ArrayList<>();
+ List inapp = new ArrayList<>();
+ List subs = new ArrayList<>();
+ for (Product product : products) {
+ try {
+ Purchase purchase = new TestPurchase(product);
+ purchases.add(purchase);
+ if (product.isSubscription()) {
+ subs.add(purchase);
+ } else {
+ inapp.add(purchase);
+ }
+ } catch (JSONException e) {}
+ }
+ when(api.getPurchases()).thenReturn(purchases);
+ when(api.getPurchases(BillingClient.SkuType.INAPP)).thenReturn(inapp);
+ when(api.getPurchases(BillingClient.SkuType.SUBS)).thenReturn(subs);
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestPurchase.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestPurchase.java
new file mode 100644
index 0000000..c0dc65d
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestPurchase.java
@@ -0,0 +1,45 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.Purchase;
+import com.getkeepsafe.cashier.Product;
+
+import org.json.JSONException;
+
+public class TestPurchase extends Purchase {
+
+ private static String TEST_JSON = "{ \""
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE+"\": "
+ +GooglePlayBillingConstants.PurchaseConstants.PURCHASE_STATE_PURCHASED+" }";
+
+ private Product product;
+
+ public TestPurchase(Product product) throws JSONException {
+ super(TEST_JSON, GooglePlayBillingSecurity.sign(TestData.TEST_PRIVATE_KEY, TEST_JSON));
+ this.product = product;
+ }
+
+ public TestPurchase(Product product, String signature) throws JSONException {
+ super(TEST_JSON, signature);
+ this.product = product;
+ }
+
+ public TestPurchase(Product product, String json, String signature) throws JSONException {
+ super(json, signature);
+ this.product = product;
+ }
+
+ @Override
+ public String getSku() {
+ return product.sku();
+ }
+
+ @Override
+ public String getOrderId() {
+ return "test-order-id";
+ }
+
+ @Override
+ public String getPurchaseToken() {
+ return "test-purchase-token";
+ }
+}
diff --git a/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestSkuDetails.java b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestSkuDetails.java
new file mode 100644
index 0000000..603f87a
--- /dev/null
+++ b/cashier-google-play-billing/src/test/java/com/getkeepsafe/cashier/billing/TestSkuDetails.java
@@ -0,0 +1,56 @@
+package com.getkeepsafe.cashier.billing;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.SkuDetails;
+import com.getkeepsafe.cashier.Product;
+
+import org.json.JSONException;
+
+/**
+ * Fake sku details query result. SkuDetails requires json to be constructed,
+ * this class overrides mostly used fields to read values from Product directly.
+ */
+public class TestSkuDetails extends SkuDetails {
+
+ private Product product;
+
+ public TestSkuDetails(Product product) throws JSONException {
+ super("{}");
+ this.product = product;
+ }
+
+ @Override
+ public String getTitle() {
+ return product.name();
+ }
+
+ @Override
+ public String getDescription() {
+ return product.description();
+ }
+
+ @Override
+ public String getSku() {
+ return product.sku();
+ }
+
+ @Override
+ public String getType() {
+ return product.isSubscription() ? BillingClient.SkuType.SUBS : BillingClient.SkuType.INAPP;
+ }
+
+ @Override
+ public String getPrice() {
+ return product.price();
+ }
+
+ @Override
+ public long getPriceAmountMicros() {
+ return product.microsPrice();
+ }
+
+ @Override
+ public String getPriceCurrencyCode() {
+ return product.currency();
+ }
+}
diff --git a/cashier-iab/src/main/java/com/getkeepsafe/cashier/iab/InAppBillingV3Vendor.java b/cashier-iab/src/main/java/com/getkeepsafe/cashier/iab/InAppBillingV3Vendor.java
index 08c425a..201b729 100644
--- a/cashier-iab/src/main/java/com/getkeepsafe/cashier/iab/InAppBillingV3Vendor.java
+++ b/cashier-iab/src/main/java/com/getkeepsafe/cashier/iab/InAppBillingV3Vendor.java
@@ -81,6 +81,11 @@
import static com.getkeepsafe.cashier.iab.InAppBillingConstants.RESPONSE_INAPP_SIGNATURE_LIST;
import static com.getkeepsafe.cashier.iab.InAppBillingConstants.VENDOR_PACKAGE;
+/**
+ * @deprecated Google In App Billing is no longer supported.
+ * Please use GooglePlayBillingVendor that uses new Google Play Billing library.
+ */
+@Deprecated
public class InAppBillingV3Vendor implements Vendor {
private final AbstractInAppBillingV3API api;
private final String publicKey64;
diff --git a/cashier-sample-google-play-billing/.gitignore b/cashier-sample-google-play-billing/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/cashier-sample-google-play-billing/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/cashier-sample-google-play-billing/build.gradle b/cashier-sample-google-play-billing/build.gradle
new file mode 100644
index 0000000..a345e0d
--- /dev/null
+++ b/cashier-sample-google-play-billing/build.gradle
@@ -0,0 +1,29 @@
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
+
+ defaultConfig {
+ applicationId "com.getkeepsafe.cashier.sample.googleplaybilling"
+ minSdkVersion 19
+ targetSdkVersion 28
+ versionCode 1
+ versionName "1.0"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+dependencies {
+ implementation project(':cashier-google-play-billing')
+ implementation project(':cashier-google-play-billing-debug')
+
+ implementation 'com.android.support:recyclerview-v7:28.0.0'
+
+ implementation deps.appCompat
+}
diff --git a/cashier-sample-google-play-billing/proguard-rules.pro b/cashier-sample-google-play-billing/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/cashier-sample-google-play-billing/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/cashier-sample-google-play-billing/src/main/AndroidManifest.xml b/cashier-sample-google-play-billing/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ea1d362
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/Item.java b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/Item.java
new file mode 100644
index 0000000..cdddd1f
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/Item.java
@@ -0,0 +1,29 @@
+package com.getkeepsafe.cashier.sample.googleplaybilling;
+
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.Purchase;
+
+public class Item {
+
+ public Product product;
+
+ public Purchase purchase;
+
+ public String title;
+
+ public String price;
+
+ public boolean isSubscription;
+
+ public boolean isPurchased;
+
+ public Item() {
+ }
+
+ public Item(String title, String price, boolean isSubscription, boolean isPurchased) {
+ this.title = title;
+ this.price = price;
+ this.isSubscription = isSubscription;
+ this.isPurchased = isPurchased;
+ }
+}
diff --git a/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/ItemsAdapter.java b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/ItemsAdapter.java
new file mode 100644
index 0000000..addec59
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/ItemsAdapter.java
@@ -0,0 +1,129 @@
+package com.getkeepsafe.cashier.sample.googleplaybilling;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.getkeepsafe.cashier.sample.googleplaybilling.ItemsAdapter.ItemViewHolder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ItemsAdapter extends RecyclerView.Adapter {
+
+ private List- items = new ArrayList<>();
+
+ private LayoutInflater layoutInflater;
+
+ private ItemListener itemListener;
+
+ public ItemsAdapter(Context context) {
+ layoutInflater = LayoutInflater.from(context);
+ }
+
+ public void setItems(List
- items) {
+ this.items = items;
+ notifyDataSetChanged();
+ }
+
+ public void setItemListener(ItemListener itemListener) {
+ this.itemListener = itemListener;
+ }
+
+ @NonNull
+ @Override
+ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
+ return new ItemViewHolder(layoutInflater.inflate(R.layout.view_item, viewGroup, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ItemViewHolder itemViewHolder, int position) {
+ itemViewHolder.bind(items.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ public class ItemViewHolder extends RecyclerView.ViewHolder {
+
+ private TextView title;
+
+ private TextView price;
+
+ private Button buy;
+
+ private Button use;
+
+ private Button info;
+
+ private Item item;
+
+ public ItemViewHolder(@NonNull final View view) {
+ super(view);
+ title = view.findViewById(R.id.item_text_title);
+ price = view.findViewById(R.id.item_text_price);
+ buy = view.findViewById(R.id.item_button_buy);
+ use = view.findViewById(R.id.item_button_use);
+ info = view.findViewById(R.id.item_button_info);
+
+ buy.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (itemListener != null) {
+ itemListener.onItemBuy(item);
+ }
+ }
+ });
+
+ use.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (itemListener != null) {
+ itemListener.onItemUse(item);
+ }
+ }
+ });
+
+ info.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (itemListener != null) {
+ itemListener.onItemGetDetails(item);
+ }
+ }
+ });
+ }
+
+ public void bind(Item item) {
+ this.item = item;
+
+ if (item.isSubscription) {
+ title.setText(item.title+" (sub)");
+ } else {
+ title.setText(item.title);
+ }
+ price.setText(item.price);
+
+ if (item.isPurchased) {
+ buy.setVisibility(View.GONE);
+ use.setVisibility(View.VISIBLE);
+ } else {
+ buy.setVisibility(View.VISIBLE);
+ use.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ public static interface ItemListener {
+ void onItemBuy(Item item);
+ void onItemUse(Item item);
+ void onItemGetDetails(Item item);
+ }
+}
diff --git a/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/MainActivity.java b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/MainActivity.java
new file mode 100644
index 0000000..3fdc8ab
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/java/com/getkeepsafe/cashier/sample/googleplaybilling/MainActivity.java
@@ -0,0 +1,321 @@
+/*
+ * Copyright 2017 Keepsafe Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.getkeepsafe.cashier.sample.googleplaybilling;
+
+import android.app.ProgressDialog;
+import android.os.Bundle;
+import android.support.v7.app.AlertDialog;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.Switch;
+import android.widget.Toast;
+
+import com.getkeepsafe.cashier.Cashier;
+import com.getkeepsafe.cashier.ConsumeListener;
+import com.getkeepsafe.cashier.Inventory;
+import com.getkeepsafe.cashier.InventoryListener;
+import com.getkeepsafe.cashier.Product;
+import com.getkeepsafe.cashier.ProductDetailsListener;
+import com.getkeepsafe.cashier.Purchase;
+import com.getkeepsafe.cashier.PurchaseListener;
+import com.getkeepsafe.cashier.Vendor;
+import com.getkeepsafe.cashier.VendorMissingException;
+import com.getkeepsafe.cashier.billing.GooglePlayBillingConstants;
+import com.getkeepsafe.cashier.billing.GooglePlayBillingVendor;
+import com.getkeepsafe.cashier.billing.debug.FakeGooglePlayBillingApi;
+import com.getkeepsafe.cashier.logging.LogcatLogger;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.getkeepsafe.cashier.VendorConstants.INVENTORY_QUERY_FAILURE;
+import static com.getkeepsafe.cashier.VendorConstants.INVENTORY_QUERY_MALFORMED_RESPONSE;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_ALREADY_OWNED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_CANCELED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_FAILURE;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_SUCCESS_RESULT_MALFORMED;
+import static com.getkeepsafe.cashier.VendorConstants.PURCHASE_UNAVAILABLE;
+
+public class MainActivity extends AppCompatActivity {
+ private Switch useFake;
+ private Cashier cashier;
+ private ProgressDialog progressDialog;
+ private Product testProduct;
+ private Product testProduct2;
+
+ private RecyclerView recyclerView;
+ private ItemsAdapter itemsAdapter;
+ private ProgressBar progressBar;
+
+ private PurchaseListener purchaseListener = new PurchaseListener() {
+ @Override
+ public void success(Purchase purchase) {
+ Toast.makeText(MainActivity.this, "Purchase success", Toast.LENGTH_SHORT).show();
+ refreshItems();
+ }
+
+ @Override
+ public void failure(Product product, Vendor.Error error) {
+ final String message;
+ switch (error.code) {
+ case PURCHASE_CANCELED:
+ message = "Purchase canceled";
+ break;
+ case PURCHASE_FAILURE:
+ message = "Purchase failed " + error.vendorCode;
+ break;
+ case PURCHASE_ALREADY_OWNED:
+ message = "You already own " + product.sku() + "!";
+ break;
+ case PURCHASE_SUCCESS_RESULT_MALFORMED:
+ message = "Malformed response! :(";
+ break;
+ case PURCHASE_UNAVAILABLE:
+ default:
+ message = "Purchase unavailable";
+ break;
+ }
+
+ Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show();
+ }
+ };
+
+ private ConsumeListener consumeListener = new ConsumeListener() {
+ @Override
+ public void success(Purchase purchase) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(MainActivity.this, "Purchase consumed!", Toast.LENGTH_SHORT).show();
+ if (progressDialog != null) {
+ progressDialog.dismiss();
+ }
+ refreshItems();
+ }
+ });
+ }
+
+ @Override
+ public void failure(Purchase purchase, final Vendor.Error error) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(MainActivity.this, "Did not consume purchase! " + error.code, Toast.LENGTH_SHORT).show();
+ if (progressDialog != null) {
+ progressDialog.dismiss();
+ }
+ }
+ });
+ }
+ };
+
+ private InventoryListener inventoryListener = new InventoryListener() {
+ @Override
+ public void success(final Inventory inventory) {
+ Map purchases = new HashMap<>();
+ for (Purchase purchase : inventory.purchases()) {
+ purchases.put(purchase.product().sku(), purchase);
+ }
+
+ List
- items = new ArrayList<>();
+ for (Product product : inventory.products()) {
+ Item item = new Item();
+ item.product = product;
+ item.purchase = purchases.get(product.sku());
+ item.title = product.name();
+ item.price = product.price();
+ item.isSubscription = product.isSubscription();
+ item.isPurchased = item.purchase != null;
+ items.add(item);
+ }
+ itemsAdapter.setItems(items);
+
+ recyclerView.setVisibility(View.VISIBLE);
+ progressBar.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void failure(final Vendor.Error error) {
+ final String message;
+ switch (error.code) {
+ case INVENTORY_QUERY_FAILURE:
+ default:
+ message = "Couldn't query the inventory for your vendor!";
+ break;
+ case INVENTORY_QUERY_MALFORMED_RESPONSE:
+ message = "Query was successful but the vendor returned a malformed response";
+ break;
+ }
+
+ Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show();
+ recyclerView.setVisibility(View.VISIBLE);
+ progressBar.setVisibility(View.GONE);
+ }
+ };
+
+ private ProductDetailsListener productDetailsListener = new ProductDetailsListener() {
+ @Override
+ public void success(Product product) {
+ new AlertDialog.Builder(MainActivity.this)
+ .setTitle("Product details")
+ .setMessage("Product details:\n"+
+ "Name: "+product.name()+"\n"+
+ "SKU: "+product.sku()+"\n"+
+ "Price: "+product.price()+"\n"+
+ "Is sub: "+product.isSubscription()+"\n"+
+ "Currency: "+product.currency())
+ .setPositiveButton("OK", null)
+ .create()
+ .show();
+ }
+
+ @Override
+ public void failure(Vendor.Error error) {
+ Toast.makeText(MainActivity.this, "Product details failure!", Toast.LENGTH_SHORT).show();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstance) {
+ super.onCreate(savedInstance);
+ setContentView(R.layout.activity_main);
+ useFake = findViewById(R.id.use_fake);
+ final Button queryPurchases = findViewById(R.id.query_purchases);
+
+ itemsAdapter = new ItemsAdapter(this);
+
+ recyclerView = findViewById(R.id.items_recycler);
+ recyclerView.setAdapter(itemsAdapter);
+
+ progressBar = findViewById(R.id.items_progress);
+ progressBar.setVisibility(View.GONE);
+
+ itemsAdapter.setItemListener(new ItemsAdapter.ItemListener() {
+ @Override
+ public void onItemBuy(Item item) {
+ cashier.purchase(MainActivity.this, item.product, purchaseListener);
+ }
+
+ @Override
+ public void onItemUse(final Item item) {
+ progressDialog = new ProgressDialog(MainActivity.this);
+ progressDialog.setIndeterminate(true);
+ progressDialog.setTitle("Consuming item, please wait...");
+ progressDialog.show();
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ cashier.consume(item.purchase, consumeListener);
+ }
+ }).start();
+ }
+
+ @Override
+ public void onItemGetDetails(Item item) {
+ cashier.getProductDetails(item.product.sku(), item.isSubscription, productDetailsListener);
+ }
+ });
+
+ testProduct = Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ "android.test.purchased",
+ "$0.99",
+ "USD",
+ "Test product",
+ "This is a test product",
+ false,
+ 990_000L);
+
+ testProduct2 = Product.create(
+ GooglePlayBillingConstants.VENDOR_PACKAGE,
+ "com.abc.def.123",
+ "$123.99",
+ "USD",
+ "Test product 2",
+ "This is another test product",
+ false,
+ 123990_000L);
+
+ // For testing certain products
+ FakeGooglePlayBillingApi.addTestProduct(testProduct);
+ FakeGooglePlayBillingApi.addTestProduct(testProduct2);
+
+ initCashier();
+
+ queryPurchases.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ refreshItems();
+ }
+ });
+
+ useFake.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ initCashier();
+ }
+ });
+
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ cashier.dispose();
+ }
+
+ private void initCashier() {
+ if (cashier != null) {
+ cashier.dispose();
+ }
+
+ new Thread() {
+ public void run() {
+ Vendor vendor;
+ if (useFake.isChecked()) {
+ vendor = new GooglePlayBillingVendor(
+ new FakeGooglePlayBillingApi(MainActivity.this,
+ FakeGooglePlayBillingApi.TEST_PUBLIC_KEY));
+ } else {
+ vendor = new GooglePlayBillingVendor();
+ }
+ try {
+ cashier = Cashier.forVendor(MainActivity.this, vendor)
+ .withLogger(new LogcatLogger())
+ .build();
+ } catch (VendorMissingException e) {
+ // Wont happen in sample
+ }
+ }
+ }.start();
+ }
+
+ private void refreshItems() {
+ recyclerView.setVisibility(View.INVISIBLE);
+ progressBar.setVisibility(View.VISIBLE);
+
+ List itemSkus = new ArrayList<>();
+ itemSkus.add(testProduct.sku());
+ itemSkus.add(testProduct2.sku());
+ cashier.getInventory(itemSkus, null, inventoryListener);
+ }
+}
diff --git a/cashier-sample/src/main/res/drawable-mdpi/ic_launcher.png b/cashier-sample-google-play-billing/src/main/res/drawable-mdpi/ic_launcher.png
similarity index 100%
rename from cashier-sample/src/main/res/drawable-mdpi/ic_launcher.png
rename to cashier-sample-google-play-billing/src/main/res/drawable-mdpi/ic_launcher.png
diff --git a/cashier-sample/src/main/res/drawable-xhdpi/ic_launcher.png b/cashier-sample-google-play-billing/src/main/res/drawable-xhdpi/ic_launcher.png
similarity index 100%
rename from cashier-sample/src/main/res/drawable-xhdpi/ic_launcher.png
rename to cashier-sample-google-play-billing/src/main/res/drawable-xhdpi/ic_launcher.png
diff --git a/cashier-sample/src/main/res/drawable-xxhdpi/ic_launcher.png b/cashier-sample-google-play-billing/src/main/res/drawable-xxhdpi/ic_launcher.png
similarity index 100%
rename from cashier-sample/src/main/res/drawable-xxhdpi/ic_launcher.png
rename to cashier-sample-google-play-billing/src/main/res/drawable-xxhdpi/ic_launcher.png
diff --git a/cashier-sample/src/main/res/drawable-xxxhdpi/ic_launcher.png b/cashier-sample-google-play-billing/src/main/res/drawable-xxxhdpi/ic_launcher.png
similarity index 100%
rename from cashier-sample/src/main/res/drawable-xxxhdpi/ic_launcher.png
rename to cashier-sample-google-play-billing/src/main/res/drawable-xxxhdpi/ic_launcher.png
diff --git a/cashier-sample-google-play-billing/src/main/res/layout/activity_main.xml b/cashier-sample-google-play-billing/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..bc564a6
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/res/layout/activity_main.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cashier-sample-google-play-billing/src/main/res/layout/view_item.xml b/cashier-sample-google-play-billing/src/main/res/layout/view_item.xml
new file mode 100644
index 0000000..ea16a1c
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/res/layout/view_item.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cashier-sample/src/main/res/values/colors.xml b/cashier-sample-google-play-billing/src/main/res/values/colors.xml
similarity index 100%
rename from cashier-sample/src/main/res/values/colors.xml
rename to cashier-sample-google-play-billing/src/main/res/values/colors.xml
diff --git a/cashier-sample-google-play-billing/src/main/res/values/strings.xml b/cashier-sample-google-play-billing/src/main/res/values/strings.xml
new file mode 100644
index 0000000..51a0aa4
--- /dev/null
+++ b/cashier-sample-google-play-billing/src/main/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+ Cashier
+ Purchase unavailable
+ Purchase cancelled
+ Buy
+ Use
+ \?
+ Use Fake Checkout
+ Query inventory
+
diff --git a/cashier-sample/src/main/res/values/styles.xml b/cashier-sample-google-play-billing/src/main/res/values/styles.xml
similarity index 100%
rename from cashier-sample/src/main/res/values/styles.xml
rename to cashier-sample-google-play-billing/src/main/res/values/styles.xml
diff --git a/cashier-sample-iab/.gitignore b/cashier-sample-iab/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/cashier-sample-iab/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/cashier-sample/build.gradle b/cashier-sample-iab/build.gradle
similarity index 96%
rename from cashier-sample/build.gradle
rename to cashier-sample-iab/build.gradle
index 17b9388..3491b20 100644
--- a/cashier-sample/build.gradle
+++ b/cashier-sample-iab/build.gradle
@@ -7,7 +7,7 @@ android {
defaultConfig {
applicationId "com.getkeepsafe.cashier.sample"
minSdkVersion 14
- targetSdkVersion 24
+ targetSdkVersion 28
versionCode 1
versionName "1.0"
}
diff --git a/cashier-sample/proguard-rules.pro b/cashier-sample-iab/proguard-rules.pro
similarity index 100%
rename from cashier-sample/proguard-rules.pro
rename to cashier-sample-iab/proguard-rules.pro
diff --git a/cashier-sample/src/main/AndroidManifest.xml b/cashier-sample-iab/src/main/AndroidManifest.xml
similarity index 100%
rename from cashier-sample/src/main/AndroidManifest.xml
rename to cashier-sample-iab/src/main/AndroidManifest.xml
diff --git a/cashier-sample/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java b/cashier-sample-iab/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java
similarity index 99%
rename from cashier-sample/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java
rename to cashier-sample-iab/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java
index 6e1fb2b..947c565 100644
--- a/cashier-sample/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java
+++ b/cashier-sample-iab/src/main/java/com/getkeepsafe/cashier/sample/MainActivity.java
@@ -17,7 +17,6 @@
package com.getkeepsafe.cashier.sample;
import android.app.ProgressDialog;
-import android.content.Intent;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
diff --git a/cashier-sample-iab/src/main/res/drawable-mdpi/ic_launcher.png b/cashier-sample-iab/src/main/res/drawable-mdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8c8875ea192a6c2950e3330484fcffdaba12c4b
GIT binary patch
literal 2302
zcmVPx-xJg7oRA>e5Sxtx>#}$4(|1+zdRpj+r(i%d935k)36pWD|f)J8(ZVt*J$uXA%
zd<`V%l#@YV2VZgtA)tdH2kROFk(58lR=klF6fG$B&aPJ4AMKB(XJ@Bprt`j6_4;pm
zX1iB{5O7J`RrTuCd*Ao!RrSvheO~^z5peRjzx3|AC!5Weemm8uUvQj4#rOSSy0rM>
z^FJ-&u$9SpFscoy2mY|ved=D@cGrLK%eUTIjSWudWG3*FciyRfb?%F+rBdk&YA;40
z#GOLIAdjf93DXNn8mnHfzrFCEJ12f~=~7qyOsWf$2BTxMrP{X?Jb*{Ri=GWdbOnO>
z7I_%0IF&$;8|6yrDEsy*>R_*8W*p;_x9Bo0PbWa;IpM-xlDt;C#w&~FTdQN
zFTHX^p-2q?j(~Lx5fK;_0Mr68I6t`GrlnR#fQ!s3z>i<95)}&w$<(gvUg05ire?Ae
zpdo-cR4g$Gk4doeubghunR7Ej2xFa|OASN~gAuS2MJ6@$q0^@6n!y1MjYgS1Tj~VS
zH_;&)vcLYoqjG^suq}`XP)8b>_khgd5CK=A#0v-STx5Z<%nZ=&?$O5X1~E?RdR=eIE@d~tecbL3S#bQwvVs+*f
z7$S!frCco&BBc>@yKVA@eZUn>fcJef4?Cjk^{CA{B4-V()JSncNTouFH!`OX&m9i>
zBdQRD+Nr~FA)*zHSb@=3>lIP*h8}IX+vIbuOjR3pB36e{^8YE>8$La0KcOB+7TC>R
zPq46rVPN17poM14uL$fV7lG(RB~mCjLIRN;Mx2|}F*8ILL>LU#X1B8|HQJCOU4kNd
zriXc5-dCa=D8w7N2q@@e4HAO_N7XuayDs~*i6jAZc!PJ?Gb3eCd%VG=@K_YPj>{P9
zmHEV*ZZ?@ED@IbwOg7hCn;28Dp-_P9lZ9>S?wQJC3#p2PCP@*JJrxlxUGPHQ_
zUz{;!B@8ELzh19=Gi_dG0%o%Hjtcf8-O%_bLItgLsB2nL@M;q|2p7eI|LIh*dj9=i
zzxOCP5B+=u7@J~8W4ckJ6UTUyY2k3KtUuvYDbUFmj;d7Zl?^MOWw~->gv+eo-s`bQ
zg^_7YVrq8g)%P!7=A$URWGBFTGwK3ufIolswV35=w{8<|K&J_`j{NPo1Q{wuynaBo6=qI{43BbAy5ex;+B32jB+MI21@dQ4)NATe&!1hWpg0+=th(;^h%
z7x6Q`2^;cE!7GPzUrCIg5gWo;#p#LeB=
zq1GzPS?BtPAImt@F=6@9hO{tk&>NK5cIOgRcz8Vp?8Zm$^6i6f#ysjh5ForD5!{y4?#nB(jto1k6pwoFv9Ho_PxV
zcrcVZY{Fw0%m9Zwd_pVUvT_o6XUJbmoH~;_T**No?f_@@JU^HvpM}ghPkh>(iA}i9
zr_Doeh2~$gFDMrB!%rOAY~fpyMxR4u-}b|IhnT5YC;uKmsPG4mhYDNNfWac!E@x+c
zgZT(V?MK@Nhq)@gsEpeZVUfjiOL~&i^C-9irn|wL
zn4_(9a0v0cT&krAmvG(-=y~G->p^Z?f1kfK-MIHL8K0g69EwVul7H8z`V*
zdLem--*cVs{^|EuK8UG}>0~BQI`QHkd!7Dvxl%j|2mt?J@jl)B`(No7KmBoF-lTv-
zi;IgiJ3C9qjvWiew4pbvpTGSpdi{-a{2Rk7CT-ZR=Xu)?7Ha3v-kgy@;L|)m
z!1bQ)Z2#r{y@elLy?Rx@b{&L2v6=^}rz@5q!hfJE+zJ`!cUJrs---M3-Ukxey3aqFyKd
zC36H^8dxQl0pmoK=R!a|0BEgN3syqq_#FX&qrbAGky8CU^)u%noNaDywi!_HBOJhD
zPiV?0SgkEUm-oPtJYdswpUVpD?Ce}*(7|03K&$-I5F)OiwN~(3TU!^?0DUg|e~Z9>
Y0lW%#EI1D`F8}}l07*qoM6N<$f}l59O#lD@
literal 0
HcmV?d00001
diff --git a/cashier-sample-iab/src/main/res/drawable-xhdpi/ic_launcher.png b/cashier-sample-iab/src/main/res/drawable-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..6da3df4cf39c7075920f891f68b6907b7b4b3cdd
GIT binary patch
literal 6182
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
Ef;}jrasU7T
literal 0
HcmV?d00001
diff --git a/cashier-sample-iab/src/main/res/drawable-xxhdpi/ic_launcher.png b/cashier-sample-iab/src/main/res/drawable-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..3bb844c4445ca59818eedd559ad89111c0966713
GIT binary patch
literal 11971
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
VF&=)NMyLP)002ovPDHLkV1le3FM|L8
literal 0
HcmV?d00001
diff --git a/cashier-sample-iab/src/main/res/drawable-xxxhdpi/ic_launcher.png b/cashier-sample-iab/src/main/res/drawable-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..db34f713dd22ec16bc3019f1ed9e2a95f782f1fd
GIT binary patch
literal 18493
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