diff --git a/plugin.xml b/plugin.xml index 4b88506..2dbc595 100644 --- a/plugin.xml +++ b/plugin.xml @@ -7,9 +7,10 @@ Stripe Payments Cordova plugin for Stripe payments using the native Android/iOS SDKs. Supports Apple Pay and card payments. - https://github.com/rolamix/cordova-plugin-stripe-payments + Rolamix, Inc. MIT - cordova,stripe,payments,apple pay,credit cards,checkout + cordova,stripe,payments,apple pay,ach,google pay,cards,credit cards,checkout + https://github.com/rolamix/cordova-plugin-stripe-payments https://github.com/rolamix/cordova-plugin-stripe-payments/issues @@ -30,6 +31,10 @@ + + + + diff --git a/src/android/StripePaymentsPlugin.gradle b/src/android/StripePaymentsPlugin.gradle new file mode 100644 index 0000000..fd4c44b --- /dev/null +++ b/src/android/StripePaymentsPlugin.gradle @@ -0,0 +1,27 @@ +dependencies { + implementation 'com.stripe:stripe-android:8.5.0' + compile 'com.google.android.gms:play-services-wallet:16.0.1' + + /* Cordova doesn't support AndroidX support libraries yet */ + compile 'com.android.support:support-v4:28.0.0' + compile 'com.android.support:appcompat-v7:28.0.0' + + /* Needed for RxAndroid */ + implementation 'io.reactivex:rxandroid:1.2.1' + implementation 'io.reactivex:rxjava:1.3.0' + + /* Needed for Rx Bindings on views */ + implementation 'com.jakewharton.rxbinding:rxbinding:0.4.0' + + /* Used for server calls */ + implementation 'com.squareup.okio:okio:1.15.0' + implementation 'com.squareup.retrofit2:retrofit:2.5.0' + + /* Used to make Retrofit easier and GSON & Rx-compatible*/ + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.squareup.retrofit2:adapter-rxjava:2.3.0' + implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + + /* Used to debug your Retrofit connections */ + implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0' +} \ No newline at end of file diff --git a/src/android/StripePaymentsPlugin.java b/src/android/StripePaymentsPlugin.java new file mode 100644 index 0000000..1e65dc3 --- /dev/null +++ b/src/android/StripePaymentsPlugin.java @@ -0,0 +1,251 @@ +package com.rolamix.plugins.stripe; + +import java.util.ArrayList; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginResult; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; +import android.os.Build; +import com.google.gson.reflect.TypeToken; + +import com.google.android.gms.common.api.ApiException; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.wallet.AutoResolveHelper; +import com.google.android.gms.wallet.CardRequirements; +import com.google.android.gms.wallet.IsReadyToPayRequest; +import com.google.android.gms.wallet.PaymentData; +import com.google.android.gms.wallet.PaymentDataRequest; +import com.google.android.gms.wallet.PaymentMethodTokenizationParameters; +import com.google.android.gms.wallet.PaymentsClient; +import com.google.android.gms.wallet.TransactionInfo; +import com.google.android.gms.wallet.Wallet; +import com.google.android.gms.wallet.WalletConstants; +import com.stripe.android.CardUtils; +import com.stripe.android.SourceCallback; +import com.stripe.android.Stripe; +import com.stripe.android.TokenCallback; +import com.stripe.android.model.AccountParams; +import com.stripe.android.model.BankAccount; +import com.stripe.android.model.Card; +import com.stripe.android.model.Source; +import com.stripe.android.model.SourceParams; +import com.stripe.android.model.Token; +import com.stripe.android.view.CardInputWidget; + +// https://stripe.com/docs/mobile/android +// https://github.com/stripe/stripe-android +// https://github.com/zyra/cordova-plugin-stripe/blob/v2/src/android/CordovaStripe.java +// https://github.com/stripe/stripe-connect-rocketrides/blob/master/server/routes/api/rides.js + +public class StripePaymentsPlugin extends CordovaPlugin { + + private CallbackContext callbackContext; + + private String publishableKey; + + private static final int LOAD_PAYMENT_DATA_REQUEST_CODE = 9972; + + public static final String ACTION_SET_KEY = "setKey"; + + public static final String ACTION_SET_NAME = "setName"; + + public static final String ACTION_PICK = "pick"; + + public static final String ACTION_PICK_AND_STORE = "pickAndStore"; + + public static final String ACTION_HAS_PERMISSION = "hasPermission"; + + private static final String LOG_TAG = "FileStackPlugin"; + + public StripePaymentsPlugin() {} + + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + super.initialize(cordova, webView); + stripeInstance = new Stripe(webView.getContext()); + } + + /** + * Executes the request and returns PluginResult. + * + * @param action The action to execute. + * @param args JSONArray of arguments for the plugin. + * @param callbackContext The callback context used when calling back into JavaScript. + * @return True if the action was valid, false otherwise. + */ + public boolean execute(final String action, final JSONArray args, final CallbackContext callbackContext) throws JSONException { + this.callbackContext = callbackContext; + this.executeArgs = args; + this.action = action; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || action.equals(ACTION_HAS_PERMISSION)) { + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.OK, hasPermission())); + return true; + } + else { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || action.equals(ACTION_SET_KEY) || action.equals(ACTION_SET_NAME)) { + execute(); + return true; + } + else { + if (hasPermission()) { + execute(); + } else { + requestPermission(); + } + return true; + } + } + } + + private boolean hasPermission() { + return cordova.hasPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + private void requestPermission() { + cordova.requestPermission(this, 0, android.Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException { + for (int r : grantResults) { + if (r == PackageManager.PERMISSION_DENIED) { + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.ERROR, "User has denied permission")); + return; + } + } + execute(); + } + + public void execute() { + final FileStackPlugin cdvPlugin = this; + this.cordova.getThreadPool().execute(() -> { + try { + if (ACTION_SET_KEY.equals(cdvPlugin.getAction())) { + this.apiKey = cdvPlugin.getArgs().getString(0); + return; + } + + Context context = cordova.getActivity().getApplicationContext(); + Intent intent = new Intent(context, FsActivity.class); + Config config = new Config(this.apiKey); + intent.putExtra(FsConstants.EXTRA_CONFIG, config); + intent.putExtra(FsConstants.EXTRA_AUTO_UPLOAD, true); + if (ACTION_PICK.equals(cdvPlugin.getAction()) || ACTION_PICK_AND_STORE.equals(cdvPlugin.getAction())) { + parseGlobalArgs(intent, cdvPlugin.getArgs()); + if (ACTION_PICK_AND_STORE.equals(cdvPlugin.getAction())) { + parseStoreArgs(intent, cdvPlugin.getArgs()); + } + cordova.startActivityForResult(cdvPlugin, intent, REQUEST_FILESTACK); + } + } + catch(JSONException exception) { + cdvPlugin.getCallbackContext().error("cannot parse json"); + } + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_FILESTACK) { + if (resultCode == Activity.RESULT_OK) { + ArrayList selections = data.getParcelableArrayListExtra(FsConstants.EXTRA_SELECTION_LIST); + try{ + callbackContext.success(toJSON(selections)); + } + catch(JSONException exception) { + callbackContext.error("json exception"); + } + } else { + callbackContext.error("nok"); + } + } + else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + public void parseGlobalArgs(Intent intent, JSONArray args) throws JSONException { + if (!args.isNull(0)) { + intent.putExtra("mimetype", parseJSONStringArray(args.getJSONArray(0))); + } + if (!args.isNull(1)) { + intent.putExtra("services", parseJSONStringArray(args.getJSONArray(1))); + } + if (!args.isNull(2)) { + intent.putExtra("multiple", args.getBoolean(2)); + } + if (!args.isNull(3)) { + intent.putExtra("maxFiles", args.getInt(3)); + } + if (!args.isNull(4)) { + intent.putExtra("maxSize", args.getInt(4)); + } + } + + public void parseStoreArgs(Intent intent, JSONArray args) throws JSONException { + if (!args.isNull(5)) { + intent.putExtra("location", args.getString(5)); + } + if (!args.isNull(6)) { + intent.putExtra("path", args.getString(6)); + } + if (!args.isNull(7)) { + intent.putExtra("container", args.getString(7)); + } + if (!args.isNull(8)) { + intent.putExtra("access", args.getString(8)); + } + } + + public String[] parseJSONStringArray(JSONArray jSONArray) throws JSONException { + String[] a = new String[jSONArray.length()]; + for(int i = 0; i < jSONArray.length(); i++){ + a[i] = jSONArray.getString(i); + } + return a; + } + + public JSONArray toJSON(ArrayList selections) throws JSONException { + JSONArray res = new JSONArray(); + for (Selection selection : selections) { + JSONObject f = new JSONObject(); + f.put("provider", selection.getProvider()); + f.put("url", selection.getUri()); + f.put("filename", selection.getName()); + f.put("mimetype", selection.getMimeType()); + f.put("localPath", selection.getPath()); + f.put("size", selection.getSize()); + + res.put(f); + } + return res; + } + + public String getAction() { + return this.action; + } + + public JSONArray getArgs() { + return this.executeArgs; + } + + public CallbackContext getCallbackContext() { + return this.callbackContext; + } +} diff --git a/src/android/service/EphemeralKeyProvider.java b/src/android/service/EphemeralKeyProvider.java new file mode 100644 index 0000000..fef93bc --- /dev/null +++ b/src/android/service/EphemeralKeyProvider.java @@ -0,0 +1,70 @@ +package com.stripe.example.service; + +import android.support.annotation.NonNull; +import android.support.annotation.Size; + +import com.stripe.android.EphemeralKeyProvider; +import com.stripe.android.EphemeralKeyUpdateListener; +import com.stripe.example.module.RetrofitFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import okhttp3.ResponseBody; +import retrofit2.Retrofit; +import rx.android.schedulers.AndroidSchedulers; +import rx.functions.Action1; +import rx.schedulers.Schedulers; +import rx.subscriptions.CompositeSubscription; + +/** + * An implementation of {@link EphemeralKeyProvider} that can be used to generate + * ephemeral keys on the backend. + */ +public class ExampleEphemeralKeyProvider implements EphemeralKeyProvider { + + private @NonNull CompositeSubscription mCompositeSubscription; + private @NonNull StripeService mStripeService; + private @NonNull ProgressListener mProgressListener; + + public ExampleEphemeralKeyProvider(@NonNull ProgressListener progressListener) { + Retrofit retrofit = RetrofitFactory.getInstance(); + mStripeService = retrofit.create(StripeService.class); + mCompositeSubscription = new CompositeSubscription(); + mProgressListener = progressListener; + } + + @Override + public void createEphemeralKey(@NonNull @Size(min = 4) String apiVersion, + @NonNull final EphemeralKeyUpdateListener keyUpdateListener) { + Map apiParamMap = new HashMap<>(); + apiParamMap.put("api_version", apiVersion); + + mCompositeSubscription.add( + mStripeService.createEphemeralKey(apiParamMap) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Action1() { + @Override + public void call(ResponseBody response) { + try { + String rawKey = response.string(); + keyUpdateListener.onKeyUpdate(rawKey); + mProgressListener.onStringResponse(rawKey); + } catch (IOException iox) { + + } + } + }, new Action1() { + @Override + public void call(Throwable throwable) { + mProgressListener.onStringResponse(throwable.getMessage()); + } + })); + } + + public interface ProgressListener { + void onStringResponse(String string); + } +} \ No newline at end of file diff --git a/src/android/service/TokenIntentService.java b/src/android/service/TokenIntentService.java new file mode 100644 index 0000000..83ceb97 --- /dev/null +++ b/src/android/service/TokenIntentService.java @@ -0,0 +1,83 @@ +package com.stripe.example.service; + +import android.app.Activity; +import android.app.IntentService; +import android.content.Intent; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; + +import com.stripe.android.PaymentConfiguration; +import com.stripe.android.Stripe; +import com.stripe.android.exception.StripeException; +import com.stripe.android.model.Card; +import com.stripe.android.model.Token; + +/** + * An {@link IntentService} subclass for handling the creation of a {@link Token} from + * input {@link Card} information. + */ +public class TokenIntentService extends IntentService { + + public static final String TOKEN_ACTION = "com.stripe.example.service.tokenAction"; + public static final String STRIPE_CARD_LAST_FOUR = "com.stripe.example.service.cardLastFour"; + public static final String STRIPE_CARD_TOKEN_ID = "com.stripe.example.service.cardTokenId"; + public static final String STRIPE_ERROR_MESSAGE = "com.stripe.example.service.errorMessage"; + + private static final String EXTRA_CARD_NUMBER = "com.stripe.example.service.extra.cardNumber"; + private static final String EXTRA_MONTH = "com.stripe.example.service.extra.month"; + private static final String EXTRA_YEAR = "com.stripe.example.service.extra.year"; + private static final String EXTRA_CVC = "com.stripe.example.service.extra.cvc"; + + public static Intent createTokenIntent( + @NonNull Activity launchingActivity, + @Nullable String cardNumber, + @Nullable Integer month, + @Nullable Integer year, + @Nullable String cvc) { + return new Intent(launchingActivity, TokenIntentService.class) + .putExtra(EXTRA_CARD_NUMBER, cardNumber) + .putExtra(EXTRA_MONTH, month) + .putExtra(EXTRA_YEAR, year) + .putExtra(EXTRA_CVC, cvc); + } + + public TokenIntentService() { + super("TokenIntentService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + String errorMessage = null; + Token token = null; + if (intent != null) { + final String cardNumber = intent.getStringExtra(EXTRA_CARD_NUMBER); + final Integer month = (Integer) intent.getExtras().get(EXTRA_MONTH); + final Integer year = (Integer) intent.getExtras().get(EXTRA_YEAR); + final String cvc = intent.getStringExtra(EXTRA_CVC); + + final Card card = new Card(cardNumber, month, year, cvc); + + final Stripe stripe = new Stripe(getApplicationContext()); + try { + token = stripe.createTokenSynchronous(card, + PaymentConfiguration.getInstance().getPublishableKey()); + } catch (StripeException stripeEx) { + errorMessage = stripeEx.getLocalizedMessage(); + } + } + + final Intent localIntent = new Intent(TOKEN_ACTION); + if (token != null) { + localIntent.putExtra(STRIPE_CARD_LAST_FOUR, token.getCard().getLast4()); + localIntent.putExtra(STRIPE_CARD_TOKEN_ID, token.getId()); + } + + if (errorMessage != null) { + localIntent.putExtra(STRIPE_ERROR_MESSAGE, errorMessage); + } + + // Broadcasts the Intent to receivers in this app. + LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent); + } +} diff --git a/src/android/xml/activity_layout.xml b/src/android/xml/activity_layout.xml new file mode 100644 index 0000000..d3b2450 --- /dev/null +++ b/src/android/xml/activity_layout.xml @@ -0,0 +1,5 @@ + \ No newline at end of file