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](.github/ic_launcher.png)
- 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 0000000..c8c8875
Binary files /dev/null and b/cashier-sample-iab/src/main/res/drawable-mdpi/ic_launcher.png differ
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 0000000..6da3df4
Binary files /dev/null and b/cashier-sample-iab/src/main/res/drawable-xhdpi/ic_launcher.png differ
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 0000000..3bb844c
Binary files /dev/null and b/cashier-sample-iab/src/main/res/drawable-xxhdpi/ic_launcher.png differ
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 0000000..db34f71
Binary files /dev/null and b/cashier-sample-iab/src/main/res/drawable-xxxhdpi/ic_launcher.png differ
diff --git a/cashier-sample/src/main/res/layout/activity_main.xml b/cashier-sample-iab/src/main/res/layout/activity_main.xml
similarity index 100%
rename from cashier-sample/src/main/res/layout/activity_main.xml
rename to cashier-sample-iab/src/main/res/layout/activity_main.xml
diff --git a/cashier-sample-iab/src/main/res/values/colors.xml b/cashier-sample-iab/src/main/res/values/colors.xml
new file mode 100644
index 0000000..5a077b3
--- /dev/null
+++ b/cashier-sample-iab/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+
diff --git a/cashier-sample/src/main/res/values/strings.xml b/cashier-sample-iab/src/main/res/values/strings.xml
similarity index 100%
rename from cashier-sample/src/main/res/values/strings.xml
rename to cashier-sample-iab/src/main/res/values/strings.xml
diff --git a/cashier-sample-iab/src/main/res/values/styles.xml b/cashier-sample-iab/src/main/res/values/styles.xml
new file mode 100644
index 0000000..705be27
--- /dev/null
+++ b/cashier-sample-iab/src/main/res/values/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/cashier/src/main/java/com/getkeepsafe/cashier/Preconditions.java b/cashier/src/main/java/com/getkeepsafe/cashier/Preconditions.java
index ea3fb75..5a7a39c 100644
--- a/cashier/src/main/java/com/getkeepsafe/cashier/Preconditions.java
+++ b/cashier/src/main/java/com/getkeepsafe/cashier/Preconditions.java
@@ -19,7 +19,7 @@
/**
* Snipped from Guava
*/
-class Preconditions {
+public class Preconditions {
private Preconditions() {
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index bc7c746..4ab48a0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
diff --git a/settings.gradle b/settings.gradle
index 81ce947..38887fd 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':cashier-sample', ':cashier-iab', ':cashier-iab-debug', ':cashier', ':cashier-iab-debug-no-op'
+include ':cashier-sample-iab', ':cashier-iab', ':cashier-iab-debug', ':cashier', ':cashier-iab-debug-no-op', ':cashier-google-play-billing', ':cashier-google-play-billing-debug', ':cashier-sample-google-play-billing'
diff --git a/versions.gradle b/versions.gradle
index 950887d..25bb64e 100644
--- a/versions.gradle
+++ b/versions.gradle
@@ -1,23 +1,24 @@
ext {
- versions = [
- compileSdk : 27,
- minSdk : 9,
- buildTools : '27.0.3',
- versionName: '0.2.6'
- ]
+ versions = [
+ compileSdk : 28,
+ minSdk : 9,
+ buildTools : '28.0.3',
+ versionName: '0.3.5'
+ ]
- final supportLibraryVersion = '27.0.2'
+ final supportLibraryVersion = '28.0.0'
- deps = [
- autoValue : 'com.google.auto.value:auto-value:1.3',
- autoParcel : 'com.ryanharter.auto.value:auto-value-parcel:0.2.5',
- appCompat : "com.android.support:appcompat-v7:$supportLibraryVersion",
- supportAnnotations: "com.android.support:support-annotations:$supportLibraryVersion",
+ deps = [
+ autoValue : 'com.google.auto.value:auto-value:1.3',
+ autoParcel : 'com.ryanharter.auto.value:auto-value-parcel:0.2.5',
+ appCompat : "com.android.support:appcompat-v7:$supportLibraryVersion",
+ supportAnnotations: "com.android.support:support-annotations:$supportLibraryVersion",
+ billingClient : 'com.android.billingclient:billing:1.2',
- // Test dependencies
- robolectric : 'org.robolectric:robolectric:3.3.2',
- junit : 'junit:junit:4.12',
- mockito : 'org.mockito:mockito-core:2.2.9',
- truth : 'com.google.truth:truth:0.31'
- ]
+ // Test dependencies
+ robolectric : 'org.robolectric:robolectric:3.3.2',
+ junit : 'junit:junit:4.12',
+ mockito : 'org.mockito:mockito-core:2.2.9',
+ truth : 'com.google.truth:truth:0.31'
+ ]
}