com.mohamnag.inappbilling.InAppBillingPlugin.java Source code

Java tutorial

Introduction

Here is the source code for com.mohamnag.inappbilling.InAppBillingPlugin.java

Source

/**
 * In App Billing Plugin
 *
 * Details and more information under:
 * https://github.com/mohamnag/InAppBilling/wiki
 */
package com.mohamnag.inappbilling;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import com.android.vending.billing.IInAppBillingService;
import com.mohamnag.inappbilling.helper.Inventory;
import com.mohamnag.inappbilling.helper.Purchase;
import com.mohamnag.inappbilling.helper.Security;
import com.mohamnag.inappbilling.helper.SkuDetails;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.json.JSONArray;
import org.json.JSONException;

public class InAppBillingPlugin extends CordovaPlugin {

    /**
     * Error codes.
     *
     * keep synchronized between: * InAppPurchase.m * InAppBillingPlugin.java *
     * android_iab.js * ios_iab.js
     *
     * Be carefull assiging new codes, these are meant to express the REASON of
     * the error as exact as possible!
     */
    private static final int ERROR_CODES_BASE = 4983497;

    public static final int ERR_NO_ERROR = ERROR_CODES_BASE;
    public static final int ERR_SETUP = ERROR_CODES_BASE + 1;
    public static final int ERR_LOAD = ERROR_CODES_BASE + 2;
    public static final int ERR_PURCHASE = ERROR_CODES_BASE + 3;
    public static final int ERR_LOAD_RECEIPTS = ERROR_CODES_BASE + 4;
    public static final int ERR_CLIENT_INVALID = ERROR_CODES_BASE + 5;
    // payment process was cancelled by user
    public static final int ERR_PAYMENT_CANCELLED = ERROR_CODES_BASE + 6;
    public static final int ERR_PAYMENT_INVALID = ERROR_CODES_BASE + 7;
    public static final int ERR_PAYMENT_NOT_ALLOWED = ERROR_CODES_BASE + 8;
    public static final int ERR_UNKNOWN = ERROR_CODES_BASE + 10;
    public static final int ERR_LOAD_INVENTORY = ERROR_CODES_BASE + 11;
    public static final int ERR_HELPER_DISPOSED = ERROR_CODES_BASE + 12;
    public static final int ERR_NOT_INITIALIZED = ERROR_CODES_BASE + 13;
    public static final int ERR_INVENTORY_NOT_LOADED = ERROR_CODES_BASE + 14;
    public static final int ERR_PURCHASE_FAILED = ERROR_CODES_BASE + 15;
    public static final int ERR_JSON_CONVERSION_FAILED = ERROR_CODES_BASE + 16;
    public static final int ERR_INVALID_PURCHASE_PAYLOAD = ERROR_CODES_BASE + 17;
    public static final int ERR_SUBSCRIPTION_NOT_SUPPORTED = ERROR_CODES_BASE + 18;
    public static final int ERR_CONSUME_NOT_OWNED_ITEM = ERROR_CODES_BASE + 19;
    public static final int ERR_CONSUMPTION_FAILED = ERROR_CODES_BASE + 20;
    // the prduct to be bought is not loaded
    public static final int ERR_PRODUCT_NOT_LOADED = ERROR_CODES_BASE + 21;
    // invalid product ids passed
    public static final int ERR_INVALID_PRODUCT_ID = ERROR_CODES_BASE + 22;
    // invalid purchase id passed
    public static final int ERR_INVALID_PURCHASE_ID = ERROR_CODES_BASE + 23;
    // item requested to be bought is already owned
    public static final int ERR_PURCHASE_OWNED_ITEM = ERROR_CODES_BASE + 24;

    // play store response codes 
    public static final int BILLING_RESPONSE_RESULT_OK = 0;
    public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1;
    public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3;
    public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4;
    public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5;
    public static final int BILLING_RESPONSE_RESULT_ERROR = 6;
    public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7;
    public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8;

    public static final String BILLING_ITEM_TYPE_INAPP = "inapp";
    public static final String BILLING_ITEM_TYPE_SUBS = "subs";

    private boolean initialized = false;

    //TODO: set this from JS, according to what is defined in options
    private final Boolean ENABLE_DEBUG_LOGGING = true;

    private final String TAG = "CORDOVA_INAPPBILLINGPLUGIN";

    /**
     * request code base for the purchase flow
     */
    static int REQUEST_CODE_BASE = 10000;

    IInAppBillingService iabService;
    ServiceConnection iabServiceConnection;
    String base64EncodedPublicKey;
    boolean subscriptionSupported;
    Map<Integer, CallbackContext> pendingPurchaseCallbacks = new HashMap<Integer, CallbackContext>();

    /**
     * A quite up to date inventory of available items and purchase items
     */
    Inventory myInventory;

    /**
     * This is a bridge to the log function in JavaScript world. We pass the
     * logs there for an easier debug for end developers.
     *
     * This is prone to XSS attacks! current workaround: turn off logs in
     * production
     *
     * @param msg
     */
    private void jsLog(String msg) {
        // transfer all the logs back to JS for a better visibility. ~> window.inappbilling.log()
        String js = String.format("window.inappbilling.log('%s');", "[android] " + msg);
        webView.sendJavascript(js);
    }

    @Override
    /**
     * Called from JavaScript and dispatches the requests further to proper
     * functions.
     */
    public boolean execute(String action, JSONArray data, final CallbackContext callbackContext)
            throws JSONException {
        jsLog("execute called for action: " + action + " data: " + data);

        // Check if the action has a handler
        Boolean isValidAction = true;

        // Initialize
        if ("init".equals(action)) {
            ArrayList<String> productIds = null;
            boolean debugEnabled = false;
            if (data.length() > 0) {
                productIds = jsonStringToList(data.getString(0));
            }
            if (data.length() > 1) {
                debugEnabled = data.getBoolean(1);
            }

            init(productIds, debugEnabled, callbackContext);
        } // Get the list of purchases
        else if ("getPurchases".equals(action) || "restoreCompletedTransactions".equals(action)) {
            if (isReady(callbackContext)) {
                getPurchases(callbackContext);
            }
        } // Buy an item
        else if ("buy".equals(action)) {
            if (isReady(callbackContext)) {
                buy(data.getString(0), callbackContext);
            }

        } // consume an owned item
        else if ("consumeProduct".equals(action)) {
            if (isReady(callbackContext)) {
                consumeProduct(data.getString(0), callbackContext);
            }

        } // Get the list of loaded products
        else if ("getLoadedProducts".equals(action)) {
            if (isReady(callbackContext)) {
                getLoadedProducts(callbackContext);
            }
        } // Get details of a loaded product
        else if ("loadProductDetails".equals(action)) {
            if (isReady(callbackContext)) {
                loadProductDetails(jsonStringToList(data.getString(0)), callbackContext);
            }
        } // Get verification payload for a puchase
        else if ("getPurchaseDetails".equals(action)) {
            if (isReady(callbackContext)) {
                getPurchaseDetails(data.getString(0), callbackContext);
            }
        } // No handler for the action
        else {

            isValidAction = false;
        }

        // Method not found
        return isValidAction;
    }

    /**
     * Helper to convert JSON string to a List<String>
     *
     * @param data
     * @return
     */
    private ArrayList<String> jsonStringToList(String data) throws JSONException {
        JSONArray jsonSkuList = new JSONArray(data);

        ArrayList<String> sku = new ArrayList<String>();
        int len = jsonSkuList.length();

        jsLog("Num SKUs Found: " + len);

        for (int i = 0; i < len; i++) {
            sku.add(jsonSkuList.get(i).toString());
            jsLog("Product SKU Added: " + jsonSkuList.get(i).toString());
        }

        return sku;
    }

    /*
     SIDE NOTE: plugins can initialize automatically using "initialize" method. 
     they can even request on startup init. may be considered too!
        
     http://docs.phonegap.com/en/3.4.0/guide_platforms_android_plugin.md.html#Android%20Plugins
     */
    /**
     * Initializes the plug-in, will also optionally load products if some
     * product IDs are provided.
     *
     * @param productIds
     * @param callbackContext
     */
    private void init(final ArrayList<String> productIds, boolean debugEnabled,
            final CallbackContext callbackContext) {

        jsLog("init called with productIds: " + productIds);

        subscriptionSupported = false;
        initialized = false;

        // retrieve licence key, if this is not set, we will skip validations later
        base64EncodedPublicKey = cordova.getActivity().getIntent().getStringExtra("android-iabplugin-license-key");

        // prepare and bind to iab service
        iabServiceConnection = new ServiceConnection() {

            @Override
            public void onServiceDisconnected(ComponentName name) {
                jsLog("Service disconnected");
                iabService = null;
            }

            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                jsLog("Service connected");
                iabService = IInAppBillingService.Stub.asInterface(service);

                try {
                    jsLog("check for in-app billing v3 support");
                    int response = iabService.isBillingSupported(3, cordova.getActivity().getPackageName(),
                            BILLING_ITEM_TYPE_INAPP);
                    if (response != BILLING_RESPONSE_RESULT_OK) {
                        callbackContext
                                .error(new Error(ERR_SETUP, "Billing v3 not supported. Response code: " + response)
                                        .toJavaScriptJSON());
                    } else {
                        // subs may be disabled independent from v3 interface
                        jsLog("check for v3 subscriptions support");
                        response = iabService.isBillingSupported(3, cordova.getActivity().getPackageName(),
                                BILLING_ITEM_TYPE_SUBS);
                        subscriptionSupported = response == BILLING_RESPONSE_RESULT_OK;

                        myInventory = new Inventory(base64EncodedPublicKey);
                        initialized = true;

                        // Now, let's pupulate inventory with products
                        loadProductDetails(productIds, callbackContext);
                    }
                } catch (JSONException ex) {
                    callbackContext
                            .error(new Error(ERR_JSON_CONVERSION_FAILED, ex.getMessage()).toJavaScriptJSON());
                } catch (RemoteException ex) {
                    Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.SEVERE, null, ex);
                    callbackContext.error(new Error(ERR_SETUP, ex.getMessage()).toJavaScriptJSON());
                }

            }

        };

        cordova.getActivity().getApplicationContext().bindService(
                new Intent("com.android.vending.billing.InAppBillingService.BIND"), iabServiceConnection,
                Context.BIND_AUTO_CREATE);
    }

    /**
     * Checks for correct initialization of plug-in and the case where helper is
     * disposed in mean time.
     *
     * @param result
     */
    private boolean isReady(CallbackContext callbackContext) throws JSONException {
        if (!initialized) {
            callbackContext
                    .error(new Error(ERR_NOT_INITIALIZED, "Plugin has not been initialized.").toJavaScriptJSON());

            return false;
        } else {
            return true;
        }
    }

    /**
     * Buy an already loaded item.
     *
     * @param productId
     * @param callbackContext
     */
    private void buy(final String productId, final CallbackContext callbackContext) throws JSONException {

        jsLog("buy called for productId: " + productId);

        SkuDetails product = myInventory.getSkuDetails(productId);
        if (product == null) {
            callbackContext
                    .error(new Error(ERR_PRODUCT_NOT_LOADED, "Product intended to be bought has not been loaded.")
                            .toJavaScriptJSON());
        } else if (BILLING_ITEM_TYPE_SUBS.equals(product.getType()) && !subscriptionSupported) {
            callbackContext.error(new Error(ERR_SUBSCRIPTION_NOT_SUPPORTED, "Subscriptions are not supported")
                    .toJavaScriptJSON());
        } else {
            cordova.setActivityResultCallback(this);
            int requestCode = REQUEST_CODE_BASE++;

            try {
                jsLog("Preparing purchase flow");

                Bundle buyIntentBundle = iabService.getBuyIntent(3, cordova.getActivity().getPackageName(),
                        product.getSku(), product.getType(), null);

                int response = buyIntentBundle.getInt("RESPONSE_CODE");
                if (response == BILLING_RESPONSE_RESULT_OK) {
                    PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT");

                    pendingPurchaseCallbacks.put(requestCode, callbackContext);

                    cordova.getActivity().startIntentSenderForResult(pendingIntent.getIntentSender(), requestCode,
                            new Intent(), 0, 0, 0);

                    jsLog("Purchase flow launched successfully");
                } else if (response == BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED) {
                    callbackContext.error(
                            new Error(ERR_PURCHASE_OWNED_ITEM, "Item requested to be bought is already owned.")
                                    .toJavaScriptJSON());
                } else {
                    callbackContext.error(
                            new Error(ERR_PURCHASE_FAILED, "Could not get buy intent. Response code: " + response)
                                    .toJavaScriptJSON());
                }
            } catch (RemoteException ex) {
                Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.SEVERE, null, ex);
                callbackContext.error(new Error(ERR_PURCHASE_FAILED, ex.getMessage()).toJavaScriptJSON());
            } catch (IntentSender.SendIntentException ex) {
                Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.SEVERE, null, ex);
                callbackContext.error(new Error(ERR_PURCHASE_FAILED, ex.getMessage()).toJavaScriptJSON());
            }

        }
    }

    /**
     * Get list of purchases. This will also load the product details for
     * purchases.
     *
     * @param callbackContext
     * @throws JSONException
     */
    private void getPurchases(CallbackContext callbackContext) throws JSONException {
        jsLog("getPurchases called.");

        Error errorInapp = queryPurchases(BILLING_ITEM_TYPE_INAPP);
        Error errorSubs = queryPurchases(BILLING_ITEM_TYPE_SUBS);

        // call success only if we had no error
        if (errorInapp != null) {
            callbackContext.error(errorInapp.toJavaScriptJSON());
        } else if (errorSubs != null) {
            callbackContext.error(errorSubs.toJavaScriptJSON());
        } else {
            callbackContext.success(myInventory.getAllPurchasesJSON());
        }
    }

    private Error queryPurchases(String itemType) {
        Error ret = null;
        jsLog("queryPurchases for type: " + itemType);

        try {
            Bundle ownedItems = iabService.getPurchases(3, cordova.getActivity().getPackageName(), itemType, null);

            int response = ownedItems.getInt("RESPONSE_CODE");
            if (response == BILLING_RESPONSE_RESULT_OK) {
                ArrayList<String> purchaseDataList = ownedItems.getStringArrayList("INAPP_PURCHASE_DATA_LIST");
                ArrayList<String> signatureList = ownedItems.getStringArrayList("INAPP_DATA_SIGNATURE_LIST");
                String continuationToken = ownedItems.getString("INAPP_CONTINUATION_TOKEN");

                jsLog("Got purchases: " + purchaseDataList.size());
                jsLog("Got signatures: " + signatureList.size());

                for (int i = 0; i < purchaseDataList.size(); ++i) {
                    String purchaseData = purchaseDataList.get(i);
                    String signature = signatureList.get(i);

                    try {
                        Purchase purchase = new Purchase(purchaseData, signature);

                        if (base64EncodedPublicKey != null
                                && !Security.verifyPurchase(base64EncodedPublicKey, purchaseData, signature)) {

                            jsLog("Signature verification failed: " + purchaseData + " signature: " + signature);
                        } else {
                            jsLog("Purchase loaded for: " + purchase.getSku());

                            // add the purchase to the inventory
                            myInventory.addPurchase(purchase);
                        }
                    } catch (JSONException e) {
                        ret = new Error(ERR_JSON_CONVERSION_FAILED, e.getMessage());
                    }
                }
            }

            // TODO: if continuationToken != null, call getPurchases again 
            // and pass in the token to retrieve more items
        } catch (RemoteException ex) {
            Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.SEVERE, null, ex);
            ret = new Error(ERR_LOAD_RECEIPTS, ex.getMessage());
        }

        return ret;
    }

    /**
     * Returns the list of all loaded products.
     *
     * @param callbackContext
     */
    private void getLoadedProducts(CallbackContext callbackContext) throws JSONException {
        jsLog("getLoadedProducts called.");

        if (initialized) {
            callbackContext.success(myInventory.getAllProductsJSON());
        } else {
            callbackContext
                    .error(new Error(ERR_INVENTORY_NOT_LOADED, "Inventory is not loaded.").toJavaScriptJSON());
        }
    }

    /**
     * Loads products with specific IDs and gets their details.
     *
     * @param productIds
     * @param callbackContext
     */
    private void loadProductDetails(final ArrayList<String> productIds, final CallbackContext callbackContext)
            throws JSONException {

        jsLog("loadProductDetails called.");

        if (productIds == null || productIds.isEmpty()) {
            jsLog("Product list was empty");
            callbackContext.success(myInventory.getAllProductsJSON());
        } else {
            jsLog("Loading/refreshing product details");

            Bundle querySkus = new Bundle();
            querySkus.putStringArrayList("ITEM_ID_LIST", productIds);

            // do same query with both types to load all!
            Error errInapp = querySkuDetails(querySkus, BILLING_ITEM_TYPE_INAPP);
            Error errSubs = querySkuDetails(querySkus, BILLING_ITEM_TYPE_SUBS);

            // only call success if no error has happened
            if (errInapp != null) {
                callbackContext.error(errInapp.toJavaScriptJSON());
            } else if (errSubs != null) {
                callbackContext.error(errSubs.toJavaScriptJSON());
            } else {
                callbackContext.success(myInventory.getAllProductsJSON());
            }

        }
    }

    /**
     * Loads the product details from play store and puts them in inventory,
     * will reload the product if they have been already loaded before.
     *
     * @param querySkus
     * @param itemType
     * @param callbackContext
     * @throws RemoteException
     */
    private Error querySkuDetails(Bundle querySkus, String itemType) {
        Error ret = null;

        try {
            Bundle skuDetailsInapp = iabService.getSkuDetails(3, cordova.getActivity().getPackageName(), itemType,
                    querySkus);

            int response = skuDetailsInapp.getInt("RESPONSE_CODE");
            if (response == BILLING_RESPONSE_RESULT_OK) {
                ArrayList<String> responseList = skuDetailsInapp.getStringArrayList("DETAILS_LIST");

                for (String thisResponse : responseList) {
                    try {
                        SkuDetails d = new SkuDetails(itemType, thisResponse);
                        jsLog("Got sku details: " + d);

                        myInventory.addSkuDetails(d);
                    } catch (JSONException ex) {
                        jsLog("JSONException: " + ex.getMessage());
                    }
                }
            } else {
                ret = new Error(ERR_LOAD_INVENTORY, "Cant load product details. Responce code: " + response);
            }
        } catch (RemoteException ex) {
            Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.SEVERE, null, ex);

            ret = new Error(ERR_LOAD_INVENTORY, ex.getMessage());
        }

        return ret;
    }

    /**
     * Consumes an already owned item.
     *
     * @param productId
     * @param callbackContext
     */
    private void consumeProduct(final String productId, final CallbackContext callbackContext)
            throws JSONException {
        jsLog("consumeProduct called.");

        Purchase purchase = myInventory.getPurchase(productId);

        if (purchase != null) {
            try {
                int response = iabService.consumePurchase(3, cordova.getActivity().getPackageName(),
                        purchase.getToken());

                if (response == BILLING_RESPONSE_RESULT_OK) {
                    myInventory.erasePurchaseByOrderId(purchase.getOrderId());

                    callbackContext.success(purchase.toJavaScriptJson());
                }
            } catch (RemoteException ex) {
                Logger.getLogger(InAppBillingPlugin.class.getName()).log(Level.WARNING, null, ex);

                callbackContext.error(new Error(ERR_CONSUMPTION_FAILED, ex.getMessage()).toJavaScriptJSON());
            }
        } else {
            callbackContext.error(
                    new Error(ERR_CONSUME_NOT_OWNED_ITEM, "No purchase record found for product id: " + productId)
                            .toJavaScriptJSON());
        }

    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {

        if (pendingPurchaseCallbacks.containsKey(requestCode)) {
            jsLog("Got response of a purchase");

            // going to handle response of a purchase
            CallbackContext callbackContext = pendingPurchaseCallbacks.get(requestCode);
            int responseCode = data.getIntExtra("RESPONSE_CODE", 0);
            String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
            String dataSignature = data.getStringExtra("INAPP_DATA_SIGNATURE");

            if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) {
                if (purchaseData == null || dataSignature == null) {
                    callbackContext
                            .error(new Error(ERR_PURCHASE_FAILED, "Empty purchase data or empty signature returned")
                                    .toJavaScriptJSON());
                } else {

                    try {
                        Purchase purchase = new Purchase(purchaseData, dataSignature);

                        if (base64EncodedPublicKey != null
                                && !Security.verifyPurchase(base64EncodedPublicKey, purchaseData, dataSignature)) {

                            callbackContext.error(new Error(ERR_PAYMENT_INVALID, "Signature verification failed")
                                    .toJavaScriptJSON());
                        } else {
                            jsLog("Purchase successful.");

                            // add the purchase to the inventory
                            myInventory.addPurchase(purchase);

                            callbackContext.success(purchase.toJavaScriptJson());
                        }
                    } catch (JSONException e) {
                        callbackContext
                                .error(new Error(ERR_JSON_CONVERSION_FAILED, e.getMessage()).toJavaScriptJSON());
                    }
                }
            } else if (resultCode == Activity.RESULT_CANCELED) {
                callbackContext
                        .error(new Error(ERR_PAYMENT_CANCELLED, "Purchase cancelled by user.").toJavaScriptJSON());
            } else {
                // unknown result, interpret as error
                callbackContext
                        .error(new Error(ERR_PURCHASE_FAILED, "Unknown result code from activity. ResultCode: "
                                + resultCode + " ResponseCode: " + responseCode).toJavaScriptJSON());
            }
        }
    }

    /**
     * Will simply return the complete purchase data. This is mainly for iOS
     * compatibility here.
     *
     * @param purchaseId
     * @param callbackContext
     */
    private void getPurchaseDetails(String purchaseId, CallbackContext callbackContext) throws JSONException {
        Purchase purchase = myInventory.getPurchaseByOrderId(purchaseId);
        if (purchase != null) {
            callbackContext.success(purchase.toJavaScriptJson());
        } else {
            callbackContext.error(new Error(ERR_INVALID_PURCHASE_ID, "Purchase with that id could not be found")
                    .toJavaScriptJSON());
        }
    }

    @Override
    public void onDestroy() {
        jsLog("onDestroy called.");

        super.onDestroy();

        initialized = false;

        if (iabService != null) {
            cordova.getActivity().getApplicationContext().unbindService(iabServiceConnection);
        }
    }

}