/*
* The MIT License (MIT)
*
* Copyright (c) 2015 NBCO Yandex.Money LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package ru.yandex.money.android;
import android.annotation.SuppressLint;
import android.app.ActionBar;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.Context;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.view.MenuItem;
import android.view.Window;
import com.yandex.money.api.methods.InstanceId;
import com.yandex.money.api.methods.payment.BaseProcessPayment;
import com.yandex.money.api.methods.payment.BaseRequestPayment;
import com.yandex.money.api.methods.payment.ProcessExternalPayment;
import com.yandex.money.api.methods.payment.RequestExternalPayment;
import com.yandex.money.api.methods.payment.params.PaymentParams;
import com.yandex.money.api.methods.payment.params.ShopParams;
import com.yandex.money.api.model.Error;
import com.yandex.money.api.model.ExternalCard;
import com.yandex.money.api.model.MoneySource;
import com.yandex.money.api.net.clients.ApiClient;
import com.yandex.money.api.net.clients.DefaultApiClient;
import com.yandex.money.api.net.providers.DefaultApiV1HostsProvider;
import com.yandex.money.api.processes.ExternalPaymentProcess;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import ru.yandex.money.android.database.DatabaseStorage;
import ru.yandex.money.android.fragments.CardsFragment;
import ru.yandex.money.android.fragments.CscFragment;
import ru.yandex.money.android.fragments.ErrorFragment;
import ru.yandex.money.android.fragments.SuccessFragment;
import ru.yandex.money.android.fragments.WebFragment;
import ru.yandex.money.android.parcelables.ExternalCardParcelable;
import ru.yandex.money.android.parcelables.ExternalPaymentProcessSavedStateParcelable;
import ru.yandex.money.android.utils.Keyboards;
/**
* <p>Main activity for a payment process. It guides a user through all payment steps and returns information whether
* payment was successful or not.</p>
*
* <p>To explicitly start this activity you may want to use {@link #getBuilder(Context)} method to create an
* {@link Intent} object, that can be passed to {@link #startActivity(Intent)} or
* {@link #startActivityForResult(Intent, int)} methods. See the description of allowed parameters in
* {@link PaymentParamsBuilder} and {@link Builder} interfaces.</p>
*
* <p>If the activity was started using {@link #startActivityForResult(Intent, int)} method, then it will return result
* of a payment to a calling activity. If payment was successful, then result code will be {@link #RESULT_OK} and
* {@link #EXTRA_INVOICE_ID} will be present in returned {@code data} object. If payment was canceled by a user or
* rejected by payment system, then result code {@link #RESULT_CANCELED} will be returned.</p>
*/
public final class PaymentActivity extends Activity implements ExternalPaymentProcess.ParameterProvider {
/**
* An instance of {@link String} representing instance id.
*/
public static final String EXTRA_INVOICE_ID = "ru.yandex.money.android.extra.INVOICE_ID";
private static final String EXTRA_ARGUMENTS = "ru.yandex.money.android.extra.ARGUMENTS";
private static final String EXTRA_HOST = "ru.yandex.money.android.extra.HOST";
private static final String EXTRA_CLIENT_ID = "ru.yandex.money.android.extra.CLIENT_ID";
private static final String KEY_PROCESS_SAVED_STATE = "processSavedState";
private static final String KEY_SELECTED_CARD = "selectedCard";
private static final String PRODUCTION_HOST = "https://money.yandex.ru";
private final ExecutorService backgroundService = Executors.newSingleThreadExecutor();
private ExternalPaymentProcess process;
private PaymentParams arguments;
private DatabaseStorage databaseStorage;
private List<ExternalCard> cards;
private boolean immediateProceed = true;
@Nullable
private ExternalCard selectedCard;
@Nullable
private AsyncTask<Void, Void, ?> task;
/**
* Returns intent builder used for launch this activity.
*
* @param context current context
* @return intent builder
*/
@NonNull
public static PaymentParamsBuilder getBuilder(@NonNull Context context) {
return new IntentBuilder(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); // todo show ongoing progress some other way
setContentView(R.layout.ym_payment_activity);
ActionBar actionBar = getActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
// we hide progress bar because on some devices we have it shown right from the start
hideProgressBar();
arguments = PaymentExtras.fromBundle(getIntent().getBundleExtra(EXTRA_ARGUMENTS));
databaseStorage = new DatabaseStorage(this);
cards = databaseStorage.selectExternalCards();
if (!initPaymentProcess()) return;
if (savedInstanceState == null) {
proceed();
} else {
ExternalPaymentProcessSavedStateParcelable savedStateParcelable =
savedInstanceState.getParcelable(KEY_PROCESS_SAVED_STATE);
if (savedStateParcelable != null) {
process.restoreSavedState(savedStateParcelable.value);
}
ExternalCardParcelable externalCardParcelable = savedInstanceState.getParcelable(KEY_SELECTED_CARD);
if (externalCardParcelable != null) {
selectedCard = externalCardParcelable.value;
}
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable(KEY_PROCESS_SAVED_STATE,
new ExternalPaymentProcessSavedStateParcelable(process.getSavedState()));
if (selectedCard != null) {
outState.putParcelable(KEY_SELECTED_CARD, new ExternalCardParcelable(selectedCard));
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
hideKeyboard();
onBackPressed();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public void onBackPressed() {
cancel();
applyResult();
Fragment fragment = getCurrentFragment();
super.onBackPressed();
Fragment currentFragment = getCurrentFragment();
if (currentFragment instanceof CscFragment) {
super.onBackPressed();
currentFragment = getCurrentFragment();
}
if (fragment instanceof WebFragment && currentFragment instanceof CardsFragment) {
if (cards.size() == 0) {
immediateProceed = false;
}
getFragmentManager()
.beginTransaction()
.remove(currentFragment)
.commit();
reset();
}
}
@Override
public String getPatternId() {
return arguments.patternId;
}
@Override
public Map<String, String> getPaymentParameters() {
return arguments.paymentParams;
}
@Override
public MoneySource getMoneySource() {
return selectedCard;
}
@Override
public String getCsc() {
Fragment fragment = getCurrentFragment();
return fragment instanceof CscFragment ? ((CscFragment) fragment).getCsc() : null;
}
@Override
public String getExtAuthSuccessUri() {
return Constants.EXT_AUTH_SUCCESS_URI;
}
@Override
public String getExtAuthFailUri() {
return Constants.EXT_AUTH_FAIL_URI;
}
@Override
public boolean isRequestToken() {
Fragment fragment = getCurrentFragment();
return fragment instanceof SuccessFragment;
}
/**
* Gets an instance of {@link DatabaseStorage}.
*
* @return instance of {@link DatabaseStorage}
*/
@NonNull
public DatabaseStorage getDatabaseStorage() {
return databaseStorage;
}
/**
* Gets list of saved cards.
*
* @return list of saved card
*/
@NonNull
public List<ExternalCard> getCards() {
return cards;
}
/**
* Shows {@link WebFragment} clearing back stack if needed.
*
* @param url url to open
* @param postData data to post
*/
public void showWeb(@NonNull String url, @NonNull Map<String, String> postData) {
Fragment fragment = getCurrentFragment();
boolean clearBackStack = !(fragment instanceof CardsFragment || fragment instanceof CscFragment);
replaceFragment(WebFragment.newInstance(url, postData), clearBackStack);
}
/**
* Shows {@link CardsFragment}.
*/
public void showCards() {
BaseRequestPayment rep = process.getRequestPayment();
replaceFragment(CardsFragment.newInstance(rep.title, rep.contractAmount), true);
}
/**
* Shows {@link ErrorFragment}.
*
* @param error known error
* @param status known status
*/
public void showError(@Nullable Error error, @Nullable String status) {
replaceFragment(ErrorFragment.newInstance(error, status), true);
}
/**
* Shows {@link ErrorFragment} for unknown error or status.
*/
public void showUnknownError() {
replaceFragment(ErrorFragment.newInstance(), true);
}
/**
* Shows {@link SuccessFragment}.
*
* @param moneySource saved card that was used to do a payment or {@code null}
*/
public void showSuccess(@Nullable ExternalCard moneySource) {
replaceFragment(SuccessFragment.newInstance(process.getRequestPayment().contractAmount, moneySource), true);
}
/**
* Shows {@link CscFragment}.
*
* @param externalCard selected card
*/
public void showCsc(@NonNull ExternalCard externalCard) {
selectedCard = externalCard;
replaceFragment(CscFragment.newInstance(externalCard), false);
}
/**
* Shows progress bar.
*/
public void showProgressBar() {
setProgressBarIndeterminateVisibility(true);
}
/**
* Hides progress bar.
*/
public void hideProgressBar() {
setProgressBarIndeterminateVisibility(false);
}
/**
* Proceeds to the next step of a payment.
*/
public void proceed() {
task = performPaymentOperation(process::proceed);
}
/**
* Repeats current step of a payment.
*/
public void repeat() {
task = performPaymentOperation(process::repeat);
}
/**
* Resets current payment process.
*/
public void reset() {
selectedCard = null;
process.reset();
proceed();
}
/**
* Cancels payment process.
*/
public void cancel() {
selectedCard = null;
if (task != null && task.getStatus() == AsyncTask.Status.RUNNING) {
task.cancel(true);
task = null;
}
}
@NonNull
private AsyncTask<Void, Void, OperationResult<Boolean>> performPaymentOperation(
@NonNull Callable<Boolean> operation) {
return perform(operation, aBoolean -> handleProcess());
}
@NonNull
private <T> AsyncTask<Void, Void, OperationResult<T>> perform(
@NonNull Callable<T> operation, @NonNull Consumer<T> consumer) {
showProgressBar();
return new AsyncTask<Void, Void, OperationResult<T>>() {
@Override
protected OperationResult<T> doInBackground(Void... params) {
try {
return new OperationResult<>(operation.call());
} catch (Exception e) {
return new OperationResult<>(e);
}
}
@Override
protected void onPostExecute(OperationResult<T> result) {
if (isCancelled()) return;
if (result.operation != null) {
consumer.consume(result.operation);
hideProgressBar();
} else {
onOperationFailed();
}
}
}.executeOnExecutor(backgroundService);
}
private void handleProcess() {
BaseProcessPayment processPayment = process.getProcessPayment();
if (processPayment != null) {
onExternalPaymentProcessed((ProcessExternalPayment) processPayment);
return;
}
BaseRequestPayment requestPayment = process.getRequestPayment();
if (requestPayment != null) {
onExternalPaymentReceived((RequestExternalPayment) requestPayment);
}
}
private boolean initPaymentProcess() {
final Intent intent = getIntent();
final String clientId = intent.getStringExtra(EXTRA_CLIENT_ID);
final String host = intent.getStringExtra(EXTRA_HOST);
final ApiClient client = new DefaultApiClient.Builder()
.setClientId(clientId)
.setHostsProvider(new DefaultApiV1HostsProvider(true) {
@Override
public String getMoney() {
return host;
}
})
.setDebugMode(!PRODUCTION_HOST.equals(host))
.create();
process = new ExternalPaymentProcess(client, this);
final Prefs prefs = new Prefs(this);
String instanceId = prefs.restoreInstanceId();
if (TextUtils.isEmpty(instanceId)) {
perform(() -> client.execute(new InstanceId.Request(clientId)), response -> {
if (response.isSuccessful()) {
prefs.storeInstanceId(response.instanceId);
process.setInstanceId(response.instanceId);
proceed();
} else {
showError(response.error, response.status.toString());
}
});
return false;
}
process.setInstanceId(instanceId);
return true;
}
private void onExternalPaymentReceived(@NonNull RequestExternalPayment rep) {
if (rep.status == BaseRequestPayment.Status.SUCCESS) {
if (immediateProceed && cards.size() == 0) {
proceed();
} else {
showCards();
}
} else {
showError(rep.error, rep.status.toString());
}
}
private void onExternalPaymentProcessed(@NonNull ProcessExternalPayment pep) {
switch (pep.status) {
case SUCCESS:
Fragment fragment = getCurrentFragment();
if (!(fragment instanceof SuccessFragment)) {
showSuccess((ExternalCard) getMoneySource());
} else if (pep.externalCard != null) {
((SuccessFragment) fragment).saveCard(pep.externalCard);
}
break;
case EXT_AUTH_REQUIRED:
showWeb(pep.acsUri, pep.acsParams);
break;
default:
showError(pep.error, pep.status.toString());
}
}
void onOperationFailed() {
showUnknownError();
hideProgressBar();
}
private void replaceFragment(@Nullable Fragment fragment, boolean clearBackStack) {
if (fragment == null) {
return;
}
Fragment currentFragment = getCurrentFragment();
FragmentManager manager = getFragmentManager();
if (clearBackStack) {
manager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);
}
@SuppressLint("CommitTransaction")
FragmentTransaction transaction = manager.beginTransaction()
.replace(R.id.ym_container, fragment);
if (!clearBackStack && currentFragment != null) {
transaction.addToBackStack(fragment.getTag());
}
transaction.commit();
hideKeyboard();
}
@Nullable
private Fragment getCurrentFragment() {
return getFragmentManager().findFragmentById(R.id.ym_container);
}
private void hideKeyboard() {
Keyboards.hideKeyboard(this);
}
private void applyResult() {
BaseProcessPayment pp = process.getProcessPayment();
if (pp != null && pp.status == BaseProcessPayment.Status.SUCCESS) {
Intent intent = new Intent();
intent.putExtra(EXTRA_INVOICE_ID, pp.invoiceId);
setResult(RESULT_OK, intent);
} else {
setResult(RESULT_CANCELED);
}
}
/**
* Implementations of this interface sets payment parameters.
*/
@SuppressWarnings("WeakerAccess")
public interface PaymentParamsBuilder extends Builder {
/**
* Sets raw payment parameters.
*
* @param patternId pattern id
* @param paymentParams payment parameters
* @return {@link Builder}
*/
@NonNull
Builder setPaymentParams(@NonNull String patternId, @NonNull Map<String, String> paymentParams);
/**
* Sets payment parameters.
*
* @param paymentParams instance of {@link PaymentParams}
* @return {@link Builder}
*/
@NonNull
Builder setPaymentParams(@Nullable PaymentParams paymentParams);
}
/**
* Implementation of this interface add additional info
*/
public interface Builder {
/**
* Sets client id.
*
* @param clientId client id
* @return itself
*/
@NonNull
Builder setClientId(@Nullable String clientId);
/**
* Sets desired host for testing purposes.
*
* @param host host to use
* @return itself
*/
@NonNull
Builder setHost(@Nullable String host);
/**
* Creates an intent that can be used to start payment process.
*
* @return {@link Intent} object
*/
@NonNull
Intent build();
}
private final static class IntentBuilder implements PaymentParamsBuilder {
@NonNull
private final Context context;
private PaymentParams params;
private String host = PRODUCTION_HOST;
private String clientId;
IntentBuilder(@NonNull Context context) {
this.context = context;
}
@NonNull
@Override
public Builder setPaymentParams(@NonNull String patternId,
@NonNull Map<String, String> paymentParams) {
this.params = new ShopParams(patternId, paymentParams);
return this;
}
@NonNull
@Override
public Builder setPaymentParams(@Nullable PaymentParams paymentParams) {
this.params = paymentParams;
return this;
}
@NonNull
@Override
public Builder setClientId(@Nullable String clientId) {
this.clientId = clientId;
return this;
}
@NonNull
@Override
public Builder setHost(@Nullable String host) {
this.host = host;
return this;
}
@NonNull
@Override
public Intent build() {
return createIntent()
.putExtra(EXTRA_ARGUMENTS, PaymentExtras.toBundle(params))
.putExtra(EXTRA_HOST, host)
.putExtra(EXTRA_CLIENT_ID, clientId);
}
@NonNull
private Intent createIntent() {
return new Intent(context, PaymentActivity.class);
}
}
private interface Consumer<T> {
void consume(T value);
}
private static final class OperationResult<T> {
@Nullable
final T operation;
@Nullable
final Exception exception;
OperationResult(@Nullable T operation) {
this.operation = operation;
this.exception = null;
}
OperationResult(@Nullable Exception exception) {
this.operation = null;
this.exception = exception;
}
}
}