package com.stripe.wrap.pay.activity;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentSender;
import android.os.Bundle;
import android.support.annotation.CallSuper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.v4.app.DialogFragment;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.util.Log;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.common.api.BooleanResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.wallet.Cart;
import com.google.android.gms.wallet.FullWallet;
import com.google.android.gms.wallet.FullWalletRequest;
import com.google.android.gms.wallet.IsReadyToPayRequest;
import com.google.android.gms.wallet.MaskedWallet;
import com.google.android.gms.wallet.MaskedWalletRequest;
import com.google.android.gms.wallet.Wallet;
import com.google.android.gms.wallet.WalletConstants;
import com.google.android.gms.wallet.fragment.SupportWalletFragment;
import com.google.android.gms.wallet.fragment.WalletFragmentInitParams;
import com.google.android.gms.wallet.fragment.WalletFragmentMode;
import com.google.android.gms.wallet.fragment.WalletFragmentOptions;
import com.google.android.gms.wallet.fragment.WalletFragmentStyle;
import com.stripe.android.model.Source;
import com.stripe.android.model.StripePaymentSource;
import com.stripe.android.model.Token;
import com.stripe.android.net.RequestOptions;
import com.stripe.android.net.StripeApiHandler;
import com.stripe.android.net.TokenParser;
import com.stripe.android.util.LoggingUtils;
import com.stripe.wrap.pay.AndroidPayConfiguration;
import com.stripe.wrap.pay.utils.PaymentUtils;
import org.json.JSONException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* A class that handles the Google API callbacks for the purchase flow and {@link GoogleApiClient}
* connection states, simplifying the required work to display and complete an Android Pay purchase.
*/
public abstract class StripeAndroidPayActivity extends AppCompatActivity
implements GoogleApiClient.OnConnectionFailedListener {
public static final String TAG = StripeAndroidPayActivity.class.getName();
public static final String EXTRA_ACCOUNT_NAME = "extra_account_name";
public static final String EXTRA_CART = "extra_cart";
public static final String EXTRA_MASKED_WALLET = "extra_masked_wallet";
// Request code to use when requesting the Masked Wallet.
public static final int REQUEST_CODE_MASKED_WALLET = 2002;
// Request code to use when allowing the user to change before confirming the Masked Wallet.
public static final int REQUEST_CODE_CHANGE_MASKED_WALLET = 3003;
// Request code to use when requesting the Full Wallet.
public static final int REQUEST_CODE_LOAD_FULL_WALLET = 4004;
// Request code to use when launching the resolution activity
private static final int REQUEST_RESOLVE_ERROR = 1001;
// Unique tag for the error dialog fragment
private static final String DIALOG_ERROR = "dialog_error";
private static final String STATE_RESOLVING_ERROR = "resolving_error";
// Bool to track whether the app is already resolving an error
private boolean mResolvingError = false;
@NonNull private Executor mExecutor = Executors.newFixedThreadPool(3);
private String mAccountName;
private Cart mCart;
private GoogleApiClient mGoogleApiClient;
private MaskedWallet mMaskedWallet;
private SupportWalletFragment mBuyButtonFragment;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mResolvingError = savedInstanceState != null
&& savedInstanceState.getBoolean(STATE_RESOLVING_ERROR, false);
if (getIntent().hasExtra(EXTRA_CART)) {
mCart = getIntent().getParcelableExtra(EXTRA_CART);
}
if (getIntent().hasExtra(EXTRA_ACCOUNT_NAME)) {
mAccountName = getIntent().getStringExtra(EXTRA_ACCOUNT_NAME);
}
if (getIntent().hasExtra(EXTRA_MASKED_WALLET)) {
mMaskedWallet = getIntent().getParcelableExtra(EXTRA_MASKED_WALLET);
}
mGoogleApiClient = buildGoogleApiClient();
onBeforeAndroidPayAvailable();
verifyAndPrepareAndroidPayControls(mGoogleApiClient,
PaymentUtils.getStripeIsReadyToPayRequest());
}
@Override
protected void onStart() {
super.onStart();
mGoogleApiClient.connect();
}
@Override
protected void onStop() {
super.onStop();
mGoogleApiClient.disconnect();
}
@CallSuper
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
// retrieve the error code, if available
int errorCode = -1;
if (data != null) {
errorCode = data.getIntExtra(WalletConstants.EXTRA_ERROR_CODE, -1);
}
switch (requestCode) {
case REQUEST_RESOLVE_ERROR:
mResolvingError = false;
if (resultCode != RESULT_OK) {
return;
}
// Make sure the app is not already connected or attempting to connect
if (!mGoogleApiClient.isConnecting() && !mGoogleApiClient.isConnected()) {
mGoogleApiClient.connect();
}
break;
case REQUEST_CODE_MASKED_WALLET:
switch (resultCode) {
case RESULT_OK:
if (data != null) {
MaskedWallet maskedWallet =
data.getParcelableExtra(WalletConstants.EXTRA_MASKED_WALLET);
if (maskedWallet != null) {
onMaskedWalletRetrieved(maskedWallet);
if (mBuyButtonFragment != null) {
mBuyButtonFragment.updateMaskedWallet(maskedWallet);
}
}
}
break;
case RESULT_CANCELED:
break;
default:
handleError(errorCode);
break;
}
break;
case REQUEST_CODE_CHANGE_MASKED_WALLET:
switch (resultCode) {
case RESULT_OK:
if (data != null) {
MaskedWallet maskedWallet =
data.getParcelableExtra(WalletConstants.EXTRA_MASKED_WALLET);
if (maskedWallet != null) {
onChangedMaskedWalletRetrieved(maskedWallet);
if (mBuyButtonFragment != null) {
mBuyButtonFragment.updateMaskedWallet(maskedWallet);
}
}
}
break;
case RESULT_CANCELED:
break;
default:
handleError(errorCode);
break;
}
break;
case REQUEST_CODE_LOAD_FULL_WALLET:
switch (resultCode) {
case RESULT_OK:
if (data != null && data.hasExtra(WalletConstants.EXTRA_FULL_WALLET)) {
FullWallet fullWallet =
data.getParcelableExtra(WalletConstants.EXTRA_FULL_WALLET);
// the full wallet can now be used to process the customer's payment
// send the wallet info up to server to process, and to get the result
// for sending a transaction status
onFullWalletRetrieved(fullWallet);
}
break;
case RESULT_CANCELED:
break;
default:
handleError(errorCode);
break;
}
case WalletConstants.RESULT_ERROR:
handleError(errorCode);
break;
default:
super.onActivityResult(requestCode, resultCode, data);
break;
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(STATE_RESOLVING_ERROR, mResolvingError);
}
protected void verifyAndPrepareAndroidPayControls(
@NonNull GoogleApiClient googleApiClient,
@NonNull IsReadyToPayRequest isReadyToPayRequest) {
Wallet.Payments.isReadyToPay(googleApiClient, isReadyToPayRequest)
.setResultCallback(
new ResultCallback<BooleanResult>() {
@Override
public void onResult(@NonNull BooleanResult booleanResult) {
onAfterAndroidPayCheckComplete();
if (booleanResult.getStatus().isSuccess()
&& booleanResult.getValue()) {
createAndAddBuyButtonWalletFragment();
} else {
onAndroidPayNotAvailable();
}
}
});
}
/**
* Call to make a final payment request to Google Play Services.
*
* @param fullWalletRequest
*/
protected void loadFullWallet(@NonNull FullWalletRequest fullWalletRequest) {
Wallet.Payments.loadFullWallet(
mGoogleApiClient,
fullWalletRequest,
REQUEST_CODE_LOAD_FULL_WALLET);
}
/**
* Builds the {@link GoogleApiClient} used in this Activity. Override
* if you'd like to change the default GoogleApiClient.
*
* @return a {@link GoogleApiClient} used to interact with the Wallet API
*/
@NonNull
protected GoogleApiClient buildGoogleApiClient() {
return new GoogleApiClient.Builder(this)
.addApi(Wallet.API, new Wallet.WalletOptions.Builder()
.setEnvironment(getWalletEnvironment())
.setTheme(getWalletTheme())
.build())
.addOnConnectionFailedListener(this)
.enableAutoManage(this, this)
.build();
}
/**
* Creates the {@link WalletFragmentStyle} for the buy button for this Activity.
* Override to change the appearance of the button. The results of this method
* are used to build the {@link WalletFragmentOptions}.
*
* @return a {@link WalletFragmentStyle} used to display Android Pay options to the user
*/
@NonNull
protected WalletFragmentStyle getWalletFragmentButtonStyle() {
return new WalletFragmentStyle()
.setBuyButtonText(WalletFragmentStyle.BuyButtonText.BUY_WITH)
.setBuyButtonAppearance(WalletFragmentStyle.BuyButtonAppearance.ANDROID_PAY_DARK)
.setBuyButtonWidth(WalletFragmentStyle.Dimension.MATCH_PARENT);
}
/**
* Creates the {@link WalletFragmentStyle} for the confirmation fragment. Override to change
* the appearance of the selection details screen. The results of this method
* are used to build the {@link WalletFragmentOptions}.
*
* @return a {@link WalletFragmentStyle} used to display Android Pay options to the user
*/
@NonNull
protected WalletFragmentStyle getWalletFragmentConfirmationStyle() {
return new WalletFragmentStyle()
.setMaskedWalletDetailsLogoImageType(WalletFragmentStyle.LogoImageType.ANDROID_PAY);
}
@NonNull
protected WalletFragmentOptions getWalletFragmentOptions(int walletFragmentMode) {
if (walletFragmentMode != WalletFragmentMode.BUY_BUTTON
&& walletFragmentMode != WalletFragmentMode.SELECTION_DETAILS) {
throw new IllegalArgumentException(
String.format(Locale.ENGLISH,
"Using unknown WalletFragmentMode (%d) to create WalletFragment",
walletFragmentMode));
}
WalletFragmentStyle style;
if (walletFragmentMode == WalletFragmentMode.BUY_BUTTON) {
style = getWalletFragmentButtonStyle();
} else {
style = getWalletFragmentConfirmationStyle();
}
return WalletFragmentOptions.newBuilder()
.setEnvironment(getWalletEnvironment())
.setFragmentStyle(style)
.setTheme(getWalletTheme())
.setMode(walletFragmentMode)
.build();
}
/**
* Creates the confirmation wallet fragment and calls
* {@link #addConfirmationWalletFragment(SupportWalletFragment)}. Override this method to
* launch a new activity as a confirmation screen, or to otherwise avoid creating a
* confirmation fragment. This method is never automatically invoked, so it can be avoided
* without an override if desired.
*
* @param maskedWallet a {@link MaskedWallet} whose details the user needs to confirm
*/
protected void createAndAddConfirmationWalletFragment(@NonNull MaskedWallet maskedWallet) {
SupportWalletFragment supportWalletFragment = SupportWalletFragment.newInstance(
getWalletFragmentOptions(WalletFragmentMode.SELECTION_DETAILS));
WalletFragmentInitParams.Builder startParamsBuilder =
WalletFragmentInitParams.newBuilder()
.setMaskedWallet(maskedWallet)
.setMaskedWalletRequestCode(REQUEST_CODE_CHANGE_MASKED_WALLET);
if (!TextUtils.isEmpty(mAccountName)) {
startParamsBuilder.setAccountName(mAccountName);
}
supportWalletFragment.initialize(startParamsBuilder.build());
addConfirmationWalletFragment(supportWalletFragment);
}
/**
* Creates the Buy Button WalletFragment and calls
* {@link #addBuyButtonWalletFragment(SupportWalletFragment)}. Override this method to
* avoid displaying or instantiating a Buy Button at all.
*/
protected void createAndAddBuyButtonWalletFragment() {
SupportWalletFragment supportWalletFragment = SupportWalletFragment.newInstance(
getWalletFragmentOptions(WalletFragmentMode.BUY_BUTTON));
if (mCart == null) {
// A masked wallet request must have a cart.
return;
}
MaskedWalletRequest maskedWalletRequest =
AndroidPayConfiguration.getInstance().generateMaskedWalletRequest(mCart);
WalletFragmentInitParams.Builder startParamsBuilder =
WalletFragmentInitParams.newBuilder()
.setMaskedWalletRequest(maskedWalletRequest)
.setMaskedWalletRequestCode(REQUEST_CODE_MASKED_WALLET);
if (!TextUtils.isEmpty(mAccountName)) {
startParamsBuilder.setAccountName(mAccountName);
}
supportWalletFragment.initialize(startParamsBuilder.build());
mBuyButtonFragment = supportWalletFragment;
addBuyButtonWalletFragment(mBuyButtonFragment);
}
/**
* Handles receipt of a {@link FullWallet} from Google Play Services. This wallet includes
* the {@link StripePaymentSource} (usually a {@link Token}). If that payment source is not
* {@code null}, it calls through to
* {@link #onStripePaymentSourceReturned(FullWallet, StripePaymentSource)}.
*
* @param fullWallet the {@link FullWallet} returned from Google Play Services
*/
@CallSuper
protected void onFullWalletRetrieved(@Nullable FullWallet fullWallet) {
if (fullWallet == null || fullWallet.getPaymentMethodToken() == null) {
return;
}
String rawPurchaseToken = fullWallet.getPaymentMethodToken().getToken();
if (rawPurchaseToken == null) {
Log.w(TAG, "Null token returned with non-null full wallet");
}
try {
Token token = TokenParser.parseToken(rawPurchaseToken);
logApiCallOnNewThread(token, null);
onStripePaymentSourceReturned(fullWallet, token);
} catch (JSONException jsonException) {
Log.i(TAG,
String.format(Locale.ENGLISH,
"Could not parse object as Stripe token. Trying as Source.\n%s",
rawPurchaseToken),
jsonException);
Source source = Source.fromString(rawPurchaseToken);
if (source == null) {
Log.w(TAG,
String.format(Locale.ENGLISH,
"Could not parse object as Stripe Source\n%s",
rawPurchaseToken),
jsonException);
return;
}
logApiCallOnNewThread(source, null);
onStripePaymentSourceReturned(fullWallet, source);
}
}
/**
* Update the Buy Button fragment's wallet. Call this method if the user changes payment
* methods or updates other details, like shipping address.
*
* @param maskedWallet the updated {@link MaskedWallet}
*/
protected void updateBuyButtonFragmentWallet(@NonNull MaskedWallet maskedWallet) {
if (mBuyButtonFragment != null) {
mBuyButtonFragment.updateMaskedWallet(maskedWallet);
}
}
@Nullable
protected SupportWalletFragment getBuyButtonFragment() {
return mBuyButtonFragment;
}
@Nullable
protected GoogleApiClient getGoogleApiClient() {
return mGoogleApiClient;
}
@Nullable
protected Cart getCart() {
return mCart;
}
protected void setCart(@NonNull Cart cart) {
mCart = cart;
}
@Nullable
protected MaskedWallet getMaskedWallet() {
return mMaskedWallet;
}
/*------ Begin GoogleApiClient.OnConnectionFailedListener ------*/
/**
* Handles the error conditions for connection issues with the {@link GoogleApiClient}.
* Deliberately mimics the behavior of enableAutoManage.
*
* @param connectionResult a {@link ConnectionResult} failure in the {@link GoogleApiClient}
*/
@Override
public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {
if (mResolvingError) {
// Already attempting to resolve an error.
return;
} else if (connectionResult.hasResolution()) {
try {
mResolvingError = true;
connectionResult.startResolutionForResult(this, REQUEST_RESOLVE_ERROR);
} catch (IntentSender.SendIntentException e) {
// There was an error with the resolution intent. Try again.
mGoogleApiClient.connect();
}
} else {
// Show dialog using GoogleApiAvailability.getErrorDialog()
showErrorDialog(connectionResult.getErrorCode());
mResolvingError = true;
}
}
/*------ End GoogleApiClient.OnConnectionFailedListener ------*/
/* Creates a dialog for an error message */
private void showErrorDialog(int errorCode) {
// Create a fragment for the error dialog
ErrorDialogFragment dialogFragment = new ErrorDialogFragment();
// Pass the error that should be displayed
Bundle args = new Bundle();
args.putInt(DIALOG_ERROR, errorCode);
dialogFragment.setArguments(args);
dialogFragment.show(getSupportFragmentManager(), "errordialog");
}
/* Called from ErrorDialogFragment when the dialog is dismissed. */
public void onDialogDismissed() {
mResolvingError = false;
}
/*------ Required Overrides ------*/
protected abstract void onAndroidPayAvailable();
protected abstract void onAndroidPayNotAvailable();
/*------ Optional Overrides ------*/
protected void onBeforeAndroidPayAvailable() {
// This is a good place to display a spinner if you anticipate delays
// initializing the Google API client.
}
protected void onAfterAndroidPayCheckComplete() {
// If a spinner was showing, remove it in this method.
}
/**
* Override to handle Google errors in custom ways.
*
* @param errorCode the error code returned from the {@link GoogleApiClient}
*/
protected void handleError(int errorCode) { }
/**
* Override this method to display the Android Pay confirmation wallet fragment. Place
* it in a container of your choice using a {@link android.support.v4.app.FragmentTransaction}.
*
* @param walletFragment a {@link SupportWalletFragment} created using the
*/
protected void addConfirmationWalletFragment(@NonNull SupportWalletFragment walletFragment) { }
/**
* Override this method to display the Android Pay fragment (a clickable button). Place
* it in a container of your choice using a {@link android.support.v4.app.FragmentTransaction}.
*
* @param walletFragment a {@link SupportWalletFragment} created using the
*/
protected void addBuyButtonWalletFragment(@NonNull SupportWalletFragment walletFragment) { }
/**
* Override this method to react to a {@link MaskedWallet} being returned from the Google API
* when the user is asked to confirm wallet choices.
*
* @param maskedWallet the {@link MaskedWallet} returned from the {@link GoogleApiClient}
*/
@CallSuper
protected void onChangedMaskedWalletRetrieved(@Nullable MaskedWallet maskedWallet) {
if (maskedWallet == null) {
// A null value for the changed masked wallet means that nothing changed, not that
// there is no wallet.
return;
}
mMaskedWallet = maskedWallet;
}
/**
* Override this method to react to a {@link MaskedWallet} being returned from
* the Google API. The masked wallet will have the user's shipping information (if
* it was required), which you can use to update the final shipping price. You can also
* use the card information as a confirmation for the user.
*
* @param maskedWallet the {@link MaskedWallet} returned from the {@link GoogleApiClient}
*/
@CallSuper
protected void onMaskedWalletRetrieved(@Nullable MaskedWallet maskedWallet) {
mMaskedWallet = maskedWallet;
}
/**
* Override this function to move to {@link WalletConstants#ENVIRONMENT_PRODUCTION}
*
* @return the current wallet environment
*/
protected int getWalletEnvironment() {
return WalletConstants.ENVIRONMENT_TEST;
}
/**
* Override this function to change the theme of Wallet display items
*
* @return the current wallet theme
*/
protected int getWalletTheme() {
return WalletConstants.THEME_LIGHT;
}
/**
* Called when a {@link StripePaymentSource} is returned from Google's servers.
* Send the ID of this payment source to your server to make a charge. This payment source will
* either be a {@link Token} or {@link Source}, but both use the ID field to create payments.
*
* @param wallet the final {@link FullWallet} object
* @param paymentSource a {@link StripePaymentSource} that has an ID field that can be used
* to make a charge
*/
protected void onStripePaymentSourceReturned(
FullWallet wallet, StripePaymentSource paymentSource) { }
/*------ End Overrides ------*/
@VisibleForTesting
void setExecutor(@NonNull Executor executor) {
mExecutor = executor;
}
@VisibleForTesting
void logApiCallOnNewThread(@NonNull StripePaymentSource paymentSource,
@Nullable final StripeApiHandler.LoggingResponseListener listener) {
@LoggingUtils.LoggingEventName String eventName =
paymentSource instanceof Token
? LoggingUtils.EVENT_TOKEN_CREATION
: LoggingUtils.EVENT_SOURCE_CREATION;
List<String> loggingTokens = Arrays.asList(LoggingUtils.ANDROID_PAY_TOKEN);
String publishableKey = AndroidPayConfiguration.getInstance().getPublicApiKey();
final Map<String, Object> loggingParams = LoggingUtils.getEventLoggingParams(
loggingTokens,
null,
publishableKey,
eventName);
final RequestOptions options = RequestOptions.builder(publishableKey).build();
Runnable runnable = new Runnable() {
@Override
public void run() {
StripeApiHandler.logApiCall(
loggingParams,
options,
listener);
}
};
mExecutor.execute(runnable);
}
/* A fragment to display an error dialog */
public static class ErrorDialogFragment extends DialogFragment {
public ErrorDialogFragment() { }
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
// Get the error code and retrieve the appropriate dialog
int errorCode = this.getArguments().getInt(DIALOG_ERROR);
return GoogleApiAvailability.getInstance().getErrorDialog(
this.getActivity(), errorCode, REQUEST_RESOLVE_ERROR);
}
@Override
public void onDismiss(DialogInterface dialog) {
((StripeAndroidPayActivity) getActivity()).onDialogDismissed();
}
}
}