/* * Copyright 2014 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: se.solovyev@gmail.com * Site: http://se.solovyev.org */ package org.solovyev.android.checkout; import android.app.Activity; import android.app.Service; import android.content.Context; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executor; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; /** * Billing API helper class. Can be be used in the context of {@link android.app.Activity} or * {@link android.app.Service}. In such case its lifespan should be bound to the lifecycle of the * bound activity/service. For example, {@link #start()} and {@link #stop()} methods of this class * should be called from the appropriate methods of activity:<br/> * <pre>{@code * public class MainActivity extends Activity { * * private final ActivityCheckout mCheckout = Checkout.forActivity(this, getCheckout()); * * private final RequestListener<Purchase> mPurchaseListener = new BillingListener<Purchase>() { * public void onSuccess(Purchase purchase) { * // item was purchased * // ... * } * }; * * protected void onCreate(Bundle savedInstanceState) { * super.onCreate(savedInstanceState); * // ... * mCheckout.start(new Checkout.EmptyListener() { * public void onReady(BillingRequests requests, String product, boolean supported) { * if (supported) { * // billing for a product is supported * // ... * } * } * }); * * mCheckout.createPurchaseFlow(mPurchaseListener); * } * * protected void onActivityResult(int requestCode, int resultCode, Intent data) { * super.onActivityResult(requestCode, resultCode, data); * mCheckout.onActivityResult(requestCode, resultCode, data); * } * * protected void onDestroy() { * mCheckout.stop(); * super.onDestroy(); * } * } * }</pre> * <br/> * If no more billing information is needed {@link Checkout} can be stopped via {@link #stop()} * method call (internally the Billing service might be unbound from the application). * If needed {@link #start()} can be used to start {@link Checkout} over. Be aware, though, that * {@link #stop()} will cancel all pending requests and remove all previously set listeners. * </p> * <p> * As soon as Billing API is ready for product * {@link Listener#onReady(BillingRequests, String, boolean)} is called. If all the products are * ready {@link Listener#onReady(BillingRequests)} is called. In case of any error while executing * the initial requests {@link Listener#onReady(BillingRequests, String, boolean)} is called with * <code>billingSupported=false</code> * </p> * <p> * <b>Note</b>: currently this class can only be used on the main application thread * </p> */ public class Checkout { @Nullable protected final Context mContext; @Nonnull protected final Billing mBilling; @Nonnull final Object mLock = new Object(); @GuardedBy("mLock") @Nonnull private final Map<String, Boolean> mSupportedProducts = new HashMap<>(); @GuardedBy("mLock") @Nonnull private final Listeners mListeners = new Listeners(); @Nonnull private final OnLoadExecutor mOnLoadExecutor = new OnLoadExecutor(); @GuardedBy("mLock") private Billing.Requests mRequests; @GuardedBy("mLock") @Nonnull private State mState = State.INITIAL; Checkout(@Nullable Context context, @Nonnull Billing billing) { mBilling = billing; mContext = context; } @Nonnull public static ActivityCheckout forActivity(@Nonnull Activity activity, @Nonnull Billing billing) { return new ActivityCheckout(activity, billing); } @Nonnull public static Checkout forService(@Nonnull Service service, @Nonnull Billing billing) { return new Checkout(service, billing); } @Nonnull public static Checkout forApplication(@Nonnull Billing billing) { return new Checkout(null, billing); } @Nonnull Context getContext() { return mBilling.getContext(); } /** * Same as {@link #start(Listener)} but with no initial request listener. */ public void start() { start(null); } /** * Starts this {@link Checkout} and sends an initial request that checks whether billing is * supported for each product available in the Billing API. * * @param listener initial request listener */ public void start(@Nullable final Listener listener) { Check.isMainThread(); synchronized (mLock) { Check.isFalse(mState == State.STARTED, "Already started"); Check.isNull(mRequests, "Already started"); mState = State.STARTED; mBilling.onCheckoutStarted(); mRequests = mBilling.getRequests(mContext); if (listener != null) { mListeners.add(listener); } for (final String product : ProductTypes.ALL) { mRequests.isBillingSupported(product, new RequestListener<Object>() { @Override public void onSuccess(@Nonnull Object result) { onBillingSupported(product, true); } @Override public void onError(int response, @Nonnull Exception e) { onBillingSupported(product, false); } }); } } } /** * Adds an initial request listener to this {@link Checkout} if the initial request hasn't * finished yet or calls appropriate methods of the passed listener if some/all data has * already been loaded. * Depending on the current state of {@link Checkout} some methods of the passed listener might * be called synchronously while other - asynchronously. * * @param listener listener which is notified about the initial request's results */ public void whenReady(@Nonnull Listener listener) { Check.isMainThread(); synchronized (mLock) { for (Map.Entry<String, Boolean> entry : mSupportedProducts.entrySet()) { listener.onReady(mRequests, entry.getKey(), entry.getValue()); } if (isReady()) { checkIsNotStopped(); Check.isNotNull(mRequests); listener.onReady(mRequests); } else { // still waiting mListeners.add(listener); } } } private void checkIsNotStopped() { Check.isFalse(mState == State.STOPPED, "Checkout is stopped"); } private boolean isReady() { Check.isTrue(Thread.holdsLock(mLock), "Should be called from synchronized block"); return mSupportedProducts.size() == ProductTypes.ALL.size(); } private void onBillingSupported(@Nonnull String product, boolean supported) { synchronized (mLock) { mSupportedProducts.put(product, supported); mListeners.onReady(mRequests, product, supported); if (isReady()) { mListeners.onReady(mRequests); mListeners.clear(); } } } /** * Creates an {@link Inventory} object related to this {@link Checkout} instance. This * method also starts loading data defined by the passed inventory <var>request</var>. * * @param request request that defines what data should be loaded * @param callback inventory listener * @return inventory */ @Nonnull public Inventory loadInventory(@Nonnull Inventory.Request request, @Nonnull Inventory.Callback callback) { final Inventory inventory = makeInventory(); inventory.load(request, callback); return inventory; } /** * Creates an {@link Inventory} object related to this {@link Checkout} instance. The created * {@link Inventory} will use a fall-back {@link Inventory} if it is returned from * {@link Billing.Configuration#getFallbackInventory(Checkout, Executor)} method. * * @return inventory */ @Nonnull public Inventory makeInventory() { Check.isMainThread(); synchronized (mLock) { checkIsNotStopped(); } final Inventory inventory; final Inventory fallbackInventory = mBilling.getConfiguration().getFallbackInventory(this, mOnLoadExecutor); if (fallbackInventory == null) { inventory = new CheckoutInventory(this); } else { inventory = new FallingBackInventory(this, fallbackInventory); } return inventory; } /** * Method clears all listeners and cancels all pending requests. After this method is called no * more work can be done with this class unless {@link Checkout#start()} method is called * again. */ public void stop() { Check.isMainThread(); synchronized (mLock) { mSupportedProducts.clear(); mListeners.clear(); if (mState != State.INITIAL) { mState = State.STOPPED; } if (mRequests != null) { mRequests.cancelAll(); mRequests = null; } if (mState == State.STOPPED) { mBilling.onCheckoutStopped(); } } } /** * @param product product * @return the last loaded value for the given product */ public boolean isBillingSupported(@Nonnull String product) { Check.isTrue(ProductTypes.ALL.contains(product), "Product should be added to the products list"); Check.isTrue(mSupportedProducts.containsKey(product), "Billing information is not ready yet"); return mSupportedProducts.get(product); } private enum State { INITIAL, STARTED, STOPPED } /** * Initial request listener, all methods are called on the main application thread */ public interface Listener { /** * Called when {@link BillingRequests#isBillingSupported(String, RequestListener)} finishes * for all the products * * @param requests requests ready to use */ void onReady(@Nonnull BillingRequests requests); /** * Called when {@link BillingRequests#isBillingSupported(String, RequestListener)} finishes * for a <var>product</var> with <var>billingSupported</var> result * * @param requests requests ready to use * @param product product for which check was done * @param billingSupported true if billing is supported for <var>product</var> */ void onReady(@Nonnull BillingRequests requests, @Nonnull String product, boolean billingSupported); } /** * Empty implementation of {@link Checkout.Listener}. Any custom listener that cares only * about a subset of the methods of {@link Checkout.Listener} can subclass this class and * implement only the methods it is interested in. */ public static abstract class EmptyListener implements Listener { @Override public void onReady(@Nonnull BillingRequests requests) { } @Override public void onReady(@Nonnull BillingRequests requests, @Nonnull String product, boolean billingSupported) { } } private static final class Listeners implements Listener { @Nonnull private final List<Listener> mList = new ArrayList<>(); public void add(@Nonnull Listener l) { if (!mList.contains(l)) { mList.add(l); } } @Override public void onReady(@Nonnull BillingRequests requests) { final List<Listener> localList = new ArrayList<>(mList); mList.clear(); for (Listener listener : localList) { listener.onReady(requests); } } @Override public void onReady(@Nonnull BillingRequests requests, @Nonnull String product, boolean billingSupported) { for (Listener listener : mList) { listener.onReady(requests, product, billingSupported); } } public void clear() { mList.clear(); } } private final class OnLoadExecutor implements Executor { @Override public void execute(Runnable command) { final Executor executor; synchronized (mLock) { executor = mRequests != null ? mRequests.getDeliveryExecutor() : null; } if (executor != null) { executor.execute(command); } else { Billing.error("Trying to deliver result on a stopped checkout."); } } } }