/* Copyright 2011 Robot Media SL (http://www.robotmedia.net)
*
* 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 net.robotmedia.billing;
import android.app.Activity;
import android.app.PendingIntent;
import android.app.PendingIntent.CanceledException;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import android.util.Log;
import net.robotmedia.billing.model.Transaction;
import net.robotmedia.billing.model.TransactionManager;
import net.robotmedia.billing.security.DefaultSignatureValidator;
import net.robotmedia.billing.security.ISignatureValidator;
import net.robotmedia.billing.utils.Compatibility;
import net.robotmedia.billing.utils.Security;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.*;
public class BillingController {
public static enum BillingStatus {
UNKNOWN, SUPPORTED, UNSUPPORTED
}
/**
* Used to provide on-demand values to the billing controller.
*/
public interface IConfiguration {
/**
* Returns a salt for the obfuscation of purchases in local memory.
*
* @return array of 20 random bytes.
*/
public byte[] getObfuscationSalt();
/**
* Returns the public key used to verify the signature of responses of
* the Market Billing service.
*
* @return Base64 encoded public key.
*/
public String getPublicKey();
}
private static BillingStatus billingStatus = BillingStatus.UNKNOWN;
private static BillingStatus subscriptionStatus = BillingStatus.UNKNOWN;
private static Set<String> automaticConfirmations = new HashSet<String>();
private static IConfiguration configuration = null;
private static boolean debug = false;
private static ISignatureValidator validator = null;
private static final String JSON_NONCE = "nonce";
private static final String JSON_ORDERS = "orders";
private static HashMap<String, Set<String>> manualConfirmations = new HashMap<String, Set<String>>();
private static Set<IBillingObserver> observers = new HashSet<IBillingObserver>();
public static final String LOG_TAG = "Billing";
private static HashMap<Long, BillingRequest> pendingRequests = new HashMap<Long, BillingRequest>();
/**
* Adds the specified notification to the set of manual confirmations of the
* specified item.
*
* @param itemId id of the item.
* @param notificationId id of the notification.
*/
private static final void addManualConfirmation(String itemId, String notificationId) {
Set<String> notifications = manualConfirmations.get(itemId);
if (notifications == null) {
notifications = new HashSet<String>();
manualConfirmations.put(itemId, notifications);
}
notifications.add(notificationId);
}
/**
* Returns the in-app product billing support status, and checks it
* asynchronously if it is currently unknown. Observers will receive a
* {@link IBillingObserver#onBillingChecked(boolean)} notification in either
* case.
* <p>
* In-app product support does not imply subscription support. To check if
* subscriptions are supported, use
* {@link BillingController#checkSubscriptionSupported(Context)}.
* </p>
*
* @param context
* @return the current in-app product billing support status (unknown,
* supported or unsupported). If it is unsupported, subscriptions
* are also unsupported.
* @see IBillingObserver#onBillingChecked(boolean)
* @see BillingController#checkSubscriptionSupported(Context)
*/
public static BillingStatus checkBillingSupported(Context context) {
if (billingStatus == BillingStatus.UNKNOWN) {
BillingService.checkBillingSupported(context);
} else {
boolean supported = billingStatus == BillingStatus.SUPPORTED;
onBillingChecked(supported);
}
return billingStatus;
}
/**
* <p>
* Returns the subscription billing support status, and checks it
* asynchronously if it is currently unknown. Observers will receive a
* {@link IBillingObserver#onSubscriptionChecked(boolean)} notification in
* either case.
* </p>
* <p>
* No support for subscriptions does not imply that in-app products are also
* unsupported. To check if in-app products are supported, use
* {@link BillingController#checkBillingSupported(Context)}.
* </p>
*
* @param context
* @return the current subscription billing status (unknown, supported or
* unsupported). If it is supported, in-app products are also
* supported.
* @see IBillingObserver#onSubscriptionChecked(boolean)
* @see BillingController#checkBillingSupported(Context)
*/
public static BillingStatus checkSubscriptionSupported(Context context) {
if (subscriptionStatus == BillingStatus.UNKNOWN) {
BillingService.checkSubscriptionSupported(context);
} else {
boolean supported = subscriptionStatus == BillingStatus.SUPPORTED;
onSubscriptionChecked(supported);
}
return subscriptionStatus;
}
/**
* Requests to confirm all pending notifications for the specified item.
*
* @param context
* @param itemId id of the item whose purchase must be confirmed.
* @return true if pending notifications for this item were found, false
* otherwise.
*/
public static boolean confirmNotifications(Context context, String itemId) {
final Set<String> notifications = manualConfirmations.get(itemId);
if (notifications != null) {
confirmNotifications(context, notifications.toArray(new String[]{}));
return true;
} else {
return false;
}
}
/**
* Requests to confirm all specified notifications.
*
* @param context
* @param notifyIds array with the ids of all the notifications to confirm.
*/
private static void confirmNotifications(Context context, String[] notifyIds) {
BillingService.confirmNotifications(context, notifyIds);
}
/**
* Returns the number of purchases for the specified item. Refunded and
* cancelled purchases are not subtracted. See
* {@link #countPurchasesNet(Context, String)} if they need to be.
*
* @param context
* @param itemId id of the item whose purchases will be counted.
* @return number of purchases for the specified item.
*/
public static int countPurchases(Context context, String itemId) {
final byte[] salt = getSalt();
itemId = salt != null ? Security.obfuscate(context, salt, itemId) : itemId;
return TransactionManager.countPurchases(context, itemId);
}
protected static void debug(String message) {
if (debug) {
Log.d(LOG_TAG, message);
}
}
/**
* Requests purchase information for the specified notification. Immediately
* followed by a call to
* {@link #onPurchaseInformationResponse(long, boolean)} and later to
* {@link #onPurchaseStateChanged(Context, String, String)}, if the request
* is successful.
*
* @param context
* @param notifyId id of the notification whose purchase information is
* requested.
*/
private static void getPurchaseInformation(Context context, String notifyId) {
final long nonce = Security.generateNonce();
BillingService.getPurchaseInformation(context, new String[]{notifyId}, nonce);
}
/**
* Gets the salt from the configuration and logs a warning if it's null.
*
* @return salt.
*/
private static byte[] getSalt() {
byte[] salt = null;
if (configuration == null || ((salt = configuration.getObfuscationSalt()) == null)) {
Log.w(LOG_TAG, "Can't (un)obfuscate purchases without salt");
}
return salt;
}
/**
* Lists all transactions stored locally, including cancellations and
* refunds.
*
* @param context
* @return list of transactions.
*/
public static List<Transaction> getTransactions(Context context) {
List<Transaction> transactions = TransactionManager.getTransactions(context);
unobfuscate(context, transactions);
return transactions;
}
/**
* Lists all transactions of the specified item, stored locally.
*
* @param context
* @param itemId id of the item whose transactions will be returned.
* @return list of transactions.
*/
public static List<Transaction> getTransactions(Context context, String itemId) {
final byte[] salt = getSalt();
itemId = salt != null ? Security.obfuscate(context, salt, itemId) : itemId;
List<Transaction> transactions = TransactionManager.getTransactions(context, itemId);
unobfuscate(context, transactions);
return transactions;
}
/**
* Returns true if the specified item has been registered as purchased in
* local memory, false otherwise. Also note that the item might have been
* purchased in another installation, but not yet registered in this one.
*
* @param context
* @param itemId item id.
* @return true if the specified item is purchased, false otherwise.
*/
public static boolean isPurchased(Context context, String itemId) {
final byte[] salt = getSalt();
itemId = salt != null ? Security.obfuscate(context, salt, itemId) : itemId;
return TransactionManager.isPurchased(context, itemId);
}
/**
* Notifies observers of the purchase state change of the specified item.
*
* @param itemId id of the item whose purchase state has changed.
* @param state new purchase state of the item.
*/
private static void notifyPurchaseStateChange(String itemId, Transaction.PurchaseState state) {
for (IBillingObserver o : observers) {
o.onPurchaseStateChanged(itemId, state);
}
}
/**
* Obfuscates the specified purchase. Only the order id, product id and
* developer payload are obfuscated.
*
* @param context
* @param purchase purchase to be obfuscated.
* @see #unobfuscate(Context, Transaction)
*/
static void obfuscate(Context context, Transaction purchase) {
final byte[] salt = getSalt();
if (salt == null) {
return;
}
purchase.orderId = Security.obfuscate(context, salt, purchase.orderId);
purchase.productId = Security.obfuscate(context, salt, purchase.productId);
purchase.developerPayload = Security.obfuscate(context, salt, purchase.developerPayload);
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.request.CheckBillingSupported} request is
* received.
*
* @param supported
*/
protected static void onBillingChecked(boolean supported) {
billingStatus = supported ? BillingStatus.SUPPORTED : BillingStatus.UNSUPPORTED;
if (billingStatus == BillingStatus.UNSUPPORTED) { // Save us the
// subscription
// check
subscriptionStatus = BillingStatus.UNSUPPORTED;
}
for (IBillingObserver o : observers) {
o.onBillingChecked(supported);
}
}
/**
* Called when an IN_APP_NOTIFY message is received.
*
* @param context
* @param notifyId notification id.
*/
protected static void onNotify(Context context, String notifyId) {
debug("Notification " + notifyId + " available");
getPurchaseInformation(context, notifyId);
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.request.RequestPurchase} request is
* received.
*
* @param itemId id of the item whose purchase was requested.
* @param purchaseIntent intent to purchase the item.
*/
protected static void onPurchaseIntent(String itemId, PendingIntent purchaseIntent) {
for (IBillingObserver o : observers) {
o.onPurchaseIntent(itemId, purchaseIntent);
}
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.request.GetPurchaseInformation} request is
* received. Registers all transactions in local memory and confirms those
* who can be confirmed automatically.
*
* @param context
* @param signedData signed JSON data received from the Market Billing service.
* @param signature data signature.
*/
protected static void onPurchaseStateChanged(Context context, String signedData, String signature) {
debug("Purchase state changed");
if (TextUtils.isEmpty(signedData)) {
Log.w(LOG_TAG, "Signed data is empty");
return;
} else {
debug(signedData);
}
if (!debug) {
if (TextUtils.isEmpty(signature)) {
Log.w(LOG_TAG, "Empty signature requires debug mode");
return;
}
final ISignatureValidator validator = BillingController.validator != null ? BillingController.validator
: new DefaultSignatureValidator(BillingController.configuration);
if (!validator.validate(signedData, signature)) {
Log.w(LOG_TAG, "Signature does not match data.");
return;
}
}
List<Transaction> purchases;
try {
JSONObject jObject = new JSONObject(signedData);
if (!verifyNonce(jObject)) {
Log.w(LOG_TAG, "Invalid nonce");
return;
}
purchases = parsePurchases(jObject);
} catch (JSONException e) {
Log.e(LOG_TAG, "JSON exception: ", e);
return;
}
ArrayList<String> confirmations = new ArrayList<String>();
for (Transaction p : purchases) {
if (p.notificationId != null && automaticConfirmations.contains(p.productId)) {
confirmations.add(p.notificationId);
} else {
// TODO: Discriminate between purchases, cancellations and
// refunds.
addManualConfirmation(p.productId, p.notificationId);
}
storeTransaction(context, p);
notifyPurchaseStateChange(p.productId, p.purchaseState);
}
if (!confirmations.isEmpty()) {
final String[] notifyIds = confirmations.toArray(new String[confirmations.size()]);
confirmNotifications(context, notifyIds);
}
}
/**
* Called after a {@link net.robotmedia.billing.BillingRequest} is sent.
*
* @param requestId the id the request.
* @param request the billing request.
*/
protected static void onRequestSent(long requestId, BillingRequest request) {
debug("Request " + requestId + " of type " + request.getRequestType() + " sent");
if (request.isSuccess()) {
pendingRequests.put(requestId, request);
} else if (request.hasNonce()) {
Security.removeNonce(request.getNonce());
}
}
/**
* Called after a {@link net.robotmedia.billing.BillingRequest} is sent.
*
* @param context
* @param requestId the id of the request.
* @param responseCode the response code.
* @see net.robotmedia.billing.request.ResponseCode
*/
protected static void onResponseCode(Context context, long requestId, int responseCode) {
final BillingRequest.ResponseCode response = BillingRequest.ResponseCode.valueOf(responseCode);
debug("Request " + requestId + " received response " + response);
final BillingRequest request = pendingRequests.get(requestId);
if (request != null) {
pendingRequests.remove(requestId);
request.onResponseCode(response);
}
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.request.CheckSubscriptionSupported} request
* is received.
*
* @param supported
*/
protected static void onSubscriptionChecked(boolean supported) {
subscriptionStatus = supported ? BillingStatus.SUPPORTED : BillingStatus.UNSUPPORTED;
if (subscriptionStatus == BillingStatus.SUPPORTED) { // Save us the
// billing check
billingStatus = BillingStatus.SUPPORTED;
}
for (IBillingObserver o : observers) {
o.onSubscriptionChecked(supported);
}
}
protected static void onTransactionsRestored() {
for (IBillingObserver o : observers) {
o.onTransactionsRestored();
}
}
/**
* Parse all purchases from the JSON data received from the Market Billing
* service.
*
* @param data JSON data received from the Market Billing service.
* @return list of purchases.
* @throws JSONException if the data couldn't be properly parsed.
*/
private static List<Transaction> parsePurchases(JSONObject data) throws JSONException {
ArrayList<Transaction> purchases = new ArrayList<Transaction>();
JSONArray orders = data.optJSONArray(JSON_ORDERS);
int numTransactions = 0;
if (orders != null) {
numTransactions = orders.length();
}
for (int i = 0; i < numTransactions; i++) {
JSONObject jElement = orders.getJSONObject(i);
Transaction p = Transaction.parse(jElement);
purchases.add(p);
}
return purchases;
}
/**
* Registers the specified billing observer.
*
* @param observer the billing observer to add.
* @return true if the observer wasn't previously registered, false
* otherwise.
* @see #unregisterObserver(IBillingObserver)
*/
public static boolean registerObserver(IBillingObserver observer) {
return observers.add(observer);
}
/**
* Requests the purchase of the specified item. The transaction will not be
* confirmed automatically.
* <p>
* For subscriptions, use {@link #requestSubscription(Context, String)}
* instead.
* </p>
*
* @param context
* @param itemId id of the item to be purchased.
* @see #requestPurchase(Context, String, boolean)
*/
public static void requestPurchase(Context context, String itemId) {
requestPurchase(context, itemId, false, null);
}
/**
* <p>
* Requests the purchase of the specified item with optional automatic
* confirmation.
* </p>
* <p>
* For subscriptions, use
* {@link #requestSubscription(Context, String, boolean, String)} instead.
* </p>
*
* @param context
* @param itemId id of the item to be purchased.
* @param confirm if true, the transaction will be confirmed automatically. If
* false, the transaction will have to be confirmed with a call
* to {@link #confirmNotifications(Context, String)}.
* @param developerPayload a developer-specified string that contains supplemental
* information about the order.
* @see IBillingObserver#onPurchaseIntent(String, PendingIntent)
*/
public static void requestPurchase(Context context, String itemId, boolean confirm, String developerPayload) {
if (confirm) {
automaticConfirmations.add(itemId);
}
BillingService.requestPurchase(context, itemId, developerPayload);
}
/**
* Requests the purchase of the specified subscription item. The transaction
* will not be confirmed automatically.
*
* @param context
* @param itemId id of the item to be purchased.
* @see #requestSubscription(Context, String, boolean, String)
*/
public static void requestSubscription(Context context, String itemId) {
requestSubscription(context, itemId, false, null);
}
/**
* Requests the purchase of the specified subscription item with optional
* automatic confirmation.
*
* @param context
* @param itemId id of the item to be purchased.
* @param confirm if true, the transaction will be confirmed automatically. If
* false, the transaction will have to be confirmed with a call
* to {@link #confirmNotifications(Context, String)}.
* @param developerPayload a developer-specified string that contains supplemental
* information about the order.
* @see IBillingObserver#onPurchaseIntent(String, PendingIntent)
*/
public static void requestSubscription(Context context, String itemId, boolean confirm, String developerPayload) {
if (confirm) {
automaticConfirmations.add(itemId);
}
BillingService.requestSubscription(context, itemId, developerPayload);
}
/**
* Requests to restore all transactions.
*
* @param context
*/
public static void restoreTransactions(Context context) {
final long nonce = Security.generateNonce();
BillingService.restoreTransations(context, nonce);
}
/**
* Sets the configuration instance of the controller.
*
* @param config configuration instance.
*/
public static void setConfiguration(IConfiguration config) {
configuration = config;
}
/**
* Sets debug mode.
*
* @param value
*/
public static final void setDebug(boolean value) {
debug = value;
}
/**
* Sets a custom signature validator. If no custom signature validator is
* provided,
* {@link net.robotmedia.billing.signature.DefaultSignatureValidator} will
* be used.
*
* @param validator signature validator instance.
*/
public static void setSignatureValidator(ISignatureValidator validator) {
BillingController.validator = validator;
}
/**
* Starts the specified purchase intent with the specified activity.
*
* @param activity
* @param purchaseIntent purchase intent.
* @param intent
*/
public static void startPurchaseIntent(Activity activity, PendingIntent purchaseIntent, Intent intent) {
if (Compatibility.isStartIntentSenderSupported()) {
// This is on Android 2.0 and beyond. The in-app buy page activity
// must be on the activity stack of the application.
Compatibility.startIntentSender(activity, purchaseIntent.getIntentSender(), intent);
} else {
// This is on Android version 1.6. The in-app buy page activity must
// be on its own separate activity stack instead of on the activity
// stack of the application.
try {
purchaseIntent.send(activity, 0 /* code */, intent);
} catch (CanceledException e) {
Log.e(LOG_TAG, "Error starting purchase intent", e);
}
}
}
static void storeTransaction(Context context, Transaction t) {
final Transaction t2 = t.clone();
obfuscate(context, t2);
TransactionManager.addTransaction(context, t2);
}
static void unobfuscate(Context context, List<Transaction> transactions) {
for (Transaction p : transactions) {
unobfuscate(context, p);
}
}
/**
* Unobfuscate the specified purchase.
*
* @param context
* @param purchase purchase to unobfuscate.
* @see #obfuscate(Context, Transaction)
*/
static void unobfuscate(Context context, Transaction purchase) {
final byte[] salt = getSalt();
if (salt == null) {
return;
}
purchase.orderId = Security.unobfuscate(context, salt, purchase.orderId);
purchase.productId = Security.unobfuscate(context, salt, purchase.productId);
purchase.developerPayload = Security.unobfuscate(context, salt, purchase.developerPayload);
}
/**
* Unregisters the specified billing observer.
*
* @param observer the billing observer to unregister.
* @return true if the billing observer was unregistered, false otherwise.
* @see #registerObserver(IBillingObserver)
*/
public static boolean unregisterObserver(IBillingObserver observer) {
return observers.remove(observer);
}
private static boolean verifyNonce(JSONObject data) {
long nonce = data.optLong(JSON_NONCE);
if (Security.isNonceKnown(nonce)) {
Security.removeNonce(nonce);
return true;
} else {
return false;
}
}
protected static void onRequestPurchaseResponse(String itemId, BillingRequest.ResponseCode response) {
for (IBillingObserver o : observers) {
o.onRequestPurchaseResponse(itemId, response);
}
}
}