diff --git a/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java index 22169f4f..8f11e9ad 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java +++ b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java @@ -50,9 +50,7 @@ import com.m2049r.xmrwallet.widget.Toolbar; import java.text.NumberFormat; import java.util.ArrayList; -import java.util.Date; import java.util.List; -import java.util.stream.Collectors; import timber.log.Timber; @@ -220,7 +218,7 @@ public class WalletFragment extends Fragment // at this point selection is XMR in case of error String displayB; double amountA = Helper.getDecimalAmount(unlockedBalance).doubleValue(); - if (!Helper.CRYPTO.equals(balanceCurrency)) { // not XMR + if (!Helper.BASE_CRYPTO.equals(balanceCurrency)) { // not XMR double amountB = amountA * balanceRate; displayB = Helper.getFormattedAmount(amountB, false); } else { // XMR @@ -229,7 +227,7 @@ public class WalletFragment extends Fragment showBalance(displayB); } - String balanceCurrency = Helper.CRYPTO; + String balanceCurrency = Helper.BASE_CRYPTO; double balanceRate = 1.0; private final ExchangeApi exchangeApi = Helper.getExchangeApi(); @@ -245,7 +243,7 @@ public class WalletFragment extends Fragment Timber.d(currency); if (!currency.equals(balanceCurrency) || (balanceRate <= 0)) { showExchanging(); - exchangeApi.queryExchangeRate(Helper.CRYPTO, currency, + exchangeApi.queryExchangeRate(Helper.BASE_CRYPTO, currency, new ExchangeCallback() { @Override public void onSuccess(final ExchangeRate exchangeRate) { @@ -301,10 +299,10 @@ public class WalletFragment extends Fragment public void exchange(final ExchangeRate exchangeRate) { hideExchanging(); - if (!Helper.CRYPTO.equals(exchangeRate.getBaseCurrency())) { + if (!Helper.BASE_CRYPTO.equals(exchangeRate.getBaseCurrency())) { Timber.e("Not XMR"); sCurrency.setSelection(0, true); - balanceCurrency = Helper.CRYPTO; + balanceCurrency = Helper.BASE_CRYPTO; balanceRate = 1.0; } else { int spinnerPosition = ((ArrayAdapter) sCurrency.getAdapter()).getPosition(exchangeRate.getQuoteCurrency()); diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java index 5c32174e..5cc56da8 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java +++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendAmountWizardFragment.java @@ -116,12 +116,12 @@ public class SendAmountWizardFragment extends SendWizardFragment { sendListener.getTxData().setAmount(Wallet.SWEEP_ALL); } } else { - if (!etAmount.validate(maxFunds)) { + if (!etAmount.validate(maxFunds, 0)) { return false; } if (sendListener != null) { - String xmr = etAmount.getAmount(); + String xmr = etAmount.getNativeAmount(); if (xmr != null) { sendListener.getTxData().setAmount(Wallet.getAmountFromString(xmr)); } else { @@ -148,8 +148,8 @@ public class SendAmountWizardFragment extends SendWizardFragment { tvFunds.setText(getString(R.string.send_available, getString(R.string.unknown_amount))); } - // getAmount is null if exchange is in progress - if ((etAmount.getAmount() != null) && etAmount.getAmount().isEmpty()) { + // getNativeAmount is null if exchange is in progress + if ((etAmount.getNativeAmount() != null) && etAmount.getNativeAmount().isEmpty()) { final BarcodeData data = sendListener.popBarcodeData(); if ((data != null) && (data.amount != null)) { etAmount.setAmount(data.amount); diff --git a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java index 45a814f1..8a6055d2 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java +++ b/app/src/main/java/com/m2049r/xmrwallet/fragment/send/SendBtcAmountWizardFragment.java @@ -31,7 +31,8 @@ import com.m2049r.xmrwallet.data.TxDataBtc; import com.m2049r.xmrwallet.model.Wallet; import com.m2049r.xmrwallet.util.Helper; import com.m2049r.xmrwallet.util.OkHttpHelper; -import com.m2049r.xmrwallet.widget.ExchangeBtcEditText; +import com.m2049r.xmrwallet.widget.ExchangeEditText; +import com.m2049r.xmrwallet.widget.ExchangeOtherEditText; import com.m2049r.xmrwallet.widget.SendProgressView; import com.m2049r.xmrwallet.xmrto.XmrToError; import com.m2049r.xmrwallet.xmrto.XmrToException; @@ -61,7 +62,7 @@ public class SendBtcAmountWizardFragment extends SendWizardFragment { } private TextView tvFunds; - private ExchangeBtcEditText etAmount; + private ExchangeOtherEditText etAmount; private TextView tvXmrToParms; private SendProgressView evParams; @@ -97,7 +98,7 @@ public class SendBtcAmountWizardFragment extends SendWizardFragment { } if (sendListener != null) { TxDataBtc txDataBtc = (TxDataBtc) sendListener.getTxData(); - String btcString = etAmount.getAmount(); + String btcString = etAmount.getNativeAmount(); if (btcString != null) { try { double btc = Double.parseDouble(btcString); @@ -166,7 +167,7 @@ public class SendBtcAmountWizardFragment extends SendWizardFragment { getView().post(new Runnable() { @Override public void run() { - etAmount.setRate(1.0d / orderParameters.getPrice()); + etAmount.setExchangeRate(1.0d / orderParameters.getPrice()); NumberFormat df = NumberFormat.getInstance(Locale.US); df.setMaximumFractionDigits(6); String min = df.format(orderParameters.getLowerLimit()); @@ -206,7 +207,7 @@ public class SendBtcAmountWizardFragment extends SendWizardFragment { } private void processOrderParmsError(final Exception ex) { - etAmount.setRate(0); + etAmount.setExchangeRate(0); orderParameters = null; maxBtc = 0; minBtc = 0; diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java new file mode 100644 index 00000000..713825ad --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeApiImpl.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2019 m2049r@monerujo.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://developer.android.com/training/basics/network-ops/xml + +package com.m2049r.xmrwallet.service.exchange.ecb; + +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeException; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import timber.log.Timber; + +public class ExchangeApiImpl implements ExchangeApi { + @NonNull + private final OkHttpClient okHttpClient; + @NonNull + private final HttpUrl baseUrl; + + //so we can inject the mockserver url + @VisibleForTesting + public ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient, @NonNull final HttpUrl baseUrl) { + this.okHttpClient = okHttpClient; + this.baseUrl = baseUrl; + } + + public ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient) { + this(okHttpClient, HttpUrl.parse("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")); + // data is daily and is refreshed around 16:00 CET every working day + } + + public static boolean isSameDay(Calendar calendar, Calendar anotherCalendar) { + return (calendar.get(Calendar.YEAR) == anotherCalendar.get(Calendar.YEAR)) && + (calendar.get(Calendar.DAY_OF_YEAR) == anotherCalendar.get(Calendar.DAY_OF_YEAR)); + } + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + if (!baseCurrency.equals("EUR")) { + callback.onError(new IllegalArgumentException("Only EUR supported as base")); + return; + } + + if (baseCurrency.equals(quoteCurrency)) { + callback.onSuccess(new ExchangeRateImpl(quoteCurrency, 1.0, new Date())); + return; + } + + if (fetchDate != null) { // we have data + boolean useCache = false; + // figure out if we can use the cached values + // data is daily and is refreshed around 16:00 CET every working day + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("CET")); + + int fetchWeekday = fetchDate.get(Calendar.DAY_OF_WEEK); + int fetchDay = fetchDate.get(Calendar.DAY_OF_YEAR); + int fetchHour = fetchDate.get(Calendar.HOUR_OF_DAY); + + int today = now.get(Calendar.DAY_OF_YEAR); + int nowHour = now.get(Calendar.HOUR_OF_DAY); + + if ( + // was it fetched today before 16:00? assume no new data iff now < 16:00 as well + ((today == fetchDay) && (fetchHour < 16) && (nowHour < 16)) + // was it fetched after, 17:00? we can assume there is no newer data + || ((today == fetchDay) && (fetchHour > 17)) + || ((today == fetchDay + 1) && (fetchHour > 17) && (nowHour < 16)) + // is the data itself from today? there can be no newer data + || (fxDate.get(Calendar.DAY_OF_YEAR) == today) + // was it fetched Sat/Sun? we can assume there is no newer data + || ((fetchWeekday == Calendar.SATURDAY) || (fetchWeekday == Calendar.SUNDAY)) + ) { // return cached rate + try { + callback.onSuccess(getRate(quoteCurrency)); + } catch (ExchangeException ex) { + callback.onError(ex); + } + return; + } + } + + final Request httpRequest = createHttpRequest(baseUrl); + + okHttpClient.newCall(httpRequest).enqueue(new okhttp3.Callback() { + @Override + public void onFailure(final Call call, final IOException ex) { + callback.onError(ex); + } + + @Override + public void onResponse(final Call call, final Response response) throws IOException { + if (response.isSuccessful()) { + try { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(response.body().byteStream()); + doc.getDocumentElement().normalize(); + parse(doc); + try { + callback.onSuccess(getRate(quoteCurrency)); + } catch (ExchangeException ex) { + callback.onError(ex); + } + } catch (ParserConfigurationException | SAXException ex) { + Timber.w(ex); + callback.onError(new ExchangeException(ex.getLocalizedMessage())); + } + } else { + callback.onError(new ExchangeException(response.code(), response.message())); + } + } + }); + } + + private Request createHttpRequest(final HttpUrl url) { + return new Request.Builder() + .url(url) + .get() + .build(); + } + + final private Map fxEntries = new HashMap<>(); + private Calendar fxDate = null; + private Calendar fetchDate = null; + + synchronized private ExchangeRate getRate(String currency) throws ExchangeException { + Timber.e("Getting %s", currency); + final Double rate = fxEntries.get(currency); + if (rate == null) throw new ExchangeException(404, "Currency not supported: " + currency); + return new ExchangeRateImpl(currency, rate, fxDate.getTime()); + } + + private final static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + + { + DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private void parse(final Document xmlRootDoc) { + final Map entries = new HashMap<>(); + Calendar date = Calendar.getInstance(TimeZone.getTimeZone("CET")); + try { + NodeList cubes = xmlRootDoc.getElementsByTagName("Cube"); + for (int i = 0; i < cubes.getLength(); i++) { + Node node = cubes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element cube = (Element) node; + if (cube.hasAttribute("time")) { // a time Cube + final Date time = DATE_FORMAT.parse(cube.getAttribute("time")); + date.setTime(time); + } else if (cube.hasAttribute("currency") + && cube.hasAttribute("rate")) { // a rate Cube + String currency = cube.getAttribute("currency"); + double rate = Double.valueOf(cube.getAttribute("rate")); + entries.put(currency, rate); + } // else an empty Cube - ignore + } + } + } catch (ParseException ex) { + Timber.d(ex); + } + synchronized (this) { + if (date != null) { + fetchDate = Calendar.getInstance(TimeZone.getTimeZone("CET")); + fxDate = date; + fxEntries.clear(); + fxEntries.putAll(entries); + } + // else don't change what we have + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java new file mode 100644 index 00000000..c02f89f7 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateImpl.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2019 m2049r et al. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.m2049r.xmrwallet.service.exchange.ecb; + +import android.support.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import java.util.Date; + +class ExchangeRateImpl implements ExchangeRate { + private final Date date; + private final String baseCurrency = "EUR"; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "ecb.europa.eu"; + } + + @Override + public String getBaseCurrency() { + return baseCurrency; + } + + @Override + public String getQuoteCurrency() { + return quoteCurrency; + } + + @Override + public double getRate() { + return rate; + } + + ExchangeRateImpl(@NonNull final String quoteCurrency, double rate, @NonNull final Date date) { + super(); + this.quoteCurrency = quoteCurrency; + this.rate = rate; + this.date = date; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java similarity index 74% rename from app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeApiImpl.java rename to app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java index 0babfdd8..6b936fb9 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeApiImpl.java +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017-2018 m2049r et al. + * Copyright (c) 2017-2019 m2049r et al. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.m2049r.xmrwallet.service.exchange.coinmarketcap; +package com.m2049r.xmrwallet.service.exchange.kraken; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; @@ -36,9 +36,9 @@ import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import timber.log.Timber; public class ExchangeApiImpl implements ExchangeApi { - static final String CRYPTO_ID = "328"; @NonNull private final OkHttpClient okHttpClient; @@ -47,14 +47,13 @@ public class ExchangeApiImpl implements ExchangeApi { //so we can inject the mockserver url @VisibleForTesting - ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient, final HttpUrl baseUrl) { - + public ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient, final HttpUrl baseUrl) { this.okHttpClient = okHttpClient; this.baseUrl = baseUrl; } public ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient) { - this(okHttpClient, HttpUrl.parse("https://api.coinmarketcap.com/v2/ticker/")); + this(okHttpClient, HttpUrl.parse("https://api.kraken.com/0/public/Ticker")); } @Override @@ -66,29 +65,25 @@ public class ExchangeApiImpl implements ExchangeApi { return; } - boolean inverse = false; - String fiat = null; + boolean invertQuery; - if (baseCurrency.equals(Helper.CRYPTO)) { - fiat = quoteCurrency; - inverse = false; - } - if (quoteCurrency.equals(Helper.CRYPTO)) { - fiat = baseCurrency; - inverse = true; - } - - if (fiat == null) { - callback.onError(new IllegalArgumentException("no fiat specified")); + if (Helper.BASE_CRYPTO.equals(baseCurrency)) { + invertQuery = false; + } else if (Helper.BASE_CRYPTO.equals(quoteCurrency)) { + invertQuery = true; + } else { + callback.onError(new IllegalArgumentException("no crypto specified")); return; } - final boolean swapAssets = inverse; + Timber.d("queryExchangeRate: i %b, b %s, q %s", invertQuery, baseCurrency, quoteCurrency); + final boolean invert = invertQuery; + final String base = invert ? quoteCurrency : baseCurrency; + final String quote = invert ? baseCurrency : quoteCurrency; final HttpUrl url = baseUrl.newBuilder() - .addEncodedPathSegments(CRYPTO_ID + "/") - .addQueryParameter("convert", fiat) + .addQueryParameter("pair", base + (quote.equals("BTC") ? "XBT" : quote)) .build(); final Request httpRequest = createHttpRequest(url); @@ -104,13 +99,13 @@ public class ExchangeApiImpl implements ExchangeApi { if (response.isSuccessful()) { try { final JSONObject json = new JSONObject(response.body().string()); - final JSONObject metadata = json.getJSONObject("metadata"); - if (!metadata.isNull("error")) { - final String errorMsg = metadata.getString("error"); + final JSONArray jsonError = json.getJSONArray("error"); + if (jsonError.length() > 0) { + final String errorMsg = jsonError.getString(0); callback.onError(new ExchangeException(response.code(), errorMsg)); } else { - final JSONObject jsonResult = json.getJSONObject("data"); - reportSuccess(jsonResult, swapAssets, callback); + final JSONObject jsonResult = json.getJSONObject("result"); + reportSuccess(jsonResult, invert, callback); } } catch (JSONException ex) { callback.onError(new ExchangeException(ex.getLocalizedMessage())); diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java similarity index 61% rename from app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeRateImpl.java rename to app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java index 42a16d78..0200b291 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/coinmarketcap/ExchangeRateImpl.java +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017-2018 m2049r et al. + * Copyright (c) 2017 m2049r et al. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.m2049r.xmrwallet.service.exchange.coinmarketcap; +package com.m2049r.xmrwallet.service.exchange.kraken; import android.support.annotation.NonNull; @@ -25,7 +25,6 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.util.Iterator; import java.util.NoSuchElementException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -38,7 +37,7 @@ class ExchangeRateImpl implements ExchangeRate { @Override public String getServiceName() { - return "coinmarketcap.com"; + return "kraken.com"; } @Override @@ -65,21 +64,29 @@ class ExchangeRateImpl implements ExchangeRate { ExchangeRateImpl(final JSONObject jsonObject, final boolean swapAssets) throws JSONException, ExchangeException { try { - final String baseC = jsonObject.getString("symbol"); - final JSONObject quotes = jsonObject.getJSONObject("quotes"); - final Iterator keys = quotes.keys(); - String key = null; - // get key which is not USD unless it is the only one - while (keys.hasNext()) { - key = keys.next(); - if (!key.equals("USD")) break; + final String key = jsonObject.keys().next(); // we expect only one + Pattern pattern = Pattern.compile("^X(.*?)Z(.*?)$"); + Matcher matcher = pattern.matcher(key); + if (matcher.find()) { + baseCurrency = swapAssets ? matcher.group(2) : matcher.group(1); + quoteCurrency = swapAssets ? matcher.group(1) : matcher.group(2); + } else { + throw new ExchangeException("no pair returned!"); + } + + JSONObject pair = jsonObject.getJSONObject(key); + JSONArray close = pair.getJSONArray("c"); + String closePrice = close.getString(0); + if (closePrice != null) { + try { + double rate = Double.parseDouble(closePrice); + this.rate = swapAssets ? (1 / rate) : rate; + } catch (NumberFormatException ex) { + throw new ExchangeException(ex.getLocalizedMessage()); + } + } else { + throw new ExchangeException("no close price returned!"); } - final String quoteC = key; - baseCurrency = swapAssets ? quoteC : baseC; - quoteCurrency = swapAssets ? baseC : quoteC; - JSONObject quote = quotes.getJSONObject(key); - double price = quote.getDouble("price"); - this.rate = swapAssets ? (1d / price) : price; } catch (NoSuchElementException ex) { throw new ExchangeException(ex.getLocalizedMessage()); } diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java new file mode 100644 index 00000000..bd674e64 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeApiImpl.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2019 m2049r@monerujo.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://developer.android.com/training/basics/network-ops/xml + +package com.m2049r.xmrwallet.service.exchange.krakenEcb; + +import android.support.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; +import com.m2049r.xmrwallet.util.Helper; + +import okhttp3.OkHttpClient; +import timber.log.Timber; + +/* + Gets the XMR/EUR rate from kraken and then gets the EUR/fiat rate from the ECB + */ + +public class ExchangeApiImpl implements ExchangeApi { + static public final String BASE_FIAT = "EUR"; + + @NonNull + private final OkHttpClient okHttpClient; + + public ExchangeApiImpl(@NonNull final OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + Timber.d("B=%s Q=%s", baseCurrency, quoteCurrency); + if (baseCurrency.equals(quoteCurrency)) { + Timber.d("BASE=QUOTE=1"); + callback.onSuccess(new ExchangeRateImpl(baseCurrency, quoteCurrency, 1.0)); + return; + } + + if (!Helper.BASE_CRYPTO.equals(baseCurrency) + && !Helper.BASE_CRYPTO.equals(quoteCurrency)) { + callback.onError(new IllegalArgumentException("no " + Helper.BASE_CRYPTO + " specified")); + return; + } + + final String quote = Helper.BASE_CRYPTO.equals(baseCurrency) ? quoteCurrency : baseCurrency; + + final ExchangeApi krakenApi = + new com.m2049r.xmrwallet.service.exchange.kraken.ExchangeApiImpl(okHttpClient); + krakenApi.queryExchangeRate(Helper.BASE_CRYPTO, BASE_FIAT, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate krakenRate) { + Timber.d("kraken = %f", krakenRate.getRate()); + final ExchangeApi ecbApi = + new com.m2049r.xmrwallet.service.exchange.ecb.ExchangeApiImpl(okHttpClient); + ecbApi.queryExchangeRate(BASE_FIAT, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate ecbRate) { + Timber.d("ECB = %f", ecbRate.getRate()); + double rate = ecbRate.getRate() * krakenRate.getRate(); + Timber.d("Q=%s QC=%s", quote, quoteCurrency); + if (!quote.equals(quoteCurrency)) rate = 1.0d / rate; + Timber.d("rate = %f", rate); + final ExchangeRate exchangeRate = + new ExchangeRateImpl(baseCurrency, quoteCurrency, rate); + callback.onSuccess(exchangeRate); + } + + @Override + public void onError(Exception ex) { + Timber.d(ex); + callback.onError(ex); + } + }); + } + + @Override + public void onError(Exception ex) { + Timber.d(ex); + callback.onError(ex); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java new file mode 100644 index 00000000..7fb34485 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/krakenEcb/ExchangeRateImpl.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019 m2049r et al. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.m2049r.xmrwallet.service.exchange.krakenEcb; + +import android.support.annotation.NonNull; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import java.util.Date; + +class ExchangeRateImpl implements ExchangeRate { + private final String baseCurrency; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "kraken+ecb"; + } + + @Override + public String getBaseCurrency() { + return baseCurrency; + } + + @Override + public String getQuoteCurrency() { + return quoteCurrency; + } + + @Override + public double getRate() { + return rate; + } + + ExchangeRateImpl(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, double rate) { + super(); + this.baseCurrency = baseCurrency; + this.quoteCurrency = quoteCurrency; + this.rate = rate; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java b/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java index 6e611f52..fe70b416 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java +++ b/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java @@ -58,7 +58,6 @@ import android.widget.TextView; import com.m2049r.xmrwallet.BuildConfig; import com.m2049r.xmrwallet.R; import com.m2049r.xmrwallet.model.NetworkType; -import com.m2049r.xmrwallet.model.Wallet; import com.m2049r.xmrwallet.model.WalletManager; import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; @@ -85,7 +84,7 @@ public class Helper { static public final String NOCRAZYPASS_FLAGFILE = ".nocrazypass"; - static public final String CRYPTO = "XMR"; + static public final String BASE_CRYPTO = "XMR"; static private final String WALLET_DIR = "monerujo" + FLAVOR_SUFFIX; static private final String HOME_DIR = "monero" + FLAVOR_SUFFIX; @@ -207,22 +206,32 @@ public class Helper { return d.toPlainString(); } - static public String getFormattedAmount(double amount, boolean isXmr) { + static public String getFormattedAmount(double amount, boolean isCrypto) { // at this point selection is XMR in case of error String displayB; - if (isXmr) { // XMR - long xmr = Wallet.getAmountFromDouble(amount); - if ((xmr > 0) || (amount == 0)) { + if (isCrypto) { + if ((amount >= 0) || (amount == 0)) { displayB = String.format(Locale.US, "%,.5f", amount); } else { displayB = null; } - } else { // not XMR + } else { // not crypto displayB = String.format(Locale.US, "%,.2f", amount); } return displayB; } + // min 2 significant digits after decimal point + static public String getFormattedAmount(double amount) { + if ((amount >= 1.0d) || (amount == 0)) + return String.format(Locale.US, "%,.2f", amount); + else { // amount < 1 + int decimals = 1 - (int) Math.floor(Math.log10(amount)); + if (decimals > 12) decimals = 12; + return String.format(Locale.US, "%,." + decimals + "f", amount); + } + } + static public Bitmap getBitmap(Context context, int drawableId) { Drawable drawable = ContextCompat.getDrawable(context, drawableId); if (drawable instanceof BitmapDrawable) { @@ -624,7 +633,7 @@ public class Helper { } static public ExchangeApi getExchangeApi() { - return new com.m2049r.xmrwallet.service.exchange.coinmarketcap.ExchangeApiImpl(OkHttpHelper.getOkHttpClient()); + return new com.m2049r.xmrwallet.service.exchange.krakenEcb.ExchangeApiImpl(OkHttpHelper.getOkHttpClient()); } public interface Action { diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java index 901c4e00..accb3c6a 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeEditText.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 m2049r + * Copyright (c) 2017-2019 m2049r * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.widget.AdapterView; +import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; @@ -35,50 +36,45 @@ import android.widget.Spinner; import android.widget.TextView; import com.m2049r.xmrwallet.R; -import com.m2049r.xmrwallet.model.Wallet; import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; import com.m2049r.xmrwallet.util.Helper; -import java.util.Locale; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import timber.log.Timber; public class ExchangeEditText extends LinearLayout { - String xmrAmount = null; - String notXmrAmount = null; - - void setXmr(String xmr) { - xmrAmount = xmr; - if (onNewAmountListener != null) { - onNewAmountListener.onNewAmount(xmr); + private double getEnteredAmount() { + String enteredAmount = etAmountA.getText().toString(); + try { + return Double.parseDouble(enteredAmount); + } catch (NumberFormatException ex) { + Timber.i(ex.getLocalizedMessage()); } + return 0; } - public boolean validate(double max) { + public boolean validate(double max, double min) { Timber.d("inProgress=%b", isExchangeInProgress()); if (isExchangeInProgress()) { shakeExchangeField(); return false; } boolean ok = true; - if (xmrAmount != null) { - try { - double amount = Double.parseDouble(xmrAmount); - if (amount > max) { - ok = false; - } - if (amount <= 0) { ///////////////////////////// - ok = false; - } - } catch (NumberFormatException ex) { - // this cannot be - Timber.e(ex.getLocalizedMessage()); + String enteredAmount = etAmountA.getText().toString(); + try { + double amount = Double.parseDouble(enteredAmount); + if ((amount < min) || (amount > max)) { ok = false; } - } else { + } catch (NumberFormatException ex) { + // this cannot be + Timber.e(ex.getLocalizedMessage()); ok = false; } if (!ok) { @@ -95,22 +91,29 @@ public class ExchangeEditText extends LinearLayout { tvAmountB.startAnimation(Helper.getShakeAnimation(getContext())); } - public void setAmount(String xmrAmount) { - if (xmrAmount != null) { - setCurrencyA(0); - etAmountA.setText(xmrAmount); - setXmr(xmrAmount); - this.notXmrAmount = null; - doExchange(); + public void setAmount(String nativeAmount) { + if (nativeAmount != null) { + etAmountA.setText(nativeAmount); + tvAmountB.setText(null); + if (sCurrencyA.getSelectedItemPosition() != 0) + sCurrencyA.setSelection(0, true); // set native currency & trigger exchange + else + doExchange(); } else { - setXmr(null); - this.notXmrAmount = null; tvAmountB.setText(null); } } - public String getAmount() { - return xmrAmount; + public void setEditable(boolean editable) { + etAmountA.setEnabled(editable); + } + + public String getNativeAmount() { + if (isExchangeInProgress()) return null; + if (sCurrencyA.getSelectedItemPosition() == 0) + return getCleanAmountString(etAmountA.getText().toString()); + else + return getCleanAmountString(tvAmountB.getText().toString()); } EditText etAmountA; @@ -120,23 +123,6 @@ public class ExchangeEditText extends LinearLayout { ImageView evExchange; ProgressBar pbExchange; - - public void setCurrencyA(int currency) { - if ((currency != 0) && (getCurrencyB() != 0)) { - setCurrencyB(0); - } - sCurrencyA.setSelection(currency, true); - doExchange(); - } - - public void setCurrencyB(int currency) { - if ((currency != 0) && (getCurrencyA() != 0)) { - setCurrencyA(0); - } - sCurrencyB.setSelection(currency, true); - doExchange(); - } - public int getCurrencyA() { return sCurrencyA.getSelectedItemPosition(); } @@ -167,12 +153,32 @@ public class ExchangeEditText extends LinearLayout { * * @param context the current context for the view. */ - private void initializeViews(Context context) { + void initializeViews(Context context) { LayoutInflater inflater = (LayoutInflater) context .getSystemService(Context.LAYOUT_INFLATER_SERVICE); inflater.inflate(R.layout.view_exchange_edit, this); } + void setCurrencyAdapter(Spinner spinner) { + List currencies = new ArrayList<>(); + currencies.add(Helper.BASE_CRYPTO); + setCurrencyAdapter(spinner, currencies); + } + + protected void setCurrencyAdapter(Spinner spinner, List currencies) { + currencies.addAll(Arrays.asList(getResources().getStringArray(R.array.currency))); + ArrayAdapter spinnerAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_item, currencies); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(spinnerAdapter); + } + + void setInitialSpinnerSelections(Spinner baseSpinner, Spinner quoteSpinner) { + baseSpinner.setSelection(0, true); + quoteSpinner.setSelection(0, true); + } + + private boolean isInitialized = false; + @Override protected void onFinishInflate() { super.onFinishInflate(); @@ -198,16 +204,28 @@ public class ExchangeEditText extends LinearLayout { evExchange = findViewById(R.id.evExchange); pbExchange = findViewById(R.id.pbExchange); + setCurrencyAdapter(sCurrencyA); + setCurrencyAdapter(sCurrencyB); + + post(new Runnable() { + @Override + public void run() { + setInitialSpinnerSelections(sCurrencyA, sCurrencyB); + isInitialized = true; + startExchange(); + } + }); + // make progress circle gray pbExchange.getIndeterminateDrawable(). setColorFilter(getResources().getColor(R.color.trafficGray), android.graphics.PorterDuff.Mode.MULTIPLY); - sCurrencyA.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parentView, View selectedItemView, int position, long id) { - if (position != 0) { // if not XMR, select XMR on other + if (!isInitialized) return; + if (position != 0) { // if not native, select native on other sCurrencyB.setSelection(0, true); } doExchange(); @@ -222,7 +240,8 @@ public class ExchangeEditText extends LinearLayout { sCurrencyB.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(final AdapterView parentView, View selectedItemView, int position, long id) { - if (position != 0) { // if not XMR, select XMR on other + if (!isInitialized) return; + if (position != 0) { // if not native, select native on other sCurrencyA.setSelection(0, true); } doExchange(); @@ -235,43 +254,56 @@ public class ExchangeEditText extends LinearLayout { }); } - public void doExchange() { - tvAmountB.setText(null); - // use cached exchange rate if we have it - if (!isExchangeInProgress()) { - String enteredCurrencyA = (String) sCurrencyA.getSelectedItem(); - String enteredCurrencyB = (String) sCurrencyB.getSelectedItem(); - if ((enteredCurrencyA + enteredCurrencyB).equals(assetPair)) { - if (prepareExchange()) { - exchange(assetRate); - } else { - clearAmounts(); - } - } else { - clearAmounts(); - startExchange(); - } + private boolean exchangeRateCacheIsUsable() { + return (exchangeRateCache != null) && + ((exchangeRateCache.getBaseCurrency().equals(sCurrencyA.getSelectedItem()) && + exchangeRateCache.getQuoteCurrency().equals(sCurrencyB.getSelectedItem())) || + (exchangeRateCache.getBaseCurrency().equals(sCurrencyB.getSelectedItem()) && + exchangeRateCache.getQuoteCurrency().equals(sCurrencyA.getSelectedItem()))); + } + + private double exchangeRateFromCache() { + if (!exchangeRateCacheIsUsable()) return 0; + if (exchangeRateCache.getBaseCurrency().equals(sCurrencyA.getSelectedItem())) { + return exchangeRateCache.getRate(); } else { - clearAmounts(); + return 1.0d / exchangeRateCache.getRate(); } } - private void clearAmounts() { - Timber.d("clearAmounts"); - if ((xmrAmount != null) || (notXmrAmount != null)) { - tvAmountB.setText(null); - setXmr(null); - notXmrAmount = null; + public void doExchange() { + if (!isInitialized) return; + tvAmountB.setText(null); + if (getCurrencyA() == getCurrencyB()) { + exchange(1); + return; + } + // use cached exchange rate if we have it + if (!isExchangeInProgress()) { + double rate = exchangeRateFromCache(); + if (rate > 0) { + if (prepareExchange()) { + exchange(rate); + } + } else { + startExchange(); + } } } private final ExchangeApi exchangeApi = Helper.getExchangeApi(); + // starts exchange through exchange api void startExchange() { - showProgress(); String currencyA = (String) sCurrencyA.getSelectedItem(); String currencyB = (String) sCurrencyB.getSelectedItem(); - exchangeApi.queryExchangeRate(currencyA, currencyB, + if ((currencyA == null) || (currencyB == null)) return; // nothing to do + execExchange(currencyA, currencyB); + } + + void execExchange(String currencyA, String currencyB) { + showProgress(); + queryExchangeRate(currencyA, currencyB, new ExchangeCallback() { @Override public void onSuccess(final ExchangeRate exchangeRate) { @@ -297,35 +329,30 @@ public class ExchangeEditText extends LinearLayout { }); } - public void exchange(double rate) { - Timber.d("%s / %s", xmrAmount, notXmrAmount); - if (getCurrencyA() == 0) { - if (xmrAmount == null) return; - if (!xmrAmount.isEmpty() && (rate > 0)) { - double amountB = rate * Double.parseDouble(xmrAmount); - notXmrAmount = Helper.getFormattedAmount(amountB, getCurrencyB() == 0); + void queryExchangeRate(final String base, final String quote, ExchangeCallback callback) { + exchangeApi.queryExchangeRate(base, quote, callback); + } + + private void exchange(double rate) { + double amount = getEnteredAmount(); + if (rate > 0) { + tvAmountB.setText(Helper.getFormattedAmount(rate * amount)); + } else { + tvAmountB.setText(null); + Timber.w("No rate!"); + } + } + + private String getCleanAmountString(String enteredAmount) { + try { + double amount = Double.parseDouble(enteredAmount); + if (amount >= 0) { + return Helper.getFormattedAmount(amount); } else { - notXmrAmount = ""; + return null; } - tvAmountB.setText(notXmrAmount); - Timber.d("%s / %s", xmrAmount, notXmrAmount); - } else if (getCurrencyB() == 0) { - if (notXmrAmount == null) return; - if (!notXmrAmount.isEmpty() && (rate > 0)) { - double amountB = rate * Double.parseDouble(notXmrAmount); - setXmr(Helper.getFormattedAmount(amountB, true)); - } else { - setXmr(""); - } - tvAmountB.setText(xmrAmount); - if (xmrAmount == null) { - shakeAmountField(); - } - } else { // no XMR currency - cannot happen! - Timber.e("No XMR currency!"); - setXmr(null); - notXmrAmount = null; - return; + } catch (NumberFormatException ex) { + return null; } } @@ -333,38 +360,14 @@ public class ExchangeEditText extends LinearLayout { Timber.d("prepareExchange()"); String enteredAmount = etAmountA.getText().toString(); if (!enteredAmount.isEmpty()) { - String cleanAmount = ""; - if (getCurrencyA() == 0) { - // sanitize the input - long xmr = Wallet.getAmountFromString(enteredAmount); - if (xmr >= 0) { - cleanAmount = Helper.getDisplayAmount(xmr); - } else { - cleanAmount = null; - } - setXmr(cleanAmount); - notXmrAmount = null; - Timber.d("cleanAmount = %s", cleanAmount); - if (cleanAmount == null) { - shakeAmountField(); - return false; - } - } else if (getCurrencyB() == 0) { // we use B & 0 here for the else below ... - // sanitize the input - double amountA = Double.parseDouble(enteredAmount); - cleanAmount = String.format(Locale.US, "%.2f", amountA); - setXmr(null); - notXmrAmount = cleanAmount; - } else { // no XMR currency - cannot happen! - Timber.e("No XMR currency!"); - setXmr(null); - notXmrAmount = null; + String cleanAmount = getCleanAmountString(enteredAmount); + Timber.d("cleanAmount = %s", cleanAmount); + if (cleanAmount == null) { + shakeAmountField(); return false; } - Timber.d("prepareExchange() %s", cleanAmount); } else { - setXmr(""); - notXmrAmount = ""; + return false; } return true; } @@ -372,33 +375,30 @@ public class ExchangeEditText extends LinearLayout { public void exchangeFailed() { hideProgress(); exchange(0); - if (onFailedExchangeListener != null) { - onFailedExchangeListener.onFailedExchange(); - } } - String assetPair = null; - double assetRate = 0; + // cache for exchange rate + ExchangeRate exchangeRateCache = null; public void exchange(ExchangeRate exchangeRate) { hideProgress(); - // first, make sure this is what we want - String enteredCurrencyA = (String) sCurrencyA.getSelectedItem(); - String enteredCurrencyB = (String) sCurrencyB.getSelectedItem(); - if (!exchangeRate.getBaseCurrency().equals(enteredCurrencyA) - || !exchangeRate.getQuoteCurrency().equals(enteredCurrencyB)) { + // make sure this is what we want + if (!exchangeRate.getBaseCurrency().equals(sCurrencyA.getSelectedItem()) || + !exchangeRate.getQuoteCurrency().equals(sCurrencyB.getSelectedItem())) { // something's wrong - Timber.e("Currencies don't match!"); + Timber.i("Currencies don't match! A: %s==%s B: %s==%s", + exchangeRate.getBaseCurrency(), sCurrencyA.getSelectedItem(), + exchangeRate.getQuoteCurrency(), sCurrencyB.getSelectedItem()); return; } - assetPair = enteredCurrencyA + enteredCurrencyB; - assetRate = exchangeRate.getRate(); + + exchangeRateCache = exchangeRate; if (prepareExchange()) { exchange(exchangeRate.getRate()); } } - private void showProgress() { + void showProgress() { pbExchange.setVisibility(View.VISIBLE); } @@ -409,35 +409,4 @@ public class ExchangeEditText extends LinearLayout { private void hideProgress() { pbExchange.setVisibility(View.INVISIBLE); } - - // Hooks - public interface OnNewAmountListener { - void onNewAmount(String xmr); - } - - OnNewAmountListener onNewAmountListener; - - public void setOnNewAmountListener(OnNewAmountListener listener) { - onNewAmountListener = listener; - } - - public interface OnAmountInvalidatedListener { - void onAmountInvalidated(); - } - - OnAmountInvalidatedListener onAmountInvalidatedListener; - - public void setOnAmountInvalidatedListener(OnAmountInvalidatedListener listener) { - onAmountInvalidatedListener = listener; - } - - public interface OnFailedExchangeListener { - void onFailedExchange(); - } - - OnFailedExchangeListener onFailedExchangeListener; - - public void setOnFailedExchangeListener(OnFailedExchangeListener listener) { - onFailedExchangeListener = listener; - } } diff --git a/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java new file mode 100644 index 00000000..29145883 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/widget/ExchangeOtherEditText.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2017-2019 m2049r + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// based on https://code.tutsplus.com/tutorials/creating-compound-views-on-android--cms-22889 + +package com.m2049r.xmrwallet.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.widget.Spinner; + +import com.m2049r.xmrwallet.R; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; +import com.m2049r.xmrwallet.util.Helper; + +import java.util.ArrayList; +import java.util.List; + +import timber.log.Timber; + +public class ExchangeOtherEditText extends ExchangeEditText { + /* + all exchanges are done through XMR + baseCurrency is the native currency + */ + + String baseCurrency = null; // not XMR + private double exchangeRate = 0; // baseCurrency to XMR + + public void setExchangeRate(double rate) { + exchangeRate = rate; + post(new Runnable() { + @Override + public void run() { + startExchange(); + } + }); + } + + private void setBaseCurrency(Context context, AttributeSet attrs) { + TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ExchangeEditText, 0, 0); + try { + baseCurrency = ta.getString(R.styleable.ExchangeEditText_baseSymbol); + if (baseCurrency == null) + throw new IllegalArgumentException("base currency must be set"); + } finally { + ta.recycle(); + } + } + + public ExchangeOtherEditText(Context context, AttributeSet attrs) { + super(context, attrs); + setBaseCurrency(context, attrs); + } + + public ExchangeOtherEditText(Context context, + AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + setBaseCurrency(context, attrs); + } + + @Override + void setCurrencyAdapter(Spinner spinner) { + List currencies = new ArrayList<>(); + if (!baseCurrency.equals(Helper.BASE_CRYPTO)) currencies.add(baseCurrency); + currencies.add(Helper.BASE_CRYPTO); + setCurrencyAdapter(spinner, currencies); + } + + @Override + void setInitialSpinnerSelections(Spinner baseSpinner, Spinner quoteSpinner) { + baseSpinner.setSelection(0, true); + quoteSpinner.setSelection(1, true); + } + + private void localExchange(final String base, final String quote, final double rate) { + exchange(new ExchangeRate() { + @Override + public String getServiceName() { + return "Local"; + } + + @Override + public String getBaseCurrency() { + return base; + } + + @Override + public String getQuoteCurrency() { + return quote; + } + + @Override + public double getRate() { + return rate; + } + }); + } + + @Override + void execExchange(String currencyA, String currencyB) { + if (!currencyA.equals(baseCurrency) && !currencyB.equals(baseCurrency)) { + throw new IllegalStateException("I can only exchange " + baseCurrency); + } + + showProgress(); + + Timber.d("execExchange(%s, %s)", currencyA, currencyB); + + // first deal with XMR/baseCurrency & baseCurrency/XMR + + if (currencyA.equals(Helper.BASE_CRYPTO) && (currencyB.equals(baseCurrency))) { + localExchange(currencyA, currencyB, exchangeRate); + return; + } + if (currencyA.equals(baseCurrency) && (currencyB.equals(Helper.BASE_CRYPTO))) { + localExchange(currencyA, currencyB, 1.0d / exchangeRate); + return; + } + + // next, deal with XMR/baseCurrency + + if (currencyA.equals(baseCurrency)) { + queryExchangeRate(Helper.BASE_CRYPTO, currencyB, exchangeRate, true); + } else { + queryExchangeRate(currencyA, Helper.BASE_CRYPTO, 1.0d / exchangeRate, false); + } + } + + private void queryExchangeRate(final String base, final String quote, final double factor, + final boolean baseIsBaseCrypto) { + queryExchangeRate(base, quote, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAttachedToWindow()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + ExchangeRate xchange = new ExchangeRate() { + @Override + public String getServiceName() { + return exchangeRate.getServiceName() + "+" + baseCurrency; + } + + @Override + public String getBaseCurrency() { + return baseIsBaseCrypto ? baseCurrency : base; + } + + @Override + public String getQuoteCurrency() { + return baseIsBaseCrypto ? quote : baseCurrency; + } + + @Override + public double getRate() { + return exchangeRate.getRate() * factor; + } + }; + exchange(xchange); + } + }); + } + + @Override + public void onError(final Exception e) { + Timber.e(e.getLocalizedMessage()); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchangeFailed(); + } + }); + } + }); + } +} diff --git a/app/src/main/res/layout/fragment_send_amount.xml b/app/src/main/res/layout/fragment_send_amount.xml index 7d70d95e..ba5d63a2 100644 --- a/app/src/main/res/layout/fragment_send_amount.xml +++ b/app/src/main/res/layout/fragment_send_amount.xml @@ -1,5 +1,6 @@ - + android:layout_marginBottom="16dp" + android:orientation="vertical" + app:baseSymbol="BTC" /> diff --git a/app/src/main/res/layout/view_exchange_edit.xml b/app/src/main/res/layout/view_exchange_edit.xml index 3526c2a2..295ce75d 100644 --- a/app/src/main/res/layout/view_exchange_edit.xml +++ b/app/src/main/res/layout/view_exchange_edit.xml @@ -14,7 +14,6 @@ android:layout_width="56dp" android:layout_height="wrap_content" android:layout_gravity="center" - android:entries="@array/currency" android:gravity="center" android:textAlignment="center" /> @@ -52,7 +51,6 @@ android:layout_width="56sp" android:layout_height="wrap_content" android:layout_gravity="center" - android:entries="@array/currency" android:gravity="center" android:textAlignment="center" /> diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 968c6fa3..f4d68d81 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -292,43 +292,6 @@ はい、お願いします! いいえ、結構です! - - XMR - EUR - USD - JPY - GBP - CHF - CAD - AUD - ZAR - - BRL - CLP - CNY - CZK - DKK - HKD - HUF - IDR - ILS - INR - KRW - MXN - MYR - NOK - NZD - PHP - PKR - PLN - RUB - SEK - SGD - THB - TRY - TWD - - 新しいウォレットの作成 閲覧専用ウォレットを復元 秘密鍵からウォレットを復元 diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 8d8d0181..faaca86f 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -7,4 +7,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d20c27a3..5c69ae1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,40 +297,40 @@ No thanks! - XMR EUR USD JPY GBP CHF CAD - AUD - ZAR + AUD + BGN BRL - CLP CNY CZK DKK HKD + HRK HUF IDR ILS INR + ISK KRW MXN MYR NOK NZD PHP - PKR PLN + RON RUB SEK SGD THB TRY - TWD + ZAR Create new wallet diff --git a/app/src/test/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateTest.java b/app/src/test/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateTest.java new file mode 100644 index 00000000..a9dc9fdb --- /dev/null +++ b/app/src/test/java/com/m2049r/xmrwallet/service/exchange/ecb/ExchangeRateTest.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2017 m2049r et al. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.m2049r.xmrwallet.service.exchange.ecb; + +import com.m2049r.xmrwallet.service.exchange.api.ExchangeApi; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeCallback; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeException; +import com.m2049r.xmrwallet.service.exchange.api.ExchangeRate; + +import net.jodah.concurrentunit.Waiter; + +import org.json.JSONException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.concurrent.TimeoutException; + +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import static org.junit.Assert.assertEquals; + + +public class ExchangeRateTest { + + private MockWebServer mockWebServer; + + private ExchangeApi exchangeApi; + + private final OkHttpClient okHttpClient = new OkHttpClient(); + private Waiter waiter; + + @Mock + ExchangeCallback mockExchangeCallback; + + @Before + public void setUp() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + waiter = new Waiter(); + + MockitoAnnotations.initMocks(this); + + exchangeApi = new ExchangeApiImpl(okHttpClient, mockWebServer.url("/")); + } + + @After + public void tearDown() throws Exception { + mockWebServer.shutdown(); + } + + @Test + public void queryExchangeRate_shouldBeGetMethod() + throws InterruptedException, TimeoutException { + + exchangeApi.queryExchangeRate("EUR", "USD", mockExchangeCallback); + + RecordedRequest request = mockWebServer.takeRequest(); + assertEquals("GET", request.getMethod()); + } + + @Test + public void queryExchangeRate_shouldBeEUR() + throws InterruptedException, TimeoutException { + + exchangeApi.queryExchangeRate("CHF", "USD", new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.fail(); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.assertTrue(e instanceof IllegalArgumentException); + waiter.resume(); + } + + }); + waiter.await(); + } + + + @Test + public void queryExchangeRate_shouldBeOneForEur() + throws InterruptedException, TimeoutException { + + exchangeApi.queryExchangeRate("EUR", "EUR", new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.assertEquals(1.0, exchangeRate.getRate()); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.fail(); + waiter.resume(); + } + + }); + waiter.await(); + } + + @Test + public void queryExchangeRate_wasSuccessfulShouldRespondWithUsdRate() + throws InterruptedException, JSONException, TimeoutException { + final String base = "EUR"; + final String quote = "USD"; + final double rate = 1.1043; + + MockResponse jsonMockResponse = new MockResponse().setBody(createMockExchangeRateResponse()); + mockWebServer.enqueue(jsonMockResponse); + + exchangeApi.queryExchangeRate(base, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.assertEquals(base, exchangeRate.getBaseCurrency()); + waiter.assertEquals(quote, exchangeRate.getQuoteCurrency()); + waiter.assertEquals(rate, exchangeRate.getRate()); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.fail(e); + waiter.resume(); + } + }); + waiter.await(); + } + + @Test + public void queryExchangeRate_wasSuccessfulShouldRespondWithAudRate() + throws InterruptedException, JSONException, TimeoutException { + final String base = "EUR"; + final String quote = "AUD"; + final double rate = 1.6246; + + MockResponse jsonMockResponse = new MockResponse().setBody(createMockExchangeRateResponse()); + mockWebServer.enqueue(jsonMockResponse); + + exchangeApi.queryExchangeRate(base, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.assertEquals(base, exchangeRate.getBaseCurrency()); + waiter.assertEquals(quote, exchangeRate.getQuoteCurrency()); + waiter.assertEquals(rate, exchangeRate.getRate()); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.fail(e); + waiter.resume(); + } + }); + waiter.await(); + } + + + @Test + public void queryExchangeRate_wasSuccessfulShouldRespondWithZarRate() + throws InterruptedException, JSONException, TimeoutException { + final String base = "EUR"; + final String quote = "ZAR"; + final double rate = 16.3978; + + MockResponse jsonMockResponse = new MockResponse().setBody(createMockExchangeRateResponse()); + mockWebServer.enqueue(jsonMockResponse); + + exchangeApi.queryExchangeRate(base, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.assertEquals(base, exchangeRate.getBaseCurrency()); + waiter.assertEquals(quote, exchangeRate.getQuoteCurrency()); + waiter.assertEquals(rate, exchangeRate.getRate()); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.fail(e); + waiter.resume(); + } + }); + waiter.await(); + } + + @Test + public void queryExchangeRate_wasNotSuccessfulShouldCallOnError() + throws InterruptedException, JSONException, TimeoutException { + mockWebServer.enqueue(new MockResponse().setResponseCode(500)); + exchangeApi.queryExchangeRate("EUR", "USD", new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.fail(); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.assertTrue(e instanceof ExchangeException); + waiter.assertTrue(((ExchangeException) e).getCode() == 500); + waiter.resume(); + } + + }); + waiter.await(); + } + + @Test + public void queryExchangeRate_unknownAssetShouldCallOnError() + throws InterruptedException, JSONException, TimeoutException { + MockResponse jsonMockResponse = new MockResponse().setBody(createMockExchangeRateResponse()); + mockWebServer.enqueue(jsonMockResponse); + exchangeApi.queryExchangeRate("EUR", "ABC", new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.fail(); + waiter.resume(); + } + + @Override + public void onError(final Exception e) { + waiter.assertTrue(e instanceof ExchangeException); + ExchangeException ex = (ExchangeException) e; + waiter.assertTrue(ex.getCode() == 404); + waiter.assertEquals(ex.getErrorMsg(), "Currency not supported: ABC"); + waiter.resume(); + } + + }); + waiter.await(); + } + + static public String createMockExchangeRateResponse() { + return "