package co.smartreceipts.android.purchases;
import android.app.Activity;
import android.app.Application;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.os.Bundle;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.widget.Toast;
import com.android.vending.billing.IInAppBillingService;
import com.google.common.base.Preconditions;
import com.google.gson.Gson;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import co.smartreceipts.android.R;
import co.smartreceipts.android.analytics.Analytics;
import co.smartreceipts.android.analytics.events.DataPoint;
import co.smartreceipts.android.analytics.events.DefaultDataPointEvent;
import co.smartreceipts.android.analytics.events.Events;
import co.smartreceipts.android.di.scopes.ApplicationScope;
import co.smartreceipts.android.purchases.lifecycle.PurchaseManagerActivityLifecycleCallbacks;
import co.smartreceipts.android.purchases.model.AvailablePurchase;
import co.smartreceipts.android.purchases.model.ConsumablePurchase;
import co.smartreceipts.android.purchases.model.InAppPurchase;
import co.smartreceipts.android.purchases.model.ManagedProduct;
import co.smartreceipts.android.purchases.model.ManagedProductFactory;
import co.smartreceipts.android.purchases.model.Subscription;
import co.smartreceipts.android.purchases.rx.RxInAppBillingServiceConnection;
import co.smartreceipts.android.purchases.source.PurchaseSource;
import co.smartreceipts.android.purchases.wallet.PurchaseWallet;
import co.smartreceipts.android.utils.log.Logger;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.functions.BiFunction;
import io.reactivex.schedulers.Schedulers;
@ApplicationScope
public class PurchaseManager {
public static final int REQUEST_CODE = 5435;
private static final int BILLING_RESPONSE_CODE_OK = 0;
private static final int API_VERSION = 3;
// Purchase state codes
private static final int PURCHASE_STATE_PURCHASED = 0;
private static final int PURCHASE_STATE_CANCELLED = 1;
private static final int PURCHASE_STATE_REFUNDED = 2;
/**
* Apparently, this has to be the same across all future sessions to recover this information, so we
* can't use a random value (unless we build in server-side logic). Adding a hard-coded value instead,
* since anyone can just download the source for this app anyway
*/
private static final String HARDCODED_DEVELOPER_PAYLOAD = "1234567890";
private final Context context;
private final PurchaseWallet purchaseWallet;
private final Analytics analytics;
private final CopyOnWriteArrayList<PurchaseEventsListener> listeners;
private final String sessionDeveloperPayload;
private final RxInAppBillingServiceConnection rxInAppBillingServiceConnection;
private final Scheduler subscribeOnScheduler;
private final Scheduler observeOnScheduler;
private final AtomicReference<WeakReference<Activity>> activityReference = new AtomicReference<>(new WeakReference<Activity>(null));
private final Gson gson = new Gson();
private CompositeDisposable compositeDisposable;
private volatile PurchaseSource mPurchaseSource;
@Inject
public PurchaseManager(Context context, PurchaseWallet purchaseWallet, Analytics analytics) {
this(context, purchaseWallet, analytics, Schedulers.io(), AndroidSchedulers.mainThread());
}
public PurchaseManager(@NonNull Context context, @NonNull PurchaseWallet purchaseWallet, @NonNull Analytics analytics,
@NonNull Scheduler subscribeOnScheduler, @NonNull Scheduler observeOnScheduler) {
this.context = context.getApplicationContext();
this.purchaseWallet = purchaseWallet;
this.analytics = analytics;
this.subscribeOnScheduler = Preconditions.checkNotNull(subscribeOnScheduler);
this.observeOnScheduler = Preconditions.checkNotNull(observeOnScheduler);
this.rxInAppBillingServiceConnection = new RxInAppBillingServiceConnection(context);
this.listeners = new CopyOnWriteArrayList<>();
this.sessionDeveloperPayload = HARDCODED_DEVELOPER_PAYLOAD;
}
/**
* Adds an event listener to our stack in order to start receiving callbacks
*
* @param listener the listener to register
* @return {@code true} if it was successfully registered. {@code false} otherwise
*/
public boolean addEventListener(@NonNull PurchaseEventsListener listener) {
return listeners.add(listener);
}
/**
* Removes an event listener from our stack in order to stop receiving callbacks
*
* @param listener the listener to unregister
* @return {@code true} if it was successfully unregistered. {@code false} otherwise
*/
public boolean removeEventListener(@NonNull PurchaseEventsListener listener) {
return listeners.remove(listener);
}
/**
* Initializes this class by binding to Google's {@link IInAppBillingService}, fetching a complete
* list of all entities that we own, and finally persisting all changes to our {@link PurchaseWallet}.
*/
public void initialize(@NonNull Application application) {
Logger.debug(PurchaseManager.this, "Initializing the purchase manager");
application.registerActivityLifecycleCallbacks(new PurchaseManagerActivityLifecycleCallbacks(this));
getAllOwnedPurchases()
.subscribeOn(subscribeOnScheduler)
.subscribe(managedProducts -> Logger.debug(PurchaseManager.this, "Successfully initialized all user owned purchases {}.", managedProducts),
throwable -> Logger.error(PurchaseManager.this, "Failed to initialize all user owned purchases.", throwable));
}
/**
* Should be called whenever we resume a new activity in order to allow us to use it for initiating
* purchases
*
* @param activity the current {@link Activity}
*/
public void onActivityResumed(@NonNull Activity activity) {
final Activity existingActivity = activityReference.get().get();
if (!activity.equals(existingActivity)) {
activityReference.set(new WeakReference<>(activity));
}
compositeDisposable = new CompositeDisposable();
}
/**
* Called whenever we pause our current {@link Activity}
*/
public void onActivityPaused() {
if (compositeDisposable != null) {
compositeDisposable.dispose();
}
}
public void initiatePurchase(@NonNull final InAppPurchase inAppPurchase, @NonNull final PurchaseSource purchaseSource) {
Logger.info(PurchaseManager.this, "Initiating purchase of {} from {}.", inAppPurchase, purchaseSource);
analytics.record(new DefaultDataPointEvent(Events.Purchases.ShowPurchaseIntent).addDataPoint(new DataPoint("sku", inAppPurchase.getSku())).addDataPoint(new DataPoint("source", purchaseSource)));
compositeDisposable.add(getPurchaseIntent(inAppPurchase, purchaseSource)
.subscribeOn(subscribeOnScheduler)
.observeOn(observeOnScheduler)
.subscribe(pendingIntent -> {
try {
final Activity existingActivity = activityReference.get().get();
if (existingActivity != null) {
existingActivity.startIntentSenderForResult(pendingIntent.getIntentSender(), PurchaseManager.REQUEST_CODE, new Intent(), 0, 0, 0);
}
} catch (IntentSender.SendIntentException e) {
Toast.makeText(context, R.string.purchase_unavailable, Toast.LENGTH_SHORT).show();
}
}, throwable -> Toast.makeText(context, R.string.purchase_unavailable, Toast.LENGTH_SHORT).show()));
}
@VisibleForTesting
public void sendMockPurchaseRequest(@NonNull InAppPurchase inAppPurchase) {
try {
final Intent data = new Intent();
final JSONObject json = new JSONObject();
json.put("developerPayload", sessionDeveloperPayload);
json.put("productId", inAppPurchase.getSku());
data.putExtra("RESPONSE_CODE", BILLING_RESPONSE_CODE_OK);
data.putExtra("INAPP_PURCHASE_DATA", json.toString());
onActivityResult(REQUEST_CODE, Activity.RESULT_OK, data);
} catch (JSONException e) {
Logger.error(PurchaseManager.this, e.toString());
}
}
/**
* Attempts to complete a purchase request as part of the activity results
*
* @return {@code true} if we handled the request. {@code false} otherwise
*/
public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
final PurchaseSource purchaseSource = mPurchaseSource != null ? mPurchaseSource : PurchaseSource.Unknown;
mPurchaseSource = null;
if (data == null) {
return false;
}
if (requestCode == REQUEST_CODE) {
final int responseCode = data.getIntExtra("RESPONSE_CODE", 0);
final String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
final String inAppDataSignature = data.getStringExtra("INAPP_DATA_SIGNATURE");
if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_CODE_OK) {
try {
final JSONObject json = new JSONObject(purchaseData);
final String actualDeveloperPayload = json.getString("developerPayload");
if (sessionDeveloperPayload.equals(actualDeveloperPayload)) {
final String sku = json.getString("productId");
final InAppPurchase inAppPurchase = InAppPurchase.from(sku);
if (inAppPurchase != null) {
purchaseWallet.addPurchaseToWallet(new ManagedProductFactory(inAppPurchase, purchaseData, inAppDataSignature).get());
for (final PurchaseEventsListener listener : listeners) {
listener.onPurchaseSuccess(inAppPurchase, purchaseSource);
}
} else {
for (final PurchaseEventsListener listener : listeners) {
listener.onPurchaseFailed(mPurchaseSource);
}
Logger.warn(PurchaseManager.this, "Retrieved an unknown sku following a successful purchase: {}", sku);
}
}
} catch (JSONException e) {
Logger.error(PurchaseManager.this, "Failed to find purchase information", e);
for (final PurchaseEventsListener listener : listeners) {
listener.onPurchaseFailed(purchaseSource);
}
}
} else {
Logger.warn(PurchaseManager.this, "Unexpected {resultCode, responseCode} pair: {" + resultCode + ", " + responseCode + "}");
for (final PurchaseEventsListener listener : listeners) {
listener.onPurchaseFailed(purchaseSource);
}
}
return true;
} else {
return false;
}
}
@NonNull
public Observable<Set<ManagedProduct>> getAllOwnedPurchases() {
return Observable.combineLatest(getOwnedConsumablePurchases(), getOwnedSubscriptions(),
(consumablePurchases, subscriptions) -> {
final HashSet<ManagedProduct> combinedSet = new HashSet<>();
combinedSet.addAll(consumablePurchases);
combinedSet.addAll(subscriptions);
return combinedSet;
})
.map(purchasedProducts -> {
purchaseWallet.updatePurchasesInWallet(purchasedProducts);
return purchaseWallet.getActivePurchases();
})
.subscribeOn(subscribeOnScheduler);
}
@NonNull
public Observable<Set<AvailablePurchase>> getAllAvailablePurchases() {
return Observable.combineLatest(getAvailableConsumablePurchases(), getAvailableSubscriptions(),
new BiFunction<Set<AvailablePurchase>, Set<AvailablePurchase>, Set<AvailablePurchase>>() {
@Override
public Set<AvailablePurchase> apply(@io.reactivex.annotations.NonNull Set<AvailablePurchase> consumablePurchases,
@io.reactivex.annotations.NonNull Set<AvailablePurchase> subscriptions) throws Exception {
final HashSet<AvailablePurchase> combinedSet = new HashSet<>();
combinedSet.addAll(consumablePurchases);
combinedSet.addAll(subscriptions);
return combinedSet;
}
})
.map(inAppPurchases -> {
final Set<AvailablePurchase> trimmedInAppPurchases = new HashSet<>();
for (final AvailablePurchase availablePurchase : inAppPurchases) {
if (availablePurchase.getInAppPurchase() != null && !purchaseWallet.hasActivePurchase(availablePurchase.getInAppPurchase())) {
trimmedInAppPurchases.add(availablePurchase);
} else {
Logger.debug(PurchaseManager.this, "Omitting {} from available purchases as we're tracking it as owned.", availablePurchase.getInAppPurchase());
}
}
return trimmedInAppPurchases;
})
.subscribeOn(subscribeOnScheduler);
}
@NonNull
public Observable<Set<InAppPurchase>> getAllAvailablePurchaseSkus() {
return getAllAvailablePurchases()
.map(availablePurchases ->{
final Set<InAppPurchase> inAppPurchases = new HashSet<>();
for (final AvailablePurchase availablePurchase : availablePurchases) {
inAppPurchases.add(availablePurchase.getInAppPurchase());
}
return inAppPurchases;
});
}
/**
* Attempts to consume the purchase of a given {@link ConsumablePurchase}
*
* @param consumablePurchase the product to consume
*
* @return an {@link io.reactivex.Completable} with the success/error result
*/
@NonNull
public Completable consumePurchase(@NonNull final ConsumablePurchase consumablePurchase) {
Logger.info(PurchaseManager.this, "Consuming the purchase of {}", consumablePurchase.getInAppPurchase());
return rxInAppBillingServiceConnection.bindToInAppBillingService()
.flatMapCompletable(inAppBillingService -> Completable.create(emitter -> {
try {
final int responseCode = inAppBillingService.consumePurchase(API_VERSION, context.getPackageName(), consumablePurchase.getPurchaseToken());
if (BILLING_RESPONSE_CODE_OK == responseCode) {
Logger.info(PurchaseManager.this, "Successfully consumed the purchase of {}", consumablePurchase.getInAppPurchase());
emitter.onComplete();
} else {
Logger.warn(PurchaseManager.this, "Received an unexpected response code, {}, for the consumption of this product.", responseCode);
emitter.onError(new Exception("Received an unexpected response code for the consumption of this product."));
}
} catch (RemoteException e) {
emitter.onError(e);
}
}));
}
@VisibleForTesting
Observable<PendingIntent> getPurchaseIntent(@NonNull final InAppPurchase inAppPurchase, @NonNull final PurchaseSource purchaseSource) {
return rxInAppBillingServiceConnection.bindToInAppBillingService()
.flatMap(inAppBillingService -> Observable.create(emitter -> {
try {
final Bundle buyIntentBundle = inAppBillingService.getBuyIntent(API_VERSION, context.getPackageName(), inAppPurchase.getSku(), inAppPurchase.getProductType(), sessionDeveloperPayload);
final PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT");
if (buyIntentBundle.getInt("RESPONSE_CODE") == BILLING_RESPONSE_CODE_OK && pendingIntent != null) {
mPurchaseSource = purchaseSource;
emitter.onNext(pendingIntent);
emitter.onComplete();
} else {
Logger.warn(PurchaseManager.this, "Received an unexpected response code, {}, for the buy intent.", buyIntentBundle.getInt("RESPONSE_CODE"));
emitter.onError(new Exception("Received an unexpected response code for the buy intent."));
}
} catch (RemoteException e) {
emitter.onError(e);
}
}));
}
@NonNull
@VisibleForTesting
Observable<Set<ManagedProduct>> getOwnedConsumablePurchases() {
return getOwnedManagedProductType(ConsumablePurchase.GOOGLE_PRODUCT_TYPE);
}
@NonNull
@VisibleForTesting
Observable<Set<ManagedProduct>> getOwnedSubscriptions() {
return getOwnedManagedProductType(Subscription.GOOGLE_PRODUCT_TYPE);
}
@NonNull
@VisibleForTesting
Observable<Set<AvailablePurchase>> getAvailableConsumablePurchases() {
return getAvailablePurchases(InAppPurchase.getConsumablePurchaseSkus(), ConsumablePurchase.GOOGLE_PRODUCT_TYPE);
}
@NonNull
@VisibleForTesting
Observable<Set<AvailablePurchase>> getAvailableSubscriptions() {
return getAvailablePurchases(InAppPurchase.getSubscriptionSkus(), Subscription.GOOGLE_PRODUCT_TYPE);
}
@NonNull
private Observable<Set<ManagedProduct>> getOwnedManagedProductType(@NonNull final String googleProductType) {
return rxInAppBillingServiceConnection.bindToInAppBillingService()
.flatMap(inAppBillingService -> Observable.create(emitter -> {
try {
final Bundle ownedItems = inAppBillingService.getPurchases(API_VERSION, context.getPackageName(), googleProductType, null);
if (ownedItems.getInt("RESPONSE_CODE") == BILLING_RESPONSE_CODE_OK) {
final ArrayList<String> ownedSkus = ownedItems.getStringArrayList("INAPP_PURCHASE_ITEM_LIST");
final ArrayList<String> purchaseDataList = ownedItems.getStringArrayList("INAPP_PURCHASE_DATA_LIST");
final ArrayList<String> signatureList = ownedItems.getStringArrayList("INAPP_DATA_SIGNATURE_LIST");
final Set<ManagedProduct> purchasedProducts = new HashSet<ManagedProduct>();
for (int i = 0; i < purchaseDataList.size(); ++i) {
final String purchaseDataString = purchaseDataList.get(i);
final JSONObject purchaseData = new JSONObject(purchaseDataString);
final String inAppDataSignature = signatureList.get(i);
final String purchaseToken = purchaseData.getString("purchaseToken");
final String sku = ownedSkus.get(i);
final InAppPurchase inAppPurchase = InAppPurchase.from(sku);
final int purchaseState = purchaseData.has("purchaseState") ? purchaseData.getInt("purchaseState") : PURCHASE_STATE_PURCHASED;
if (inAppPurchase != null && purchaseState == PURCHASE_STATE_PURCHASED) {
purchasedProducts.add(new ManagedProductFactory(inAppPurchase, purchaseDataString, inAppDataSignature).get());
} else {
Logger.warn(PurchaseManager.this, "Failed to process {} in purchase state {}.", sku, purchaseState);
}
}
emitter.onNext(purchasedProducts);
emitter.onComplete();
} else {
Logger.error(PurchaseManager.this, "Failed to retrieve " + googleProductType + " due to response code error");
emitter.onError(new Exception("Failed to retrieve " + googleProductType + " due to response code error"));
}
} catch (RemoteException | JSONException e) {
Logger.error(PurchaseManager.this, "Failed to retrieve the user's owned InAppPurchases", e);
emitter.onError(e);
}
}));
}
@NonNull
private Observable<Set<AvailablePurchase>> getAvailablePurchases(@NonNull final ArrayList<String> skus, @NonNull final String googleProductType) {
return rxInAppBillingServiceConnection.bindToInAppBillingService()
.flatMap(inAppBillingService -> Observable.create(emitter -> {
try {
// Next, let's figure out what is available for purchase
final Set<AvailablePurchase> availablePurchases = new HashSet<>();
final Bundle subscriptionsQueryBundle = new Bundle();
subscriptionsQueryBundle.putStringArrayList("ITEM_ID_LIST", skus);
final Bundle skuDetails = inAppBillingService.getSkuDetails(3, context.getPackageName(), googleProductType, subscriptionsQueryBundle);
if (skuDetails.getInt("RESPONSE_CODE") == BILLING_RESPONSE_CODE_OK) {
final ArrayList<String> responseList = skuDetails.getStringArrayList("DETAILS_LIST");
for (final String response : responseList) {
final AvailablePurchase availablePurchase = gson.fromJson(response, AvailablePurchase.class);
final InAppPurchase inAppPurchase = availablePurchase.getInAppPurchase();
if (inAppPurchase != null && !PurchaseManager.this.purchaseWallet.hasActivePurchase(inAppPurchase)) {
availablePurchases.add(availablePurchase);
} else {
Logger.warn(PurchaseManager.this, "Unknown or already owned sku returned from the available purchases query: {}.", availablePurchase.getInAppPurchase());
}
}
emitter.onNext(availablePurchases);
emitter.onComplete();
} else {
Logger.error(PurchaseManager.this, "Failed to get available skus for purchase");
if (!emitter.isDisposed()) {
emitter.onError(new Exception("Failed to get available skus for purchase"));
}
}
} catch (RemoteException e) {
Logger.error(PurchaseManager.this, "Failed to get available skus for purchase", e);
if (!emitter.isDisposed()) {
emitter.onError(e);
}
}
}));
}
}