Permalink
Switch branches/tags
Find file
07dbb29 Oct 16, 2017
@mrmcduff-stripe @ksun-stripe
752 lines (680 sloc) 30 KB
package com.stripe.android;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RestrictTo;
import android.support.annotation.VisibleForTesting;
import android.support.v4.content.LocalBroadcastManager;
import com.stripe.android.exception.APIConnectionException;
import com.stripe.android.exception.APIException;
import com.stripe.android.exception.InvalidRequestException;
import com.stripe.android.exception.StripeException;
import com.stripe.android.model.Customer;
import com.stripe.android.model.ShippingInformation;
import com.stripe.android.model.Source;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* Represents a logged-in session of a single Customer.
*/
public class CustomerSession implements EphemeralKeyManager.KeyManagerListener {
public static final String ACTION_API_EXCEPTION = "action_api_exception";
public static final String EXTRA_EXCEPTION = "exception";
public static final String EVENT_SHIPPING_INFO_SAVED = "shipping_info_saved";
private static final String ACTION_ADD_SOURCE = "add_source";
private static final String ACTION_SET_DEFAULT_SOURCE = "default_source";
private static final String ACTION_SET_CUSTOMER_SHIPPING_INFO = "set_shipping_info";
private static final String KEY_SOURCE = "source";
private static final String KEY_SOURCE_TYPE = "source_type";
private static final String KEY_SHIPPING_INFO = "shipping_info";
private static final String TOKEN_PAYMENT_SESSION = "PaymentSession";
private static final Set<String> VALID_TOKENS =
new HashSet<>(Arrays.asList("AddSourceActivity",
"PaymentMethodsActivity",
"PaymentFlowActivity",
TOKEN_PAYMENT_SESSION,
"ShippingInfoScreen",
"ShippingMethodScreen"));
private @Nullable Customer mCustomer;
private long mCustomerCacheTime;
private @Nullable WeakReference<Context> mCachedContextReference;
private @Nullable CustomerRetrievalListener mCustomerRetrievalListener;
private @Nullable SourceRetrievalListener mSourceRetrievalListener;
private @Nullable EphemeralKey mEphemeralKey;
private @NonNull EphemeralKeyManager mEphemeralKeyManager;
private @NonNull Handler mUiThreadHandler;
private @NonNull Set<String> mProductUsageTokens;
private @Nullable Calendar mProxyNowCalendar;
private @Nullable StripeApiProxy mStripeApiProxy;
// A queue of Runnables for doing customer updates
private final BlockingQueue<Runnable> mNetworkQueue = new LinkedBlockingQueue<>();
private @NonNull ThreadPoolExecutor mThreadPoolExecutor;
private static final int CUSTOMER_RETRIEVED = 7;
private static final int CUSTOMER_ERROR = 11;
private static final int SOURCE_RETRIEVED = 13;
private static final int SOURCE_ERROR = 17;
private static final int CUSTOMER_SHIPPING_INFO_SAVED = 19;
// The maximum number of active threads we support
private static final int THREAD_POOL_SIZE = 3;
// Sets the amount of time an idle thread waits before terminating
private static final int KEEP_ALIVE_TIME = 2;
// Sets the Time Unit to seconds
private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS;
private static final long KEY_REFRESH_BUFFER_IN_SECONDS = 30L;
private static final long CUSTOMER_CACHE_DURATION_MILLISECONDS = TimeUnit.MINUTES.toMillis(1);
private static CustomerSession mInstance;
/**
* Create a CustomerSession with the provided {@link EphemeralKeyProvider}.
*
* @param keyProvider an {@link EphemeralKeyProvider} used to get
* {@link EphemeralKey EphemeralKeys} as needed
*/
public static void initCustomerSession(@NonNull EphemeralKeyProvider keyProvider) {
initCustomerSession(keyProvider, null, null);
}
/**
* Gets the singleton instance of {@link CustomerSession}. If the session has not been
* initialized, this will throw a {@link RuntimeException}.
*
* @return the singleton {@link CustomerSession} instance.
*/
public static CustomerSession getInstance() {
if (mInstance == null) {
throw new IllegalStateException(
"Attempted to get instance of CustomerSession without initialization.");
}
return mInstance;
}
/**
* End the singleton instance of a {@link CustomerSession}.
* Calls to {@link CustomerSession#getInstance()} will throw an {@link IllegalStateException}
* after this call, until the user calls
* {@link CustomerSession#initCustomerSession(EphemeralKeyProvider)} again.
*/
public static void endCustomerSession() {
clearInstance();
}
@VisibleForTesting
static void initCustomerSession(
@NonNull EphemeralKeyProvider keyProvider,
@Nullable StripeApiProxy stripeApiProxy,
@Nullable Calendar proxyNowCalendar) {
mInstance = new CustomerSession(keyProvider, stripeApiProxy, proxyNowCalendar);
}
@VisibleForTesting
static void clearInstance() {
if (mInstance == null) {
return;
}
mInstance.mThreadPoolExecutor.shutdownNow();
mInstance = null;
}
private CustomerSession(
@NonNull EphemeralKeyProvider keyProvider,
@Nullable StripeApiProxy stripeApiProxy,
@Nullable Calendar proxyNowCalendar) {
mThreadPoolExecutor = createThreadPoolExecutor();
mUiThreadHandler = createMainThreadHandler();
mStripeApiProxy = stripeApiProxy;
mProxyNowCalendar = proxyNowCalendar;
mProductUsageTokens = new HashSet<>();
mEphemeralKeyManager = new EphemeralKeyManager(
keyProvider,
this,
KEY_REFRESH_BUFFER_IN_SECONDS,
proxyNowCalendar);
}
@RestrictTo(RestrictTo.Scope.LIBRARY)
public void addProductUsageTokenIfValid(String token) {
if (token != null && VALID_TOKENS.contains(token)) {
mProductUsageTokens.add(token);
}
}
/**
* Retrieve the current {@link Customer}. If the cached value at {@link #mCustomer} is not
* stale, this returns immediately with the cache. If not, it fetches a new value and returns
* that to the listener.
*
* @param listener a {@link CustomerRetrievalListener} to invoke with the result of getting the
* customer, either from the cache or from the server
*/
public void retrieveCurrentCustomer(@NonNull CustomerRetrievalListener listener) {
if (canUseCachedCustomer()) {
listener.onCustomerRetrieved(getCachedCustomer());
} else {
mCustomer = null;
mCustomerRetrievalListener = listener;
mEphemeralKeyManager.retrieveEphemeralKey(null, null);
}
}
/**
* Force an update of the current customer, regardless of how much time has passed.
*
* @param listener a {@link CustomerRetrievalListener} to invoke with the result of getting
* the customer from the server
*/
public void updateCurrentCustomer(@NonNull CustomerRetrievalListener listener) {
mCustomer = null;
mCustomerRetrievalListener = listener;
mEphemeralKeyManager.retrieveEphemeralKey(null, null);
}
/**
* Gets a cached customer, or {@code null} if the current customer has expired.
*
* @return the current value of {@link #mCustomer}, or {@code null} if the customer object is
* expired.
*/
@Nullable
public Customer getCachedCustomer() {
if (canUseCachedCustomer()) {
return mCustomer;
} else {
return null;
}
}
/**
* Add the input source to the current customer object.
*
* @param context the {@link Context} to use for resources
* @param sourceId the ID of the source to be added
* @param listener a {@link SourceRetrievalListener} to be notified when the api call is
* complete
*/
public void addCustomerSource(
@NonNull Context context,
@NonNull String sourceId,
@NonNull @Source.SourceType String sourceType,
@Nullable SourceRetrievalListener listener) {
mCachedContextReference = new WeakReference<>(context);
Map<String, Object> arguments = new HashMap<>();
arguments.put(KEY_SOURCE, sourceId);
arguments.put(KEY_SOURCE_TYPE, sourceType);
mSourceRetrievalListener = listener;
mEphemeralKeyManager.retrieveEphemeralKey(ACTION_ADD_SOURCE, arguments);
}
/**
* Set the shipping information on the current customer object.
*
* @param context a {@link Context} to use for resources
* @param shippingInformation the data to be set
*/
public void setCustomerShippingInformation(
@NonNull Context context,
@NonNull ShippingInformation shippingInformation) {
mCachedContextReference = new WeakReference<>(context);
Map<String, Object> arguments = new HashMap<>();
arguments.put(KEY_SHIPPING_INFO, shippingInformation);
mEphemeralKeyManager.retrieveEphemeralKey(ACTION_SET_CUSTOMER_SHIPPING_INFO, arguments);
}
/**
* Set the default source of the current customer object.
*
* @param context a {@link Context} to use for resources
* @param sourceId the ID of the source to be set
* @param listener a {@link CustomerRetrievalListener} to be notified about an update to the
* customer
*/
public void setCustomerDefaultSource(
@NonNull Context context,
@NonNull String sourceId,
@NonNull @Source.SourceType String sourceType,
@Nullable CustomerRetrievalListener listener) {
mCachedContextReference = new WeakReference<>(context);
Map<String, Object> arguments = new HashMap<>();
arguments.put(KEY_SOURCE, sourceId);
arguments.put(KEY_SOURCE_TYPE, sourceType);
mCustomerRetrievalListener = listener;
mEphemeralKeyManager.retrieveEphemeralKey(ACTION_SET_DEFAULT_SOURCE, arguments);
}
void resetUsageTokens() {
mProductUsageTokens.clear();
}
@Nullable
@VisibleForTesting
Customer getCustomer() {
return mCustomer;
}
@VisibleForTesting
long getCustomerCacheTime() {
return mCustomerCacheTime;
}
@Nullable
@VisibleForTesting
EphemeralKey getEphemeralKey() {
return mEphemeralKey;
}
@VisibleForTesting
Set<String> getProductUsageTokens() {
return mProductUsageTokens;
}
@VisibleForTesting
void setStripeApiProxy(@Nullable StripeApiProxy proxy) {
mStripeApiProxy = proxy;
}
private void addCustomerSource(
@NonNull final WeakReference<Context> contextWeakReference,
@NonNull final EphemeralKey key,
@NonNull final String sourceId,
@NonNull final String sourceType,
@NonNull final List<String> productUsageTokens) {
Runnable fetchCustomerRunnable = new Runnable() {
@Override
public void run() {
try {
Source source = addCustomerSourceWithKey(
contextWeakReference,
key,
new ArrayList<>(productUsageTokens),
sourceId,
sourceType,
mStripeApiProxy);
Message message = mUiThreadHandler.obtainMessage(SOURCE_RETRIEVED, source);
mUiThreadHandler.sendMessage(message);
} catch (StripeException stripeEx) {
Message message = mUiThreadHandler.obtainMessage(SOURCE_ERROR, stripeEx);
mUiThreadHandler.sendMessage(message);
sendErrorIntent(contextWeakReference, stripeEx);
}
}
};
executeRunnable(fetchCustomerRunnable);
}
private boolean canUseCachedCustomer() {
long currentTime = getCalendarInstance().getTimeInMillis();
return mCustomer != null &&
currentTime - mCustomerCacheTime < CUSTOMER_CACHE_DURATION_MILLISECONDS;
}
private void setCustomerSourceDefault(
@NonNull final WeakReference<Context> contextWeakReference,
@NonNull final EphemeralKey key,
@NonNull final String sourceId,
@NonNull final String sourceType,
@NonNull final List<String> productUsageTokens) {
Runnable fetchCustomerRunnable = new Runnable() {
@Override
public void run() {
try {
Customer customer = setCustomerSourceDefaultWithKey(
contextWeakReference,
key,
new ArrayList<>(productUsageTokens),
sourceId,
sourceType,
mStripeApiProxy);
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_RETRIEVED, customer);
mUiThreadHandler.sendMessage(message);
} catch (StripeException stripeEx) {
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_ERROR, stripeEx);
mUiThreadHandler.sendMessage(message);
sendErrorIntent(contextWeakReference, stripeEx);
}
}
};
executeRunnable(fetchCustomerRunnable);
}
private void setCustomerShippingInformation(
@NonNull final WeakReference<Context> contextWeakReference,
@NonNull final EphemeralKey key,
@NonNull final ShippingInformation shippingInformation,
@NonNull final List<String> productUsageTokens) {
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Customer customer = setCustomerShippingInfoWithKey(
contextWeakReference,
key,
new ArrayList<>(productUsageTokens),
shippingInformation,
mStripeApiProxy);
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_SHIPPING_INFO_SAVED,
customer);
mUiThreadHandler.sendMessage(message);
} catch (StripeException stripeEx) {
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_ERROR, stripeEx);
mUiThreadHandler.sendMessage(message);
sendErrorIntent(contextWeakReference, stripeEx);
}
}
};
executeRunnable(runnable);
}
private void updateCustomer(@NonNull final EphemeralKey key) {
Runnable fetchCustomerRunnable = new Runnable() {
@Override
public void run() {
try {
Customer customer = retrieveCustomerWithKey(
mCachedContextReference,
key,
mStripeApiProxy);
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_RETRIEVED, customer);
mUiThreadHandler.sendMessage(message);
} catch (StripeException stripeEx) {
Message message = mUiThreadHandler.obtainMessage(CUSTOMER_ERROR, stripeEx);
mUiThreadHandler.sendMessage(message);
}
}
};
executeRunnable(fetchCustomerRunnable);
}
private void executeRunnable(@NonNull Runnable runnable) {
// In automation, run on the main thread.
if (mStripeApiProxy != null) {
runnable.run();
return;
}
mThreadPoolExecutor.execute(runnable);
}
@Override
public void onKeyUpdate(
@Nullable EphemeralKey ephemeralKey,
@Nullable String actionString,
@Nullable Map<String, Object> arguments) {
mEphemeralKey = ephemeralKey;
if (mEphemeralKey != null) {
if (actionString == null) {
updateCustomer(mEphemeralKey);
} else if (ACTION_ADD_SOURCE.equals(actionString)
&& mCachedContextReference != null
&& arguments != null
&& arguments.containsKey(KEY_SOURCE)
&& arguments.containsKey(KEY_SOURCE_TYPE)) {
addCustomerSource(
mCachedContextReference,
mEphemeralKey,
(String) arguments.get(KEY_SOURCE),
(String) arguments.get(KEY_SOURCE_TYPE),
new ArrayList<>(mProductUsageTokens));
resetUsageTokens();
} else if (ACTION_SET_DEFAULT_SOURCE.equals(actionString)
&& mCachedContextReference != null
&& arguments != null
&& arguments.containsKey(KEY_SOURCE)
&& arguments.containsKey(KEY_SOURCE_TYPE)) {
setCustomerSourceDefault(
mCachedContextReference,
mEphemeralKey,
(String) arguments.get(KEY_SOURCE),
(String) arguments.get(KEY_SOURCE_TYPE),
new ArrayList<>(mProductUsageTokens));
resetUsageTokens();
} else if (ACTION_SET_CUSTOMER_SHIPPING_INFO.equals(actionString)
&& mCachedContextReference != null
&& arguments != null
&& arguments.containsKey(KEY_SHIPPING_INFO)) {
setCustomerShippingInformation(
mCachedContextReference,
mEphemeralKey,
(ShippingInformation) arguments.get(KEY_SHIPPING_INFO),
new ArrayList<>(mProductUsageTokens));
resetUsageTokens();
}
}
}
@Override
public void onKeyError(int errorCode, @Nullable String errorMessage) {
// Any error eliminates all listeners
if (mCustomerRetrievalListener != null) {
mCustomerRetrievalListener.onError(errorCode, errorMessage);
mCustomerRetrievalListener = null;
}
if (mSourceRetrievalListener != null) {
mSourceRetrievalListener.onError(errorCode, errorMessage);
mSourceRetrievalListener = null;
}
}
@SuppressWarnings("checkstyle:MissingSwitchDefault")
private Handler createMainThreadHandler() {
return new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Object messageObject = msg.obj;
switch (msg.what) {
case CUSTOMER_RETRIEVED:
if (messageObject instanceof Customer) {
mCustomer = (Customer) messageObject;
mCustomerCacheTime = getCalendarInstance().getTimeInMillis();
if (mCustomerRetrievalListener != null) {
mCustomerRetrievalListener.onCustomerRetrieved(mCustomer);
// Eliminate reference to retrieval listener after use.
mCustomerRetrievalListener = null;
}
}
break;
case SOURCE_RETRIEVED:
if (messageObject instanceof Source && mSourceRetrievalListener != null) {
mSourceRetrievalListener.onSourceRetrieved((Source) messageObject);
}
// A source listener only listens once.
mSourceRetrievalListener = null;
// Clear our context reference so we don't use a stale one.
mCachedContextReference = null;
break;
case CUSTOMER_SHIPPING_INFO_SAVED:
if (messageObject instanceof Customer) {
mCustomer = (Customer) messageObject;
Intent intent = new Intent(EVENT_SHIPPING_INFO_SAVED);
LocalBroadcastManager.getInstance(mCachedContextReference.get())
.sendBroadcast(intent);
}
break;
case CUSTOMER_ERROR:
if (messageObject instanceof StripeException) {
StripeException exception = (StripeException) messageObject;
if (mCustomerRetrievalListener != null) {
int errorCode = exception.getStatusCode() == null
? 400
: exception.getStatusCode();
mCustomerRetrievalListener.onError(
errorCode,
exception.getLocalizedMessage());
mCustomerRetrievalListener = null;
}
resetUsageTokens();
}
break;
case SOURCE_ERROR:
StripeException exception = (StripeException) messageObject;
if (mSourceRetrievalListener != null) {
int errorCode = exception.getStatusCode() == null
? 400
: exception.getStatusCode();
mSourceRetrievalListener.onError(
errorCode,
exception.getLocalizedMessage());
mSourceRetrievalListener = null;
resetUsageTokens();
}
break;
}
}
};
}
private ThreadPoolExecutor createThreadPoolExecutor() {
return new ThreadPoolExecutor(
THREAD_POOL_SIZE,
THREAD_POOL_SIZE,
KEEP_ALIVE_TIME,
KEEP_ALIVE_TIME_UNIT,
mNetworkQueue);
}
@NonNull
private Calendar getCalendarInstance() {
return mProxyNowCalendar == null ? Calendar.getInstance() : mProxyNowCalendar;
}
static Source addCustomerSourceWithKey(
@NonNull WeakReference<Context> contextWeakReference,
@NonNull EphemeralKey key,
@NonNull List<String> productUsageTokens,
@NonNull String sourceId,
@NonNull @Source.SourceType String sourceType,
@Nullable StripeApiProxy proxy) throws StripeException {
if (proxy != null) {
return proxy.addCustomerSourceWithKey(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
sourceId,
sourceType,
key.getSecret());
} else {
return StripeApiHandler.addCustomerSource(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
sourceId,
sourceType,
key.getSecret(),
null);
}
}
static Customer setCustomerShippingInfoWithKey(
@NonNull WeakReference<Context> contextWeakReference,
@NonNull EphemeralKey key,
@NonNull List<String> productUsageTokens,
@NonNull ShippingInformation shippingInformation,
@Nullable StripeApiProxy proxy) throws StripeException {
if (proxy != null) {
return proxy.setCustomerShippingInfoWithKey(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
shippingInformation,
key.getSecret());
} else {
return StripeApiHandler.setCustomerShippingInfo(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
shippingInformation,
key.getSecret(),
null);
}
}
static Customer setCustomerSourceDefaultWithKey(
@NonNull WeakReference<Context> contextWeakReference,
@NonNull EphemeralKey key,
@NonNull List<String> productUsageTokens,
@NonNull String sourceId,
@NonNull @Source.SourceType String sourceType,
@Nullable StripeApiProxy proxy) throws StripeException {
if (proxy != null) {
return proxy.setDefaultCustomerSourceWithKey(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
sourceId,
sourceType,
key.getSecret());
} else {
return StripeApiHandler.setDefaultCustomerSource(
contextWeakReference.get(),
key.getCustomerId(),
PaymentConfiguration.getInstance().getPublishableKey(),
productUsageTokens,
sourceId,
sourceType,
key.getSecret(),
null);
}
}
/**
* Calls the Stripe API (or a test proxy) to fetch a customer. If the provided key is expired,
* this method <b>does not</b> update the key.
* Use {@link #updateCustomer(EphemeralKey)} to validate the key
* before refreshing the customer.
*
* @param errorContext a {@link WeakReference} to a {@link Context}
* that can be used for broadcasting errors.
* @param key the {@link EphemeralKey} used for this access
* @param proxy a {@link StripeApiProxy} to intercept calls to the real servers
* @return a {@link Customer} if one can be found with this key, or {@code null} if one cannot.
*/
@Nullable
static Customer retrieveCustomerWithKey(
@Nullable WeakReference<Context> errorContext,
@NonNull EphemeralKey key,
@Nullable StripeApiProxy proxy) throws StripeException {
if (proxy != null) {
return proxy.retrieveCustomerWithKey(key.getCustomerId(), key.getSecret());
} else {
return StripeApiHandler.retrieveCustomer(
key.getCustomerId(),
key.getSecret());
}
}
@NonNull
static void sendErrorIntent(@Nullable WeakReference<Context> errorContext,
@NonNull StripeException exception) {
if (errorContext == null || errorContext.get() == null) {
return;
}
Bundle bundle = new Bundle();
bundle.putSerializable(EXTRA_EXCEPTION, exception);
Intent intent = new Intent(ACTION_API_EXCEPTION);
intent.putExtras(bundle);
LocalBroadcastManager.getInstance(errorContext.get()).sendBroadcast(intent);
}
public interface CustomerRetrievalListener {
void onCustomerRetrieved(@NonNull Customer customer);
void onError(int errorCode, @Nullable String errorMessage);
}
public interface SourceRetrievalListener {
void onSourceRetrieved(@NonNull Source source);
void onError(int errorCode, @Nullable String errorMessage);
}
interface StripeApiProxy {
Customer retrieveCustomerWithKey(@NonNull String customerId, @NonNull String secret)
throws InvalidRequestException, APIConnectionException, APIException;
Source addCustomerSourceWithKey(
@Nullable Context context,
@NonNull String customerId,
@NonNull String publicKey,
@NonNull List<String> productUsageTokens,
@NonNull String sourceId,
@NonNull String sourceType,
@NonNull String secret)
throws InvalidRequestException, APIConnectionException, APIException;
Customer setDefaultCustomerSourceWithKey(
@Nullable Context context,
@NonNull String customerId,
@NonNull String publicKey,
@NonNull List<String> productUsageTokens,
@NonNull String sourceId,
@NonNull String sourceType,
@NonNull String secret)
throws InvalidRequestException, APIConnectionException, APIException;
Customer setCustomerShippingInfoWithKey(
@Nullable Context context,
@NonNull String customerId,
@NonNull String publicKey,
@NonNull List<String> productUsageTokens,
@NonNull ShippingInformation shippingInformation,
@NonNull String secret)
throws InvalidRequestException, APIConnectionException, APIException;
}
}