/* * 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 com.android.vending.billing.IInAppBillingService; import android.app.Activity; import android.app.Application; import android.app.Service; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.Handler; import android.os.IBinder; import android.os.RemoteException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumMap; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import static java.lang.System.currentTimeMillis; import static org.solovyev.android.checkout.ResponseCodes.ITEM_ALREADY_OWNED; import static org.solovyev.android.checkout.ResponseCodes.ITEM_NOT_OWNED; /** * A core class of the Checkout's implementation of Android's Billing API. * This class is responsible for: * <ol> * <li>Connecting and disconnecting to the billing service</li> * <li>Performing billing requests</li> * <li>Caching the requests results</li> * <li>Creating {@link Checkout} objects</li> * <li>Logging</li> * </ol> * Though, this class can be used on its own to obtain the billing information from Android it's * recommended to use higher abstractions, such as {@link Checkout} and {@link Inventory}, for such * purposes. */ public final class Billing { static final int V3 = 3; static final int V5 = 5; static final long SECOND = 1000L; static final long MINUTE = SECOND * 60L; static final long HOUR = MINUTE * 60L; static final long DAY = HOUR * 24L; @Nonnull private static final String TAG = "Checkout"; @Nonnull private static final EmptyRequestListener sEmptyListener = new EmptyRequestListener(); // a list of states from which transition to this state is allowed @Nonnull private static final EnumMap<State, List<State>> sPreviousStates = new EnumMap<>(State.class); @Nonnull private static Logger sLogger = newLogger(); static { sPreviousStates.put(State.INITIAL, Collections.<State>emptyList()); sPreviousStates.put(State.CONNECTING, Arrays.asList(State.INITIAL, State.FAILED, State.DISCONNECTED, State.DISCONNECTING)); sPreviousStates.put(State.CONNECTED, Collections.singletonList(State.CONNECTING)); sPreviousStates.put(State.DISCONNECTING, Collections.singletonList(State.CONNECTED)); sPreviousStates.put(State.DISCONNECTED, Arrays.asList(State.DISCONNECTING, State.CONNECTING)); sPreviousStates.put(State.FAILED, Collections.singletonList(State.CONNECTING)); } @Nonnull private final Context mContext; @Nonnull private final Object mLock = new Object(); @Nonnull private final StaticConfiguration mConfiguration; @Nonnull private final ConcurrentCache mCache; @Nonnull private final PendingRequests mPendingRequests = new PendingRequests(); @Nonnull private final BillingRequests mRequests = newRequestsBuilder().withTag(null).onBackgroundThread().create(); @GuardedBy("mLock") @Nonnull private final PlayStoreBroadcastReceiver mPlayStoreBroadcastReceiver; @Nonnull private final PlayStoreListener mPlayStoreListener = new PlayStoreListener() { @Override public void onPurchasesChanged() { mCache.removeAll(RequestType.GET_PURCHASES.getCacheKeyType()); } }; @GuardedBy("mLock") @Nullable private IInAppBillingService mService; @GuardedBy("mLock") @Nonnull private State mState = State.INITIAL; @Nonnull private CancellableExecutor mMainThread; @Nonnull private Executor mBackground = Executors.newSingleThreadExecutor(new ThreadFactory() { @Override public Thread newThread(@Nonnull Runnable r) { return new Thread(r, "RequestThread"); } }); @Nonnull private ServiceConnector mConnector = new DefaultServiceConnector(); @GuardedBy("mLock") private int mCheckoutCount; public Billing(@Nonnull Context context, @Nonnull Configuration configuration) { this(context, new Handler(), configuration); Check.isMainThread(); } /** * @param context application or activity context. Needed to bind to the in-app billing * service. * @param configuration billing configuration */ public Billing(@Nonnull Context context, @Nonnull Handler handler, @Nonnull Configuration configuration) { if (context instanceof Application) { // mContext.getApplicationContext() might return null for applications as we allow create Billing before // Application#onCreate is called mContext = context; } else { mContext = context.getApplicationContext(); } mMainThread = new MainThread(handler); mConfiguration = new StaticConfiguration(configuration); Check.isNotEmpty(mConfiguration.getPublicKey()); final Cache cache = configuration.getCache(); mCache = new ConcurrentCache(cache == null ? null : new SafeCache(cache)); mPlayStoreBroadcastReceiver = new PlayStoreBroadcastReceiver(mContext, mLock); } /** * Sometimes Google Play is not that fast in updating information on device. Let's wait it a * little bit as if we don't wait we might cache expired information (though, it will be * updated soon as RequestType#GET_PURCHASES cache entry expires quite often) */ static void waitGooglePlay() { try { Thread.sleep(100L); } catch (InterruptedException e) { error(e); } } @SuppressWarnings("unchecked") @Nonnull private static <R> RequestListener<R> emptyListener() { return sEmptyListener; } static void error(@Nonnull String message) { sLogger.e(TAG, message); } static void error(@Nonnull Exception e) { error(e.getMessage(), e); } static void error(@Nonnull String message, @Nonnull Exception e) { if (e instanceof BillingException) { final BillingException be = (BillingException) e; switch (be.getResponse()) { case ResponseCodes.OK: case ResponseCodes.USER_CANCELED: case ResponseCodes.ACCOUNT_ERROR: sLogger.e(TAG, message, e); break; default: sLogger.e(TAG, message, e); } } else { sLogger.e(TAG, message, e); } } static void debug(@Nonnull String subTag, @Nonnull String message) { sLogger.d(TAG + "/" + subTag, message); } static void debug(@Nonnull String message) { sLogger.d(TAG, message); } static void warning(@Nonnull String message) { sLogger.w(TAG, message); } public static void setLogger(@Nullable Logger logger) { Billing.sLogger = logger == null ? new EmptyLogger() : logger; } /** * @return default cache implementation */ @Nonnull public static Cache newCache() { return new MapCache(); } /** * @return default purchase verifier */ @Nonnull public static PurchaseVerifier newPurchaseVerifier(@Nonnull String publicKey) { return new DefaultPurchaseVerifier(publicKey); } /** * @return default logger */ @Nonnull public static Logger newLogger() { return new DefaultLogger(); } /** * @return logger whose methods are called only on the main thread */ @Nonnull public static Logger newMainThreadLogger(@Nonnull Logger logger) { return new MainThreadLogger(logger); } /** * Cancels listener recursively * * @param listener listener to be cancelled */ static void cancel(@Nonnull RequestListener<?> listener) { if (listener instanceof CancellableRequestListener) { ((CancellableRequestListener) listener).cancel(); } } @Nonnull public Context getContext() { return mContext; } @Nonnull Configuration getConfiguration() { return mConfiguration; } @Nonnull ServiceConnector getConnector() { return mConnector; } void setConnector(@Nonnull ServiceConnector connector) { mConnector = connector; } void setService(@Nullable IInAppBillingService service, boolean connecting) { synchronized (mLock) { final State newState; if (connecting) { if (mState != State.CONNECTING) { // don't leak the service and disconnect directly without going through Billing#setState if (service != null) { mConnector.disconnect(); } return; } newState = service == null ? State.FAILED : State.CONNECTED; } else { if (mState == State.INITIAL || mState == State.DISCONNECTED || mState == State.FAILED) { // preserve the state Check.isNull(mService); return; } // service might be disconnected abruptly but we must go through CONNECTED->DISCONNECTING->DISCONNECTED // routine to free the acquired resources. If, however, the current state was not // CONNECTED (only one option left is CONNECTING) then we should directly jump to // FAILED state as something strange has happened on the billing service side if (mState == State.CONNECTED) { setState(State.DISCONNECTING); } if (mState == State.DISCONNECTING) { newState = State.DISCONNECTED; } else { Check.isTrue(mState == State.CONNECTING, "Unexpected state: " + mState); // DISCONNECTED state can occur only after the established connection. If the // connection was never established it's a newState = State.FAILED; } } mService = service; setState(newState); } } void setBackground(@Nonnull Executor background) { mBackground = background; } void setMainThread(@Nonnull CancellableExecutor mainThread) { mMainThread = mainThread; } void setPurchaseVerifier(@Nonnull PurchaseVerifier purchaseVerifier) { mConfiguration.setPurchaseVerifier(purchaseVerifier); } private void executePendingRequests() { mBackground.execute(mPendingRequests); } @Nonnull State getState() { synchronized (mLock) { return mState; } } void setState(@Nonnull State newState) { synchronized (mLock) { if (mState == newState) { return; } Check.isTrue(sPreviousStates.get(newState).contains(mState), "State " + newState + " can't come right after " + mState + " state"); mState = newState; switch (mState) { case DISCONNECTING: // as we can jump directly from DISCONNECTING to CONNECTED state let's remove // the listener here instead of in DISCONNECTED state. That also will protect // us from getting in the following trap: CONNECTED->DISCONNECTING->CONNECTING->FAILED mPlayStoreBroadcastReceiver.removeListener(mPlayStoreListener); break; case CONNECTED: // CONNECTED is the only state when we know for sure that Play Store is available. // Registering the listener here also means that it should be never registered // in the FAILED state mPlayStoreBroadcastReceiver.addListener(mPlayStoreListener); executePendingRequests(); break; case FAILED: // the play store listener should not be registered in the receiver in case of // failure as FAILED state can't occur after CONNECTED Check.isTrue(!mPlayStoreBroadcastReceiver.contains(mPlayStoreListener), "Leaking the listener"); mMainThread.execute(new Runnable() { @Override public void run() { mPendingRequests.onConnectionFailed(); } }); break; } } } /** * Connects to the Billing service. Called automatically when first request is done, * Use {@link #disconnect()} to disconnect. * It's allowed to call this method several times, if service is already connected nothing will * happen. */ public void connect() { synchronized (mLock) { if (mState == State.CONNECTED) { executePendingRequests(); return; } if (mState == State.CONNECTING) { return; } if (mConfiguration.isAutoConnect() && mCheckoutCount <= 0) { warning("Auto connection feature is turned on. There is no need in calling Billing.connect() manually. See Billing.Configuration.isAutoConnect"); } setState(State.CONNECTING); mMainThread.execute(new Runnable() { @Override public void run() { connectOnMainThread(); } }); } } private void connectOnMainThread() { Check.isMainThread(); final boolean connecting = mConnector.connect(); if (!connecting) { setState(State.FAILED); } } /** * Adds {@link PlayStoreListener} possibly registering a {@link android.content.BroadcastReceiver} * responsible for getting "com.android.vending.billing.PURCHASES_UPDATED" intent from the Play * Store. * * @param listener listener to be added */ public void addPlayStoreListener(@Nonnull PlayStoreListener listener) { synchronized (mLock) { mPlayStoreBroadcastReceiver.addListener(listener); } } /** * Removes previously added {@link PlayStoreListener}. This method might also unregister the * {@link android.content.BroadcastReceiver}. * * @param listener listener to be removed */ public void removePlayStoreListener(@Nonnull PlayStoreListener listener) { synchronized (mLock) { mPlayStoreBroadcastReceiver.removeListener(listener); } } /** * Disconnects from the Billing service cancelling all pending requests if any. Any subsequent * request will automatically reconnect the Billing service. Thus, no more requests should be * scheduled after this method has been called (otherwise the service will be connected again). * It's allowed to call this method several times, if the service is already disconnected * nothing happens. */ public void disconnect() { synchronized (mLock) { if (mState == State.DISCONNECTED || mState == State.DISCONNECTING || mState == State.INITIAL) { return; } if (mState == State.FAILED) { // it would be strange to change the state from FAILED to DISCONNECTING/DISCONNECTED, // thus, just cancelling all pending the requested here and returning without updating // the state mPendingRequests.cancelAll(); return; } if (mState == State.CONNECTED) { setState(State.DISCONNECTING); mMainThread.execute(new Runnable() { @Override public void run() { disconnectOnMainThread(); } }); } else { // if we're still CONNECTING - skip DISCONNECTING state setState(State.DISCONNECTED); } // requests should be cancelled only when Billing#disconnect() is called explicitly as // it's only then we know for sure that no more work should be done mPendingRequests.cancelAll(); } } private void disconnectOnMainThread() { Check.isMainThread(); mConnector.disconnect(); } private int runWhenConnected(@Nonnull Request request, @Nullable Object tag) { return runWhenConnected(request, null, tag); } <R> int runWhenConnected(@Nonnull Request<R> request, @Nullable RequestListener<R> listener, @Nullable Object tag) { if (listener != null) { if (mCache.hasCache()) { listener = new CachingRequestListener<>(request, listener); } request.setListener(listener); } if (tag != null) { request.setTag(tag); } mPendingRequests.add(onConnectedService(request)); connect(); return request.getId(); } /** * Cancels a pending request with the given <var>requestId</var>. * * @param requestId id of request */ public void cancel(int requestId) { mPendingRequests.cancel(requestId); } /** * Cancels all pending requests. */ public void cancelAll() { mPendingRequests.cancelAll(); } @Nonnull private RequestRunnable onConnectedService(@Nonnull final Request request) { return new OnConnectedServiceRunnable(request); } /** * A factory method of {@link RequestsBuilder}. * * @return new instance of {@link RequestsBuilder} */ @Nonnull public RequestsBuilder newRequestsBuilder() { return new RequestsBuilder(); } /** * A factory method of {@link BillingRequests}. The constructed object is tagged with the given * <var>activity</var>. All methods of {@link RequestListener} used in this {@link * BillingRequests} are called on the main application thread. * * @param activity activity * @return requests for given <var>activity</var> */ @Nonnull public BillingRequests getRequests(@Nonnull Activity activity) { return new RequestsBuilder().withTag(activity).onMainThread().create(); } /** * A factory method of {@link BillingRequests}. The constructed object is tagged with the given * <var>service</var> context. All methods of {@link RequestListener} used in this * {@link BillingRequests} are called on the main application thread. * * @param service service * @return requests for given <var>mContext</var> */ @Nonnull public BillingRequests getRequests(@Nonnull Service service) { return new RequestsBuilder().withTag(service).onMainThread().create(); } /** * @return default requests object associated with this {@link Billing} class. All methods of * {@link RequestListener} used in it are called on the main application thread. */ @Nonnull public BillingRequests getRequests() { return mRequests; } @Nonnull Requests getRequests(@Nullable Context context) { if (context instanceof Activity) { return (Requests) getRequests((Activity) context); } else if (context instanceof Service) { return (Requests) getRequests((Service) context); } else { Check.isNull(context); return (Requests) getRequests(); } } @Nonnull PurchaseFlow createPurchaseFlow(@Nonnull Activity activity, int requestCode, @Nonnull RequestListener<Purchase> listener) { if (mCache.hasCache()) { listener = new RequestListenerWrapper<Purchase>(listener) { @Override public void onSuccess(@Nonnull Purchase result) { mCache.removeAll(RequestType.GET_PURCHASES.getCacheKeyType()); super.onSuccess(result); } }; } return new PurchaseFlow(activity, requestCode, listener, mConfiguration.getPurchaseVerifier()); } @Nonnull private <R> RequestListener<R> onMainThread(@Nonnull final RequestListener<R> listener) { return new MainThreadRequestListener<>(mMainThread, listener); } public void onCheckoutStarted() { Check.isMainThread(); synchronized (mLock) { mCheckoutCount++; if (mCheckoutCount > 0 && mConfiguration.isAutoConnect()) { connect(); } } } void onCheckoutStopped() { Check.isMainThread(); synchronized (mLock) { mCheckoutCount--; if (mCheckoutCount < 0) { mCheckoutCount = 0; warning("Billing#onCheckoutStopped is called more than Billing#onCheckoutStarted"); } if (mCheckoutCount == 0 && mConfiguration.isAutoConnect()) { disconnect(); } } } /** * Service connection state */ enum State { /** * Service is not connected, no requests can be done, initial state */ INITIAL, /** * Service is connecting */ CONNECTING, /** * Service is connected, requests can be executed */ CONNECTED, /** * Service is disconnecting */ DISCONNECTING, /** * Service is disconnected */ DISCONNECTED, /** * Service failed to connect */ FAILED } interface ServiceConnector { boolean connect(); void disconnect(); } /** * An interface that represents {@link Billing}'s configuration. Each {@link Billing} object * gets an instance of this class when it is constructed. Once {@link Billing} is created the * configuration can't be changed. * A {@link DefaultConfiguration} can be used as a base class for common configurations. */ public interface Configuration { /** * This is used for verification of purchase signatures. You can find app's base64-encoded * public key in application's page on Google Play Developer Console. Note that this * is *not* "developer public key". * * @return application's public key, encoded in base64. */ @Nonnull String getPublicKey(); /** * Though, Android's Billing API claims to support client caching Checkout library uses its * own cache. The main reason is to avoid too frequent inter-process communication (IPC) * between the app and the billing service. This feature can be disabled if a null * reference is returned by this method. * * @return cache instance to be used for caching, null for no caching * @see Billing#newCache() */ @Nullable Cache getCache(); /** * A hook to perform a custom signature verification via {@link PurchaseVerifier} * interface. * One and only one instance of {@link PurchaseVerifier} is used in {@link Billing}: this * method is called from the {@link Billing}'s constructor and the returned value is cached * and later reused. * * @return {@link PurchaseVerifier} to be used to validate purchases * @see PurchaseVerifier */ @Nonnull PurchaseVerifier getPurchaseVerifier(); /** * A fallback inventory is used to recover purchases that were done in the earlier Billing * API versions and that can't be restored automatically in Billing API v.3 * * @param checkout checkout * @param onLoadExecutor executor to be used to call {@link Inventory.Callback} methods * @return inventory to be used if Billing v.3 is not supported */ @Nullable Inventory getFallbackInventory(@Nonnull Checkout checkout, @Nonnull Executor onLoadExecutor); /** * Internally, Checkout library connects to the Billing service and uses it to perform * the API requests. As often only some application activities require Billing information * there is no need in keeping the connection all the time. Starting and stopping * {@link Billing} manually in the activities that need it is one way to solve the problem. * Another way is to allow {@link Billing} to manage the connection itself. If * <code>true</code> is returned from this method {@link Billing} will count all the * {@link Checkout} objects created in it and will close the connection as soon as the last * {@link Checkout} is destroyed. * * @return true if {@link Billing} should connect to/disconnect from Billing API service * automatically */ boolean isAutoConnect(); } /** * Class that partially implements {@link Configuration} interface. {@link Billing} instance * configured with this class will get a cache from {@link #newCache()}, a purchase verifier * from {@link #newPurchaseVerifier(String)}, no fallback inventory and will auto-connect to * the billing service when needed. */ public abstract static class DefaultConfiguration implements Configuration { @Nullable @Override public Cache getCache() { return newCache(); } @Nonnull @Override public PurchaseVerifier getPurchaseVerifier() { Billing.warning("Default purchase verification procedure is used, please read https://github.com/serso/android-checkout#purchase-verification"); return newPurchaseVerifier(getPublicKey()); } @Nullable @Override public Inventory getFallbackInventory(@Nonnull Checkout checkout, @Nonnull Executor onLoadExecutor) { return null; } @Override public boolean isAutoConnect() { return true; } } /** * {@link Configuration} that caches and re-uses some fields of the original * {@link Configuration} passed to its constructor. */ private static final class StaticConfiguration implements Configuration { @Nonnull private final Configuration mOriginal; @Nonnull private final String mPublicKey; @Nonnull private PurchaseVerifier mPurchaseVerifier; private StaticConfiguration(@Nonnull Configuration original) { mOriginal = original; mPublicKey = original.getPublicKey(); mPurchaseVerifier = original.getPurchaseVerifier(); } @Nonnull @Override public String getPublicKey() { return mPublicKey; } @Nullable @Override public Cache getCache() { return mOriginal.getCache(); } @Nonnull @Override public PurchaseVerifier getPurchaseVerifier() { return mPurchaseVerifier; } void setPurchaseVerifier(@Nonnull PurchaseVerifier purchaseVerifier) { mPurchaseVerifier = purchaseVerifier; } @Nullable @Override public Inventory getFallbackInventory(@Nonnull Checkout checkout, @Nonnull Executor onLoadExecutor) { return mOriginal.getFallbackInventory(checkout, onLoadExecutor); } @Override public boolean isAutoConnect() { return mOriginal.isAutoConnect(); } } private final class OnConnectedServiceRunnable implements RequestRunnable { @GuardedBy("this") @Nullable private Request mRequest; public OnConnectedServiceRunnable(@Nonnull Request request) { mRequest = request; } @Override public boolean run() { final Request localRequest = getRequest(); if (localRequest == null) { // request was cancelled => finish here return true; } if (checkCache(localRequest)) return true; // request is alive, let's check the service state final State localState; final IInAppBillingService localService; synchronized (mLock) { localState = mState; localService = mService; } if (localState == State.CONNECTED) { Check.isNotNull(localService); // service is connected, let's start request try { localRequest.start(localService, mContext.getPackageName()); } catch (RemoteException | RuntimeException | RequestException e) { localRequest.onError(e); } } else { // service is not connected, let's check why if (localState != State.FAILED) { // service was disconnected connect(); return false; } else { // service was not connected in the first place => can't do anything, aborting the request localRequest.onError(ResponseCodes.SERVICE_NOT_CONNECTED); } } return true; } private boolean checkCache(@Nonnull Request request) { if (!mCache.hasCache()) { return false; } final String key = request.getCacheKey(); if (key == null) { return false; } final Cache.Entry entry = mCache.get(request.getType().getCacheKey(key)); if (entry == null) { return false; } request.onSuccess(entry.data); return true; } @Override @Nullable public Request getRequest() { synchronized (this) { return mRequest; } } public void cancel() { synchronized (this) { if (mRequest != null) { Billing.debug("Cancelling request: " + mRequest); mRequest.cancel(); } mRequest = null; } } @Override public int getId() { synchronized (this) { return mRequest != null ? mRequest.getId() : -1; } } @Nullable @Override public Object getTag() { synchronized (this) { return mRequest != null ? mRequest.getTag() : null; } } @Override public String toString() { return String.valueOf(mRequest); } } /** * A {@link BillingRequests} builder. Allows to specify request tags and result delivery * methods */ public final class RequestsBuilder { @Nullable private Object mTag; @Nullable private Boolean mOnMainThread; private RequestsBuilder() { } /** * @param tag tab to be used for all requests initiated by the constructed {@link * BillingRequests} * @return this builder */ @Nonnull public RequestsBuilder withTag(@Nullable Object tag) { Check.isNull(mTag); mTag = tag; return this; } /** * Makes {@link RequestListener} methods to be called on a background thread. * * @return this builder */ @Nonnull public RequestsBuilder onBackgroundThread() { Check.isNull(mOnMainThread); mOnMainThread = false; return this; } /** * Makes {@link RequestListener} methods to be called on the main application thread. * Default choice if neither this nor {@link #onBackgroundThread()} was called. * * @return this builder */ @Nonnull public RequestsBuilder onMainThread() { Check.isNull(mOnMainThread); mOnMainThread = true; return this; } @Nonnull public BillingRequests create() { return new Requests(mTag, mOnMainThread == null ? true : mOnMainThread); } } final class Requests implements BillingRequests { @Nullable private final Object mTag; private final boolean mOnMainThread; private Requests(@Nullable Object tag, boolean onMainThread) { mTag = tag; mOnMainThread = onMainThread; } @Override public int isBillingSupported(@Nonnull String product) { return isBillingSupported(product, emptyListener()); } @Override public int isBillingSupported(@Nonnull String product, int apiVersion) { return isBillingSupported(product, apiVersion, emptyListener()); } @Override public int isBillingSupported(@Nonnull String product, int apiVersion, @Nonnull RequestListener<Object> listener) { Check.isNotEmpty(product); return runWhenConnected(new BillingSupportedRequest(product, apiVersion), wrapListener(listener), mTag); } @Override public int isBillingSupported(@Nonnull final String product, @Nonnull RequestListener<Object> listener) { return isBillingSupported(product, V3, listener); } @Nonnull private <R> RequestListener<R> wrapListener(@Nonnull RequestListener<R> listener) { return mOnMainThread ? onMainThread(listener) : listener; } @Nonnull Executor getDeliveryExecutor() { return mOnMainThread ? mMainThread : SameThreadExecutor.INSTANCE; } @Override public int getPurchases(@Nonnull final String product, @Nullable final String continuationToken, @Nonnull RequestListener<Purchases> listener) { Check.isNotEmpty(product); return runWhenConnected(new GetPurchasesRequest(product, continuationToken, mConfiguration.getPurchaseVerifier()), wrapListener(listener), mTag); } @Override public int getAllPurchases(@Nonnull String product, @Nonnull RequestListener<Purchases> listener) { Check.isNotEmpty(product); final GetAllPurchasesListener getAllPurchasesListener = new GetAllPurchasesListener(listener); final GetPurchasesRequest request = new GetPurchasesRequest(product, null, mConfiguration.getPurchaseVerifier()); getAllPurchasesListener.mRequest = request; return runWhenConnected(request, wrapListener(getAllPurchasesListener), mTag); } @Override public int isPurchased(@Nonnull final String product, @Nonnull final String sku, @Nonnull final RequestListener<Boolean> listener) { Check.isNotEmpty(sku); final IsPurchasedListener isPurchasedListener = new IsPurchasedListener(sku, listener); final GetPurchasesRequest request = new GetPurchasesRequest(product, null, mConfiguration.getPurchaseVerifier()); isPurchasedListener.mRequest = request; return runWhenConnected(request, wrapListener(isPurchasedListener), mTag); } @Override public int getSkus(@Nonnull String product, @Nonnull List<String> skus, @Nonnull RequestListener<Skus> listener) { Check.isNotEmpty(product); Check.isNotEmpty(skus); return runWhenConnected(new GetSkuDetailsRequest(product, skus), wrapListener(listener), mTag); } @Override public int purchase(@Nonnull String product, @Nonnull String sku, @Nullable String payload, @Nonnull PurchaseFlow purchaseFlow) { Check.isNotEmpty(product); Check.isNotEmpty(sku); return runWhenConnected(new PurchaseRequest(product, sku, payload), wrapListener(purchaseFlow), mTag); } @Override public int changeSubscription(@Nonnull List<String> oldSkus, @Nonnull String newSku, @Nullable String payload, @Nonnull PurchaseFlow purchaseFlow) { Check.isNotEmpty(oldSkus); Check.isNotEmpty(newSku); return runWhenConnected( new ChangePurchaseRequest(ProductTypes.SUBSCRIPTION, oldSkus, newSku, payload), wrapListener(purchaseFlow), mTag); } @Override public int changeSubscription(@Nonnull List<Sku> oldSkus, @Nonnull Sku newSku, @Nullable String payload, @Nonnull PurchaseFlow purchaseFlow) { Check.isTrue(ProductTypes.SUBSCRIPTION.equals(newSku.id.product), "Only subscriptions can be downgraded/upgraded"); final List<String> oldSkuIds = new ArrayList<>(oldSkus.size()); for (Sku oldSku : oldSkus) { Check.isTrue(oldSku.id.product.equals(newSku.id.product), "Product type can't be changed"); oldSkuIds.add(oldSku.id.code); } return changeSubscription(oldSkuIds, newSku.id.code, payload, purchaseFlow); } @Override public int isChangeSubscriptionSupported(RequestListener<Object> listener) { return isBillingSupported(ProductTypes.SUBSCRIPTION, Billing.V5, listener); } @Override public int purchase(@Nonnull Sku sku, @Nullable String payload, @Nonnull PurchaseFlow purchaseFlow) { return purchase(sku.id.product, sku.id.code, payload, purchaseFlow); } @Override public int consume(@Nonnull String token, @Nonnull RequestListener<Object> listener) { Check.isNotEmpty(token); return runWhenConnected(new ConsumePurchaseRequest(token), wrapListener(listener), mTag); } @Override public void cancelAll() { mPendingRequests.cancelAll(mTag); } @Override public void cancel(int requestId) { mPendingRequests.cancel(requestId); } /** * This class waits for the result from {@link GetPurchasesRequest} and checks if purchases * contains specified <var>sku</var>. If there is a <var>continuationToken</var> and item * can't be found in this bulk of purchases another (recursive) request is executed (to * load other purchases) and the search is done again. New (additional) request has the * same ID and the same listener as the original request and, thus, can be cancelled with * the original request ID. */ private final class IsPurchasedListener implements CancellableRequestListener<Purchases> { @Nonnull private final String mSku; @Nonnull private final RequestListener<Boolean> mListener; @Nonnull private GetPurchasesRequest mRequest; public IsPurchasedListener(@Nonnull String sku, @Nonnull RequestListener<Boolean> listener) { mSku = sku; mListener = listener; } @Override public void onSuccess(@Nonnull Purchases purchases) { final Purchase purchase = purchases.getPurchase(mSku); if (purchase != null) { mListener.onSuccess(purchase.state == Purchase.State.PURCHASED); return; } if (purchases.continuationToken == null) { mListener.onSuccess(false); return; } mRequest = new GetPurchasesRequest(mRequest, purchases.continuationToken); runWhenConnected(mRequest, mTag); } @Override public void onError(int response, @Nonnull Exception e) { mListener.onError(response, e); } @Override public void cancel() { Billing.cancel(mListener); } } private final class GetAllPurchasesListener implements CancellableRequestListener<Purchases> { @Nonnull private final RequestListener<Purchases> mListener; @Nonnull private final List<Purchase> mPurchases = new ArrayList<>(); @Nonnull private GetPurchasesRequest mRequest; public GetAllPurchasesListener(@Nonnull RequestListener<Purchases> listener) { this.mListener = listener; } @Override public void onSuccess(@Nonnull Purchases purchases) { mPurchases.addAll(purchases.list); // we need to check continuation token if (purchases.continuationToken == null) { mListener.onSuccess(new Purchases(purchases.product, mPurchases, null)); return; } mRequest = new GetPurchasesRequest(mRequest, purchases.continuationToken); runWhenConnected(mRequest, mTag); } @Override public void onError(int response, @Nonnull Exception e) { mListener.onError(response, e); } @Override public void cancel() { Billing.cancel(mListener); } } } private class CachingRequestListener<R> extends RequestListenerWrapper<R> { @Nonnull private final Request<R> mRequest; public CachingRequestListener(@Nonnull Request<R> request, @Nonnull RequestListener<R> listener) { super(listener); Check.isTrue(mCache.hasCache(), "Cache must exist"); this.mRequest = request; } @Override public void onSuccess(@Nonnull R result) { final String key = mRequest.getCacheKey(); final RequestType type = mRequest.getType(); if (key != null) { final long now = currentTimeMillis(); final Cache.Entry entry = new Cache.Entry(result, now + type.expiresIn); mCache.putIfNotExist(type.getCacheKey(key), entry); } switch (type) { case PURCHASE: case CHANGE_PURCHASE: case CONSUME_PURCHASE: // these requests might affect the state of purchases => we need to invalidate caches. // see Billing#onPurchaseFinished() also mCache.removeAll(RequestType.GET_PURCHASES.getCacheKeyType()); break; } super.onSuccess(result); } @Override public void onError(int response, @Nonnull Exception e) { final RequestType type = mRequest.getType(); // sometimes it is possible that cached data is not synchronized with data on Google Play => we can // clear caches if such situation occurs switch (type) { case PURCHASE: case CHANGE_PURCHASE: if (response == ITEM_ALREADY_OWNED) { mCache.removeAll(RequestType.GET_PURCHASES.getCacheKeyType()); } break; case CONSUME_PURCHASE: if (response == ITEM_NOT_OWNED) { mCache.removeAll(RequestType.GET_PURCHASES.getCacheKeyType()); } break; } super.onError(response, e); } } private final class DefaultServiceConnector implements ServiceConnector { @Nonnull private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceDisconnected(ComponentName name) { setService(null, false); } @Override public void onServiceConnected(ComponentName name, IBinder service) { setService(IInAppBillingService.Stub.asInterface(service), true); } }; @Override public boolean connect() { try { final Intent intent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); intent.setPackage("com.android.vending"); return mContext.bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } catch (IllegalArgumentException e) { // some devices throw IllegalArgumentException (Service Intent must be explicit) // even though we set package name explicitly. Let's not crash the app and catch // such exceptions here, the billing on such devices will not work. return false; } catch (NullPointerException e) { // Meizu M3s phones might throw an NPE in Context#bindService (Attempt to read from field 'int com.android.server.am.ProcessRecord.uid' on a null object reference). // As in-app purchases don't work if connection to the billing service can't be // established let's not crash and allow users to continue using the app return false; } } @Override public void disconnect() { mContext.unbindService(mConnection); } } }