package com.mygeopay.wallet.ui;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Handler;
import android.os.Message;
import android.support.v4.app.Fragment;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import com.mygeopay.core.coins.CoinType;
import com.mygeopay.core.coins.Value;
import com.mygeopay.core.exchange.shapeshift.ShapeShift;
import com.mygeopay.core.exchange.shapeshift.data.ShapeShiftAmountTx;
import com.mygeopay.core.exchange.shapeshift.data.ShapeShiftMarketInfo;
import com.mygeopay.core.exchange.shapeshift.data.ShapeShiftNormalTx;
import com.mygeopay.core.exchange.shapeshift.data.ShapeShiftTime;
import com.mygeopay.core.util.ExchangeRate;
import com.mygeopay.core.util.GenericUtils;
import com.mygeopay.core.wallet.SendRequest;
import com.mygeopay.core.wallet.Wallet;
import com.mygeopay.core.wallet.WalletPocketHD;
import com.mygeopay.core.wallet.exceptions.NoSuchPocketException;
import com.mygeopay.wallet.Configuration;
import com.mygeopay.wallet.ExchangeHistoryProvider;
import com.mygeopay.wallet.ExchangeRatesProvider;
import com.mygeopay.wallet.ExchangeHistoryProvider.ExchangeEntry;
import com.mygeopay.wallet.R;
import com.mygeopay.wallet.WalletApplication;
import com.mygeopay.wallet.ui.widget.SendOutput;
import com.mygeopay.wallet.ui.widget.TransactionAmountVisualizer;
import com.mygeopay.wallet.util.Keyboard;
import com.mygeopay.wallet.util.WeakHandler;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.InsufficientMoneyException;
import org.bitcoinj.crypto.KeyCrypter;
import org.bitcoinj.crypto.KeyCrypterException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import javax.annotation.Nullable;
import static com.mygeopay.core.Preconditions.checkNotNull;
import static com.mygeopay.wallet.Constants.ARG_ACCOUNT_ID;
import static com.mygeopay.wallet.Constants.ARG_EMPTY_WALLET;
import static com.mygeopay.wallet.Constants.ARG_SEND_TO_ACCOUNT_ID;
import static com.mygeopay.wallet.Constants.ARG_SEND_TO_ADDRESS;
import static com.mygeopay.wallet.Constants.ARG_SEND_VALUE;
import static com.mygeopay.wallet.ExchangeRatesProvider.getRates;
/**
* This fragment displays a busy message and makes the transaction in the background
*
*/
public class MakeTransactionFragment extends Fragment {
private static final Logger log = LoggerFactory.getLogger(MakeTransactionFragment.class);
private static final int START_TRADE_TIMEOUT = 0;
private static final int UPDATE_TRADE_TIMEOUT = 1;
private static final int TRADE_EXPIRED = 2;
private static final int STOP_TRADE_TIMEOUT = 3;
private static final int SAFE_TIMEOUT_MARGIN_SEC = 60;
// Loader IDs
private static final int ID_RATE_LOADER = 0;
private static final String TRANSACTION_BROADCAST = "transaction_broadcast";
private static final String ERROR = "error";
private static final String EXCHANGE_ENTRY = "exchange_entry";
private static final String DEPOSIT_ADDRESS = "deposit_address";
private static final String DEPOSIT_AMOUNT = "deposit_amount";
private static final String WITHDRAW_ADDRESS = "withdraw_address";
private static final String WITHDRAW_AMOUNT = "withdraw_amount";
private Handler handler = new MyHandler(this);
@Nullable private String password;
private Listener mListener;
private ContentResolver contentResolver;
private SignAndBroadcastTask signAndBroadcastTask;
private CreateTransactionTask createTransactionTask;
private WalletApplication application;
private Configuration config;
private TextView transactionInfo;
private EditText passwordView;
private TransactionAmountVisualizer txVisualizer;
private SendOutput tradeWithdrawSendOutput;
private Address sendToAddress;
private boolean sendingToAccount;
@Nullable private Value sendAmount;
private boolean emptyWallet;
private CoinType sourceType;
private SendRequest request;
private LoaderManager loaderManager;
private WalletPocketHD sourceAccount;
@Nullable private ExchangeEntry exchangeEntry;
@Nullable private Address tradeDepositAddress;
@Nullable private Value tradeDepositAmount;
@Nullable private Address tradeWithdrawAddress;
@Nullable private Value tradeWithdrawAmount;
private boolean transactionBroadcast = false;
@Nullable private Exception error;
private HashMap<String, ExchangeRate> localRates = new HashMap<>();
private CountDownTimer countDownTimer;
public static MakeTransactionFragment newInstance(Bundle args) {
MakeTransactionFragment fragment = new MakeTransactionFragment();
fragment.setArguments(args);
return fragment;
}
public MakeTransactionFragment() {
// Required empty public constructor
}
@Override
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
signAndBroadcastTask = null;
Bundle args = getArguments();
checkNotNull(args, "Must provide arguments");
try {
String fromAccountId = args.getString(ARG_ACCOUNT_ID);
sourceAccount = (WalletPocketHD) checkNotNull(application.getAccount(fromAccountId));
application.maybeConnectAccount(sourceAccount);
sourceType = sourceAccount.getCoinType();
emptyWallet = args.getBoolean(ARG_EMPTY_WALLET, false);
sendAmount = (Value) args.getSerializable(ARG_SEND_VALUE);
if (emptyWallet && sendAmount != null) {
throw new IllegalArgumentException(
"Cannot set 'empty wallet' and 'send amount' at the same time");
}
if (args.containsKey(ARG_SEND_TO_ACCOUNT_ID)) {
String toAccountId = args.getString(ARG_SEND_TO_ACCOUNT_ID);
WalletPocketHD toAccount = (WalletPocketHD) checkNotNull(application.getAccount(toAccountId));
sendToAddress = toAccount.getReceiveAddress(config.isManualAddressManagement());
sendingToAccount = true;
} else {
sendToAddress = (Address) checkNotNull(args.getSerializable(ARG_SEND_TO_ADDRESS));
sendingToAccount = false;
}
if (savedState != null) {
error = (Exception) savedState.getSerializable(ERROR);
transactionBroadcast = savedState.getBoolean(TRANSACTION_BROADCAST);
exchangeEntry = (ExchangeEntry) savedState.getSerializable(EXCHANGE_ENTRY);
tradeDepositAddress = (Address) savedState.getSerializable(DEPOSIT_ADDRESS);
tradeDepositAmount = (Value) savedState.getSerializable(DEPOSIT_AMOUNT);
tradeWithdrawAddress = (Address) savedState.getSerializable(WITHDRAW_ADDRESS);
tradeWithdrawAmount = (Value) savedState.getSerializable(WITHDRAW_AMOUNT);
}
maybeStartCreateTransaction();
} catch (Exception e) {
error = e;
if (mListener != null) {
mListener.onSignResult(e, null);
}
}
String localSymbol = config.getExchangeCurrencyCode();
for (ExchangeRatesProvider.ExchangeRate rate : getRates(getActivity(), localSymbol)) {
localRates.put(rate.currencyCodeId, rate.rate);
}
loaderManager.initLoader(ID_RATE_LOADER, null, rateLoaderCallbacks);
}
@Override
public void onDestroy() {
loaderManager.destroyLoader(ID_RATE_LOADER);
super.onDestroy();
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_make_transaction, container, false);
if (error != null) return view;
transactionInfo = (TextView) view.findViewById(R.id.transaction_info);
transactionInfo.setVisibility(View.GONE);
passwordView = (EditText) view.findViewById(R.id.password);
final TextView passwordLabelView = (TextView) view.findViewById(R.id.enter_password_label);
if (sourceAccount.isEncrypted()) {
passwordView.requestFocus();
passwordView.setVisibility(View.VISIBLE);
passwordLabelView.setVisibility(View.VISIBLE);
} else {
passwordView.setVisibility(View.GONE);
passwordLabelView.setVisibility(View.GONE);
}
txVisualizer = (TransactionAmountVisualizer) view.findViewById(R.id.transaction_amount_visualizer);
tradeWithdrawSendOutput = (SendOutput) view.findViewById(R.id.transaction_trade_withdraw);
tradeWithdrawSendOutput.setVisibility(View.GONE);
showTransaction();
view.findViewById(R.id.button_confirm).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (passwordView.isShown()) {
Keyboard.hideKeyboard(getActivity());
password = passwordView.getText().toString();
}
maybeStartSignAndBroadcast();
}
});
TextView poweredByShapeShift = (TextView) view.findViewById(R.id.powered_by_shapeshift);
poweredByShapeShift.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new AlertDialog.Builder(getActivity())
.setTitle(R.string.about_shapeshift_title)
.setMessage(R.string.about_shapeshift_message)
.setPositiveButton(R.string.button_ok, null)
.create().show();
}
});
poweredByShapeShift.setVisibility(isExchangeNeeded() ? View.VISIBLE : View.GONE);
return view;
}
private void showTransaction() {
if (request != null && txVisualizer != null) {
txVisualizer.setTransaction(sourceAccount, request.tx);
if (tradeWithdrawAmount != null && tradeWithdrawAddress != null) {
tradeWithdrawSendOutput.setVisibility(View.VISIBLE);
if (sendingToAccount) {
tradeWithdrawSendOutput.setSending(false);
} else {
tradeWithdrawSendOutput.setSending(true);
tradeWithdrawSendOutput.setLabelAndAddress(tradeWithdrawAddress);
}
tradeWithdrawSendOutput.setAmount(GenericUtils.formatValue(tradeWithdrawAmount));
tradeWithdrawSendOutput.setSymbol(tradeWithdrawAmount.type.getSymbol());
txVisualizer.getOutputs().get(0).setSendLabel(getString(R.string.trade));
txVisualizer.hideAddresses(); // Hide exchange address
}
updateLocalRates();
}
}
boolean isExchangeNeeded() {
return !sourceAccount.getCoinType().equals(sendToAddress.getParameters());
}
private void maybeStartCreateTransaction() {
if (createTransactionTask == null && !transactionBroadcast && error == null) {
createTransactionTask = new CreateTransactionTask();
createTransactionTask.execute();
}
}
private SendRequest generateSendRequest(Address sendTo,
boolean emptyWallet, @Nullable Value amount)
throws InsufficientMoneyException {
SendRequest sendRequest;
if (emptyWallet) {
sendRequest = SendRequest.emptyWallet(sendTo);
} else {
sendRequest = SendRequest.to(sendTo, checkNotNull(amount).toCoin());
}
sendRequest.signInputs = false;
sourceAccount.completeTx(sendRequest);
return sendRequest;
}
private boolean isSendingFromSourceAccount() {
return isEmptyWallet() || (sendAmount != null && sourceType.equals(sendAmount.type));
}
private boolean isEmptyWallet() {
return emptyWallet && sendAmount == null;
}
private void maybeStartSignAndBroadcast() {
if (signAndBroadcastTask == null && !transactionBroadcast && request != null && error == null) {
signAndBroadcastTask = new SignAndBroadcastTask();
signAndBroadcastTask.execute();
} else if (transactionBroadcast) {
Toast.makeText(getActivity(), R.string.tx_already_broadcast, Toast.LENGTH_SHORT).show();
if (mListener != null) {
mListener.onSignResult(error, exchangeEntry);
}
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
outState.putBoolean(TRANSACTION_BROADCAST, transactionBroadcast);
outState.putSerializable(ERROR, error);
if (isExchangeNeeded()) {
outState.putSerializable(EXCHANGE_ENTRY, exchangeEntry);
outState.putSerializable(DEPOSIT_ADDRESS, tradeDepositAddress);
outState.putSerializable(DEPOSIT_AMOUNT, tradeDepositAmount);
outState.putSerializable(WITHDRAW_ADDRESS, tradeWithdrawAddress);
outState.putSerializable(WITHDRAW_AMOUNT, tradeWithdrawAmount);
}
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
try {
mListener = (Listener) activity;
contentResolver = activity.getContentResolver();
application = (WalletApplication) activity.getApplication();
config = application.getConfiguration();
loaderManager = getLoaderManager();
} catch (ClassCastException e) {
throw new ClassCastException(activity.toString()
+ " must implement " + MakeTransactionFragment.Listener.class);
}
}
@Override
public void onDetach() {
super.onDetach();
mListener = null;
onStopTradeCountDown();
}
void onStartTradeCountDown(int secondsLeft) {
if (countDownTimer != null) return;
countDownTimer = new CountDownTimer(secondsLeft * 1000, 1000) {
public void onTick(long millisUntilFinished) {
handler.sendMessage(handler.obtainMessage(
UPDATE_TRADE_TIMEOUT, (int) (millisUntilFinished / 1000)));
}
public void onFinish() {
handler.sendEmptyMessage(TRADE_EXPIRED);
}
};
countDownTimer.start();
}
void onStopTradeCountDown() {
if (countDownTimer != null) {
countDownTimer.cancel();
countDownTimer = null;
handler.removeMessages(START_TRADE_TIMEOUT);
handler.removeMessages(UPDATE_TRADE_TIMEOUT);
handler.removeMessages(TRADE_EXPIRED);
}
}
private void onTradeExpired() {
if (transactionBroadcast) { // Transaction already sent, so the trade is not expired
return;
}
if (transactionInfo.getVisibility() != View.VISIBLE) {
transactionInfo.setVisibility(View.VISIBLE);
}
String errorString = getString(R.string.trade_expired);
transactionInfo.setText(errorString);
if (mListener != null) {
error = new Exception(errorString);
mListener.onSignResult(error, null);
}
}
private void onUpdateTradeCountDown(int secondsRemaining) {
if (transactionInfo.getVisibility() != View.VISIBLE) {
transactionInfo.setVisibility(View.VISIBLE);
}
int minutes = secondsRemaining / 60;
int seconds = secondsRemaining % 60;
Resources res = getResources();
String timeLeft;
if (minutes > 0) {
timeLeft = res.getQuantityString(R.plurals.tx_confirm_timer_minute,
minutes, String.format("%d:%02d", minutes, seconds));
} else {
timeLeft = res.getQuantityString(R.plurals.tx_confirm_timer_second,
seconds, seconds);
}
String message = getString(R.string.tx_confirm_timer_message, timeLeft);
transactionInfo.setText(message);
}
/**
* Makes a call to ShapeShift about the time left for the trade
*
* Note: do not call this from the main thread!
*/
@Nullable
private static ShapeShiftTime getTimeLeftSync(ShapeShift shapeShift, Address address) {
// Try 3 times
for (int tries = 1; tries <= 3; tries++) {
try {
log.info("Getting time left for: {}", address);
return shapeShift.getTime(address);
} catch (Exception e) {
log.info("Will retry: {}", e.getMessage());
/* ignore and retry, with linear backoff */
try {
Thread.sleep(1000 * tries);
} catch (InterruptedException ie) { /*ignored*/ }
}
}
return null;
}
private void updateLocalRates() {
if (localRates != null) {
if (txVisualizer != null && localRates.containsKey(sourceType.getSymbol())) {
txVisualizer.setExchangeRate(localRates.get(sourceType.getSymbol()));
}
if (tradeWithdrawAmount != null && localRates.containsKey(tradeWithdrawAmount.type.getSymbol())) {
ExchangeRate rate = localRates.get(tradeWithdrawAmount.type.getSymbol());
Value fiatAmount = rate.convert(tradeWithdrawAmount);
tradeWithdrawSendOutput.setAmountLocal(GenericUtils.formatFiatValue(fiatAmount));
tradeWithdrawSendOutput.setSymbolLocal(fiatAmount.type.getSymbol());
}
}
}
private void updateLocalRates(HashMap<String, ExchangeRate> rates) {
localRates = rates;
updateLocalRates();
}
public interface Listener {
void onSignResult(@Nullable Exception error, @Nullable ExchangeEntry exchange);
}
private final LoaderManager.LoaderCallbacks<Cursor> rateLoaderCallbacks = new LoaderManager.LoaderCallbacks<Cursor>() {
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
String localSymbol = config.getExchangeCurrencyCode();
return new ExchangeRateLoader(getActivity(), config, localSymbol);
}
@Override
public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) {
if (data != null && data.getCount() > 0) {
HashMap<String, ExchangeRate> rates = new HashMap<>(data.getCount());
data.moveToFirst();
do {
ExchangeRatesProvider.ExchangeRate rate = ExchangeRatesProvider.getExchangeRate(data);
rates.put(rate.currencyCodeId, rate.rate);
} while (data.moveToNext());
updateLocalRates(rates);
}
}
@Override
public void onLoaderReset(final Loader<Cursor> loader) {
}
};
/**
* The fragment handler
*/
private static class MyHandler extends WeakHandler<MakeTransactionFragment> {
public MyHandler(MakeTransactionFragment referencingObject) { super(referencingObject); }
@Override
protected void weakHandleMessage(MakeTransactionFragment ref, Message msg) {
switch (msg.what) {
case START_TRADE_TIMEOUT:
ref.onStartTradeCountDown((int) msg.obj);
break;
case UPDATE_TRADE_TIMEOUT:
ref.onUpdateTradeCountDown((int) msg.obj);
break;
case TRADE_EXPIRED:
ref.onTradeExpired();
break;
case STOP_TRADE_TIMEOUT:
ref.onStopTradeCountDown();
break;
}
}
}
private class CreateTransactionTask extends AsyncTask<Void, Void, Void> {
private Dialogs.ProgressDialogFragment busyDialog;
@Override
protected void onPreExecute() {
// Show dialog as we need to make network connections
if (isExchangeNeeded()) {
busyDialog = Dialogs.ProgressDialogFragment.newInstance(
getString(R.string.contacting_exchange));
busyDialog.show(getFragmentManager(), null);
}
}
@Override
protected Void doInBackground(Void... params) {
try {
if (isExchangeNeeded()) {
ShapeShift shapeShift = application.getShapeShift();
Address refundAddress =
sourceAccount.getRefundAddress(config.isManualAddressManagement());
// If emptying wallet or the amount is the same type as the source account
if (isSendingFromSourceAccount()) {
ShapeShiftMarketInfo marketInfo = shapeShift.getMarketInfo(
sourceType, (CoinType) sendToAddress.getParameters());
// If no values set, make the call
if (tradeDepositAddress == null || tradeDepositAmount == null ||
tradeWithdrawAddress == null || tradeWithdrawAmount == null) {
ShapeShiftNormalTx normalTx =
shapeShift.exchange(sendToAddress, refundAddress);
// TODO, show a retry message
if (normalTx.isError) throw new Exception(normalTx.errorMessage);
tradeDepositAddress = normalTx.deposit;
tradeDepositAmount = sendAmount;
tradeWithdrawAddress = sendToAddress;
// set tradeWithdrawAmount after we generate the send tx
}
request = generateSendRequest(tradeDepositAddress, isEmptyWallet(), tradeDepositAmount);
// The amountSending could be equal to sendAmount or the actual amount if
// emptying the wallet
Value amountSending = Value.valueOf(sourceType, request.tx
.getValue(sourceAccount).negate() .subtract(request.tx.getFee()));
tradeWithdrawAmount = marketInfo.rate.convert(amountSending);
} else {
// If no values set, make the call
if (tradeDepositAddress == null || tradeDepositAmount == null ||
tradeWithdrawAddress == null || tradeWithdrawAmount == null) {
ShapeShiftAmountTx fixedAmountTx =
shapeShift.exchangeForAmount(sendAmount, sendToAddress, refundAddress);
// TODO, show a retry message
if (fixedAmountTx.isError) throw new Exception(fixedAmountTx.errorMessage);
tradeDepositAddress = fixedAmountTx.deposit;
tradeDepositAmount = fixedAmountTx.depositAmount;
tradeWithdrawAddress = fixedAmountTx.withdrawal;
tradeWithdrawAmount = fixedAmountTx.withdrawalAmount;
}
ShapeShiftTime time = getTimeLeftSync(shapeShift, tradeDepositAddress);
if (time != null && !time.isError) {
int secondsLeft = time.secondsRemaining - SAFE_TIMEOUT_MARGIN_SEC;
handler.sendMessage(handler.obtainMessage(
START_TRADE_TIMEOUT, secondsLeft));
} else {
throw new Exception(time == null ? "Error getting trade expiration time" : time.errorMessage);
}
request = generateSendRequest(tradeDepositAddress, false, tradeDepositAmount);
}
} else {
request = generateSendRequest(sendToAddress, isEmptyWallet(), sendAmount);
}
} catch (Exception e) {
error = e;
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
if (busyDialog != null) busyDialog.dismissAllowingStateLoss();
if (error != null && mListener != null) {
mListener.onSignResult(error, null);
} else if (error == null) {
showTransaction();
} else {
log.warn("Error occurred while creating transaction", error);
}
}
}
private class SignAndBroadcastTask extends AsyncTask<Void, Void, Exception> {
private Dialogs.ProgressDialogFragment busyDialog;
@Override
protected void onPreExecute() {
super.onPreExecute();
busyDialog = Dialogs.ProgressDialogFragment.newInstance(
getResources().getString(R.string.preparing_transaction));
busyDialog.show(getFragmentManager(), null);
}
@Override
protected Exception doInBackground(Void... params) {
Wallet wallet = application.getWallet();
if (wallet == null) return new NoSuchPocketException("No wallet found.");
try {
if (wallet.isEncrypted()) {
KeyCrypter crypter = checkNotNull(wallet.getKeyCrypter());
request.aesKey = crypter.deriveKey(password);
}
request.signInputs = true;
sourceAccount.completeAndSignTx(request);
// Before broadcasting, check if there is an error, like the trade expiration
if (error != null) throw error;
if (!sourceAccount.broadcastTxSync(request.tx)) {
throw new Exception("Error broadcasting transaction: " + request.tx.getHashAsString());
}
transactionBroadcast = true;
if (isExchangeNeeded() && tradeDepositAddress != null && tradeDepositAmount != null) {
exchangeEntry = new ExchangeEntry(tradeDepositAddress,
tradeDepositAmount, request.tx.getHashAsString());
Uri uri = ExchangeHistoryProvider.contentUri(application.getPackageName(),
tradeDepositAddress);
contentResolver.insert(uri, exchangeEntry.getContentValues());
}
handler.sendEmptyMessage(STOP_TRADE_TIMEOUT);
}
catch (Exception e) { error = e; }
return error;
}
protected void onPostExecute(final Exception e) {
busyDialog.dismissAllowingStateLoss();
if (e instanceof KeyCrypterException) {
DialogBuilder.warn(getActivity(), R.string.unlocking_wallet_error_title)
.setMessage(R.string.unlocking_wallet_error_detail)
.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
mListener.onSignResult(e, exchangeEntry);
}
})
.setPositiveButton(R.string.button_retry, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
password = null;
passwordView.setText(null);
signAndBroadcastTask = null;
error = null;
}
})
.create().show();
} else if (mListener != null) {
mListener.onSignResult(e, exchangeEntry);
}
}
}
}