net.robotmedia.billing.BillingController Maven / Gradle / Ivy
/*
* Copyright 2013 serso aka se.solovyev
*
* 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.
*
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Contact details
*
* Email: [email protected]
* Site: http://se.solovyev.org
*/
package net.robotmedia.billing;
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.BillingSecurity;
import net.robotmedia.billing.security.DefaultSignatureValidator;
import net.robotmedia.billing.security.ISignatureValidator;
import net.robotmedia.billing.utils.AESObfuscator;
import net.robotmedia.billing.utils.Compatibility;
import net.robotmedia.billing.utils.ObfuscateUtils;
import net.robotmedia.billing.utils.Security;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.solovyev.common.security.CiphererException;
import org.solovyev.common.security.SecurityService;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.SecretKey;
import java.util.*;
public class BillingController {
@Nullable
private static SecretKey secretKey;
@Nonnull
public static SecretKey getSecretKey(@Nonnull Context context) throws CiphererException {
if (secretKey == null) {
final byte[] salt = getSalt();
final String password = BillingSecurity.generatePassword(context);
secretKey = getTransactionObfuscator().getSecretKeyProvider().getSecretKey(password, salt);
}
return secretKey;
}
public static enum BillingStatus {
UNKNOWN,
SUPPORTED,
UNSUPPORTED
}
/**
* Used to provide on-demand values to the billing controller.
*/
public static interface IConfiguration {
/**
* Returns a salt for the obfuscation of purchases in local memory.
* NOTE: this array must be the same during different application starts
* or user must call the net.robotmedia.billing.BillingController#restoreTransactions(android.content.Context) method to get all transaction from market
*
* @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 final String JSON_NONCE = "nonce";
private static final String JSON_ORDERS = "orders";
public static final String LOG_TAG = "Billing";
@Nonnull
private static BillingStatus status = BillingStatus.UNKNOWN;
private static IConfiguration configuration = null;
private static boolean debug = false;
@Nullable
private static ISignatureValidator validator = null;
// todo serso: we only queue to this list and never remove (probably we should do it inside net.robotmedia.billing.BillingController.onPurchaseStateChanged )
// synchronized field
// value: product id with automatic confirmation
@Nonnull
private static final Set automaticConfirmations = new HashSet();
// todo serso: we only queue to this list and never remove (probably we should do it inside net.robotmedia.billing.BillingController.confirmNotifications )
// synchronized field
@Nonnull
private static final Map> manualConfirmations = new HashMap>();
// synchronized field
@Nonnull
private static final Map pendingRequests = new HashMap();
@Nullable
private static SecurityService transactionObfuscator;
/**
* Adds the specified notification to the set of manual confirmations of the
* specified item.
*
* @param productId id of the item.
* @param notificationId id of the notification.
*/
private static void addManualConfirmation(@Nonnull String productId, @Nonnull String notificationId) {
synchronized (manualConfirmations) {
Set notifications = manualConfirmations.get(productId);
if (notifications == null) {
notifications = new HashSet();
manualConfirmations.put(productId, notifications);
}
notifications.add(notificationId);
}
}
/**
* Returns the current billing status. NOTE: current billing status may be not the same as the actual status needs some time to update (but nevertheless it can be used as first approach)
* This method calls billing service to determine the exact status and notify listeners through IBillingObserver#onCheckBillingSupportedResponse(boolean) method
*
* @param context context
* @return the current billing status (unknown, supported or unsupported)
* @see IBillingObserver#onCheckBillingSupportedResponse(boolean)
*/
@Nonnull
public static BillingStatus checkBillingSupported(@Nonnull Context context) {
BillingService.checkBillingSupported(context);
return status;
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.BillingRequest.CheckBillingSupported} request is
* received.
*
* @param supported billing supported
*/
static void onCheckBillingSupportedResponse(boolean supported) {
status = supported ? BillingStatus.SUPPORTED : BillingStatus.UNSUPPORTED;
BillingObserverRegistry.onCheckBillingSupportedResponse(supported);
}
/**
* Requests to confirm all pending MANUAL notifications for the specified item.
*
* @param context context
* @param productId 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(@Nonnull Context context, @Nonnull String productId) {
synchronized (manualConfirmations) {
final Set notifications = manualConfirmations.get(productId);
if (notifications != null) {
confirmNotifications(context, notifications);
return true;
} else {
return false;
}
}
}
/**
* Requests to confirm all specified notifications.
*
* @param context context
* @param notifyIds array with the ids of all the notifications to confirm.
*/
private static void confirmNotifications(@Nonnull Context context, @Nonnull String[] notifyIds) {
BillingService.confirmNotifications(context, notifyIds);
}
/**
* Requests to confirm all specified notifications.
*
* @param context context
* @param notifyIds array with the ids of all the notifications to confirm.
*/
private static void confirmNotifications(@Nonnull Context context, @Nonnull Collection notifyIds) {
BillingService.confirmNotifications(context, notifyIds);
}
/**
* Returns the number of purchases for the specified item. Only transactions with state PURCHASED are counted
*
* @param context context
* @param productId id of the item whose purchases will be counted.
* @return number of purchases for the specified item.
*/
public static int countPurchases(@Nonnull Context context, @Nonnull String productId) {
final String obfuscatedItemId = Security.obfuscate(context, getSalt(), productId);
// item id != null => obfuscatedItemId != null
assert obfuscatedItemId != null;
return TransactionManager.countPurchases(obfuscatedItemId);
}
protected static void debug(@Nullable String message) {
if (debug && message != null) {
Log.d(LOG_TAG, message);
}
}
/**
* Requests purchase information for the specified notification. Immediately
* followed by a call to
* {@link #onPurchaseStateChanged(android.content.Context, String, String)}, if the request
* is successful.
*
* @param context context
* @param notifyId id of the notification whose purchase information is
* requested.
*/
private static void getPurchaseInformation(@Nonnull Context context, @Nonnull 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.
*/
@Nullable
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 context
* @return list of transactions.
*/
@Nonnull
public static List getTransactions(@Nonnull Context context) {
final List transactions = TransactionManager.getTransactions();
ObfuscateUtils.unobfuscate(context, transactions, getSalt());
return transactions;
}
/**
* Lists all transactions of the specified item, stored locally.
*
* @param context context
* @param productId id of the item whose transactions will be returned.
* @return list of transactions.
*/
@Nonnull
public static List getTransactions(@Nonnull Context context, @Nonnull String productId) {
byte[] salt = getSalt();
final String obfuscatedItemId = Security.obfuscate(context, getSalt(), productId);
assert obfuscatedItemId != null;
final List transactions = TransactionManager.getTransactions(obfuscatedItemId);
ObfuscateUtils.unobfuscate(context, transactions, salt);
return transactions;
}
/**
* Returns true if the specified item has been registered as purchased in
* local memory. Note that if the item was later canceled or refunded this
* will still return true. Also note that the item might have been purchased
* in another installation, but not yet registered in this one.
*
* @param context context
* @param productId item id.
* @return true if the specified item is purchased, false otherwise.
*/
public static boolean isPurchased(@Nonnull Context context, @Nonnull String productId) {
final byte[] salt = getSalt();
final String obfuscatedItemId = Security.obfuscate(context, salt, productId);
assert obfuscatedItemId != null;
return TransactionManager.isPurchased(obfuscatedItemId);
}
/**
* Called when an IN_APP_NOTIFY message is received.
*
* @param context context
* @param notifyId notification id.
*/
protected static void onNotify(@Nonnull Context context, @Nonnull String notifyId) {
debug("Notification " + notifyId + " available");
getPurchaseInformation(context, notifyId);
}
/**
* Called after the response to a
* {@link net.robotmedia.billing.BillingRequest.GetPurchaseInformation} request is
* received. Registers all transactions in local memory and confirms those
* who can be confirmed automatically.
*
* @param context context
* @param signedData signed JSON data received from the Market Billing service.
* @param signature data signature.
*/
protected static void onPurchaseStateChanged(@Nonnull Context context, @Nullable String signedData, @Nullable String signature) {
debug("Purchase state changed");
if (TextUtils.isEmpty(signedData)) {
Log.w(LOG_TAG, "Signed data is empty");
return;
}
if (!debug) {
if (TextUtils.isEmpty(signature)) {
Log.w(LOG_TAG, "Empty signature requires debug mode");
return;
}
final ISignatureValidator validator = getSignatureValidator();
if (!validator.validate(signedData, signature)) {
Log.w(LOG_TAG, "Signature does not match data.");
return;
}
}
List transactions;
try {
final JSONObject jObject = new JSONObject(signedData);
if (!verifyNonce(jObject)) {
Log.w(LOG_TAG, "Invalid nonce");
return;
}
transactions = parseTransactions(jObject);
} catch (JSONException e) {
Log.e(LOG_TAG, "JSON exception: ", e);
return;
}
final List confirmations = new ArrayList();
for (Transaction transaction : transactions) {
if (transaction.notificationId != null) {
synchronized (automaticConfirmations) {
if (automaticConfirmations.contains(transaction.productId)) {
confirmations.add(transaction.notificationId);
} else {
// TODO: Discriminate between purchases, cancellations and refunds.
addManualConfirmation(transaction.productId, transaction.notificationId);
}
}
}
storeTransaction(context, transaction);
BillingObserverRegistry.notifyPurchaseStateChange(transaction.productId, transaction.purchaseState);
}
if (!confirmations.isEmpty()) {
final String[] notifyIds = confirmations.toArray(new String[confirmations.size()]);
confirmNotifications(context, notifyIds);
}
}
/**
* Called after a {@link BillingRequest} is
* sent.
*
* @param requestId the id the request.
* @param request the billing request.
*/
protected static void onRequestSent(long requestId, @Nonnull IBillingRequest request) {
debug("Request " + requestId + " of type " + request.getRequestType() + " sent");
if (request.isSuccess()) {
synchronized (pendingRequests) {
pendingRequests.put(requestId, request);
}
} else if (request.hasNonce()) {
// in case of unsuccessful request with nonce we shall unregister nonce
Security.removeNonce(request.getNonce());
}
}
/**
* Called after a {@link BillingRequest} is
* sent.
*
* @param requestId the id of the request.
* @param responseCode the response code.
* @see ResponseCode
*/
protected static void onResponseCode(long requestId, int responseCode) {
final ResponseCode response = ResponseCode.valueOf(responseCode);
debug("Request " + requestId + " received response " + response);
synchronized (pendingRequests) {
final IBillingRequest request = pendingRequests.get(requestId);
if (request != null) {
pendingRequests.remove(requestId);
request.onResponseCode(response);
}
}
}
/**
* 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 org.json.JSONException if the data couldn't be properly parsed.
*/
@Nonnull
private static List parseTransactions(@Nonnull JSONObject data) throws JSONException {
final List result = new ArrayList();
final JSONArray orders = data.optJSONArray(JSON_ORDERS);
if (orders != null) {
for (int i = 0; i < orders.length(); i++) {
final JSONObject jElement = orders.getJSONObject(i);
result.add(Transaction.newInstance(jElement));
}
}
return result;
}
/**
* Requests the purchase of the specified item. The transaction will not be
* confirmed automatically.
*
* @param context context
* @param productId id of the item to be purchased.
* @see #requestPurchase(android.content.Context, String, boolean)
*/
public static void requestPurchase(@Nonnull Context context, @Nonnull String productId) {
requestPurchase(context, productId, false);
}
/**
* Requests the purchase of the specified item with optional automatic
* confirmation.
*
* @param context context
* @param productId id of the item to be purchased.
* @param autoConfirmation if true, the transaction will be confirmed automatically. If
* false, the transaction will have to be confirmed with a call
* to {@link #confirmNotifications(android.content.Context, String)}.
* @see IBillingObserver#onPurchaseIntentOK(String, android.app.PendingIntent)
*/
public static void requestPurchase(@Nonnull Context context,
@Nonnull String productId,
boolean autoConfirmation) {
if (autoConfirmation) {
synchronized (automaticConfirmations) {
automaticConfirmations.add(productId);
}
}
BillingService.requestPurchase(context, productId, null);
}
/**
* Requests to restore all transactions.
*
* @param context context
*/
public static void restoreTransactions(@Nonnull Context context) {
Log.d(BillingController.class.getSimpleName(), "Restoring transactions...");
final long nonce = Security.generateNonce();
BillingService.restoreTransactions(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 debug debug
*/
public static void setDebug(boolean debug) {
BillingController.debug = debug;
}
public static boolean isDebug() {
return debug;
}
/**
* Sets a custom signature validator. If no custom signature validator is
* provided,
* {@link net.robotmedia.billing.security.DefaultSignatureValidator} will
* be used.
*
* @param validator signature validator instance.
*/
@SuppressWarnings({"UnusedDeclaration"})
public static void setSignatureValidator(ISignatureValidator validator) {
BillingController.validator = validator;
}
@Nonnull
static ISignatureValidator getSignatureValidator() {
return BillingController.validator != null ? BillingController.validator : new DefaultSignatureValidator(BillingController.configuration);
}
/**
* Starts the specified purchase intent with the specified activity.
*
* @param context context
* @param purchaseIntent purchase intent.
* @param intent intent
*/
public static void startPurchaseIntent(@Nonnull Context context,
@Nonnull PendingIntent purchaseIntent,
@Nullable Intent intent) {
if (Compatibility.isStartIntentSenderSupported(context)) {
// 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(context, 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(context, 0 /* code */, intent);
} catch (CanceledException e) {
Log.e(LOG_TAG, "Error starting purchase intent", e);
}
}
}
static void storeTransaction(@Nonnull Context context, @Nonnull Transaction t) {
final Transaction clone = t.clone();
ObfuscateUtils.obfuscate(context, clone, getSalt());
TransactionManager.addTransaction(clone);
}
private static boolean verifyNonce(@Nonnull JSONObject data) {
long nonce = data.optLong(JSON_NONCE);
if (Security.isNonceKnown(nonce)) {
Security.removeNonce(nonce);
return true;
} else {
return false;
}
}
public static void dropBillingData(@Nonnull Context context) {
Log.d(BillingController.class.getSimpleName(), "Dropping billing database...");
TransactionManager.dropDatabase(context);
}
static void onRequestPurchaseResponse(@Nonnull String productId, @Nonnull ResponseCode response) {
BillingObserverRegistry.onRequestPurchaseResponse(productId, response);
}
static void onPurchaseIntent(@Nonnull String productId, @Nonnull PendingIntent purchaseIntent) {
BillingObserverRegistry.onPurchaseIntent(productId, purchaseIntent);
}
static void onPurchaseIntentFailure(@Nonnull String productId, @Nonnull ResponseCode responseCode) {
BillingObserverRegistry.onPurchaseIntentFailure(productId, responseCode);
}
static void onTransactionsRestored() {
BillingObserverRegistry.onTransactionsRestored();
}
static void onErrorRestoreTransactions(@Nonnull ResponseCode response) {
BillingObserverRegistry.onErrorRestoreTransactions(response);
}
public static void registerObserver(@Nonnull IBillingObserver billingObserver) {
BillingObserverRegistry.registerObserver(billingObserver);
}
public static void unregisterObserver(@Nonnull IBillingObserver billingObserver) {
BillingObserverRegistry.unregisterObserver(billingObserver);
}
@Nonnull
static SecurityService getTransactionObfuscator() {
if (transactionObfuscator == null) {
transactionObfuscator = BillingSecurity.getObfuscationSecurityService(AESObfuscator.IV, AESObfuscator.SECURITY_PREFIX);
}
return transactionObfuscator;
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy